When we set about the rewriting of the DNSimple domain API, one of the pre-requisites we defined was that the API should be consumed by clients in several programming languages. We knew that we would implement an initial set of official clients, and we selected Ruby, Go, and Elixir to start with. Node was added as a fourth client later on. To support these different clients, with the small team that we have, we knew we'd need have good tests that ran quickly. The idea of using HTTP fixtures developed from this need.

The first step to this path of testing is to find something that could be used across multiple languages. There are various HTTP stubbing and mocking libraries available in different languages, and they often provide a way to capture the HTTP request/response and store it for later use. Some examples include A in Ruby, B in Elixir, and C in Node. The challenge when trying to apply this concept across multiple languages is that each uses a different representation of the request and response. To address this issue, we decided to use the output of curl commands for the response fixture data.

An Example

Let's start with a simple example: a whoami request to determine if I am authenticated correctly. Here is what the fixture looks like:

HTTP/1.1 200 OK
Server: nginx
Date: Fri, 18 Dec 2015 15:19:37 GMT
Content-Type: application/json; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
X-RateLimit-Limit: 4000
X-RateLimit-Remaining: 3991
X-RateLimit-Reset: 1450451976
ETag: W/"5ea6326bc1a8e83e5c156c564f2559f0"
Cache-Control: max-age=0, private, must-revalidate
X-Request-Id: 15a7f3a5-7ee5-4e36-ac5a-8c21c2e1fffd
X-Runtime: 0.141588
Strict-Transport-Security: max-age=31536000

{"data":{"user":null,"account":{"id":1,"email":"example-account@example.com"}}}

This fixture can be obtained by using the API itself and then manipulated to clean data, and includes the HTTP response line, all of the response headers, as well as the response body. It is, essentially, the raw HTTP response.

You could execute the following against our sandbox environment to get the latest data for the fixture:

curl -i https://api.sandbox.dnsimple.com/v2/whoami -H "Authorization: Bearer token"

Where token is replaced by an account token generated on the sandbox site as described in the support article API access tokens.

So now that we have the fixture data, we need to use it. Let's take a look across all of the different API clients.

Ruby

First up, Ruby:

describe Dnsimple::Client, ".identity" do

  subject { described_class.new(base_url: "https://api.dnsimple.test", access_token: "a1b2c3").identity }


  describe "#whoami" do
    before do
      stub_request(:get, %r{/v2/whoami$}).
          to_return(read_http_fixture("whoami/success.http"))
    end

    it "builds the correct request" do
      subject.whoami

      expect(WebMock).to have_requested(:get, "https://api.dnsimple.test/v2/whoami").
          with(headers: { 'Accept' => 'application/json' })
    end
  end
end

I've taken the above snippet directly from the Ruby client specs. The important line for getting the fixture data is in the before block:

stub_request(:get, %r{/v2/whoami$}).
    to_return(read_http_fixture("whoami/success.http"))

The stub_request method is provided by the Webmock library, whereas the read_http_fixture method is a helper method defined in the RSpecSupportHelpers module. The code matches any GET request for /v2/whoami and returns the content from the file fixtures.http/whoami/success.http.

Next, let's take a look at the Golang example.

Golang

The Golang library also has a fixtures.http directory, and the fixtures are generally the same as the ones in the developer repo (although right now syncronization is manual, so there may be some variations - this is something we will fix in the future). In Go, tests are placed in the src directory next to the implementation. In the case of the whoami call, the implementation is in the dnsimple/identity.go file and the test is in dnsimple/identity_test.go. Here is the code for testing the whoami endpoint in Go:

func TestAuthService_Whoami(t *testing.T) {
    setupMockServer()
    defer teardownMockServer()

    mux.HandleFunc("/v2/whoami", func(w http.ResponseWriter, r *http.Request) {
        httpResponse := httpResponseFixture(t, "/whoami/success.http")

        testMethod(t, r, "GET")
        testHeaders(t, r)

        w.WriteHeader(httpResponse.StatusCode)
        io.Copy(w, httpResponse.Body)
    })

    whoamiResponse, err := client.Identity.Whoami()
    if err != nil {
        t.Fatalf("Auth.Whoami() returned error: %v", err)
    }

    whoami := whoamiResponse.Data
    want := &WhoamiData{Account: &Account{ID: 1, Email: "example-account@example.com"}}
    if !reflect.DeepEqual(whoami, want) {
        t.Errorf("Auth.Whoami() returned %+v, want %+v", whoami, want)
    }
}

The key bit of code for the response stubbing is the following section:

mux.HandleFunc("/v2/whoami", func(w http.ResponseWriter, r *http.Request) {
    httpResponse := httpResponseFixture(t, "/whoami/success.http")

    testMethod(t, r, "GET")
    testHeaders(t, r)

    w.WriteHeader(httpResponse.StatusCode)
    io.Copy(w, httpResponse.Body)
})

Similar to the Ruby code, this test handles calls to /v2/whoami. It does so by attaching a handler function in the muxer (which stands for HTTP request multiplexer, basically a request router and dispatcher) wherein the response is written from the fixture returned by httpResponseFixture, which is implemented in dnsimple/dnsimple_test.go. The code above writes the method, headers, status code, and body to the http.ResponseWriter that is passed to the handler function when the test is executed.

Node.js

Let's look at one more example: the Node.js example.

describe('identity', function() {
  describe('#whoami when authenticated as account', function() {
    var fixture = testUtils.fixture('whoami/success_account.http');
    var endpoint = nock('https://api.dnsimple.com')
                     .get('/v2/whoami')
                     .reply(fixture.statusCode, fixture.body);

    it('produces an account', function(done) {
      dnsimple.identity.whoami().then(function(response) {
        expect(response.data.user).to.be.null;
        var account = response.data.account;
        expect(account.id).to.eql(1);
        expect(account.email).to.eql('example-account@example.com');
        done();
      }, function(error) {
        done(error);
      });
    });
  });
});

The Node.js client uses nock, an HTTP stubbing and mocking library for Node apps. As with the other client libraries, the fixtures are in fixtures.http. The Node test code is similar to the Go test code in that it parses the fixture, creates an in-memory representation, and then uses that to fill out the mocked response. The important bit of code is here:

var fixture = testUtils.fixture('whoami/success_account.http');
var endpoint = nock('https://api.dnsimple.com')
                 .get('/v2/whoami')
                 .reply(fixture.statusCode, fixture.body);

The fixture is loaded into a JS object, and the reply is mocked using the status code and the body from that object.

Conclusion

Hopefully this introduction to how we mock HTTP responses will help you as you implement your own APIs and client libraries. Powerful APIs with easy-to-use client libraries are a great way to ensure that systems can be integrated simply.

Using a standard representation for HTTP fixtures that can be generated from curl has a number of benefits:

  • It is reusable across different languages
  • Fixtures are easy to obtain using a simple tool without the need to construct elaborate data structures
  • Fixtures are easy to understand as they are basically just an HTTP response as a document
  • Standardizing the approach to mocking HTTP responses reduces the time needed to implement client libraries

To help share these fixtures amongst the team, and beyond, we've published them in the DNSimple Developer site repo on GitHub. If you're interested in implementing a client library for a language we don't yet support, let us know! We'd love to help you in the process and provide guidance, and eventually even bring your client into the official set of supported libraries.