Test driven API integration with Go

Posted on Mon 25 May 2015 in programming by ryanday • Tagged with golang, tdd

Test Driven design is more than simply testing individual functions before you write then. There are several components of your API client that you want to address:

  • How the workflow will appear from a user's perspective.
  • How the API meta control will work (pages, a cursor, offset/limit, authenticate, this sort of thing).
  • How the backend API developers will shake their heads as you call the same method over and over during testing.

We want to find a testing method that solves all these concerns. We should verify our core functionality is working. Then we can play with the user's workflow and test out different ideas without breaking that functionality. We can implement caching, iterating large data, or pieces of data, on top of the API after the tests are complete. Having the tests in place gives us the freedom to experiment.

As always, let's check out Github to see how others are testing their client side APIs. There are lots of examples of course, but the common methods boil down to:

  • Use a key and the live API (twitter)
  • Don't test the actual calls, just the response handling (facebook)
  • Mock very short responses to test calls (tumblr, flikr)
  • Use a mix of mocks and live (google github)

Each of these examples is worth taking a look at. Of these, Google's seems the most complete. Their test setup creates a global Mux and httptest.Server for that Mux. Each unit test attaches a handler to the Mux. They also have live tests that don't run in their CI. These are specifically to test whether server side functionality has changed.

Mocking the calls

Really, we want to test the API as the user would consume it. We don't want to simply test all the little pieces and assume a nice workflow. So we need to write a test like the user would write their code.

api := CreateNewApi(&client)

controlChan := make(chan bool)
responseChan, _ := api.SomeMethod(controlChan)

CreateNewApi() most likely has us speaking with the actual API server. The API url is hardcoded in the library somewhere. So our test will probably hit the live server. Using the live API for testing violates my third concern. Fortunately we can rewrite the client's transport method to point to a test server. We do this with http.RoundTripper.

type RewriteTransport struct {
        Transport http.RoundTripper
        URL       *url.URL
}

func (t RewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
        // note that url.URL.ResolveReference doesn't work here
        // since t.u is an absolute url
        req.URL.Scheme = t.URL.Scheme
        req.URL.Host = t.URL.Host
        req.URL.Path = path.Join(t.URL.Path, req.URL.Path)
        rt := t.Transport
        if rt == nil {
                rt = http.DefaultTransport
        }
        return rt.RoundTrip(req)
}

client := &http.Client{
        Transport: RewriteTransport{
                URL: testUrl,
        },
}

Minimal response test

With the custom transport, we are able to implement handlers for our tests. These handlers can return minimal expected responses. Take Flickr for example:

// https://github.com/masci/flickr/blob/master/response_test.go
func TestExtra(t *testing.T) {
        bodyStr := `<?xml version="1.0" encoding="utf-8" ?>
                <rsp stat="ok">
                        <user id="23148015@N00">
                        <username>Massimiliano Pippi</username>
                        </user>
                        <brands>
                          <brand id="apple">Apple</brand>
                        </brands>
                </rsp>`

        flickrResp := &BasicResponse{}

        response := &http.Response{}    // HL
        response.Body = NewFakeBody(bodyStr)    // HL
        err := parseApiResponse(response, flickrResp)   // HL
        Expect(t, err, nil)
        Expect(t, flickrResp.Extra != "", true)
}

Flickr uses a transport rewrite to provide a minimal response to test. This gets us away from using the live API to test data. But the method being testing is parseApiResponse(). This isn't how the user will implement the API.

This Tumblr API uses the same minimal response method. It also tests functions in the same way a user would call them.

// https://github.com/MariaTerzieva/gotumblr/blob/master/gotumblr_test.go
func TestFollowing(t *testing.T) {
        setup()
        defer teardown()

        handleFunc("/v2/user/following", "GET", `{"response": {"total_blogs": 1}}`, map[string]string{}, t)

        following := client.Following(map[string]string{}).Total_blogs
        want := int64(1)
        if following != want {
                t.Errorf("Following returned %+v, want %v", following, want)
        }
}

Hosting test data

This is a method I've been using that I haven't seen in the wild. It takes advantage of all the API consoles out there. You make a call using the provider's API console in a sandbox, and copy the (usually) JSON response into your test_data/ directory. Then simply serve the appropriate JSON file during the test.

Using a recent integration for the Lingotek API, here is an example directory structure:

src/
  lingotek/
    test_data/
      projects_full.json
      projects_three.json

These two json files contain a full API response, including headers, offset/limit/total counts, and other meta information. In our mock handler, we can control which of those files is served based on query parameters.

To begin, we have a function which takes a channel for returning a *http.Request, and a function which returns the name of the file which contains our appropriate response.

func createTestServer(requestChan chan *http.Request, getFileName func(*http.Request) string) httptest.Server, http.Client) {
        handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                var testData io.Reader
                var err error

                // getFileName will determine which response to load based on the request
                testData, err = os.Open(getFileName(r))
                if err != nil {
                        http.NotFound(w, r)
                        return
                }

                io.Copy(w, testData)

                requestChan <- r
        })

        server := httptest.NewServer(handler)
        testUrl, _ := url.Parse(server.URL)

        client := http.Client{
                Transport: RewriteTransport{
                        URL: testUrl,
                },
        }

        // Return our test server and a client which points to it
        return server, client
}

Using this test server and client, we write a test like this:

func TestGetProjects(t *testing.T) {

        // This is a simple handler, we always return the same response file
        f := func(r *http.Request) string {
                return "test_data/projects_full.json"
        }

        rCh := make(chan *http.Request, 1)
        server, client := createTestServer(rCh, f)
        defer server.Close()
        defer close(rCh)

        api := NewApi("dummyToken", &client)
        resp, err := api.GetProjectsPage(0, 10)
        if err != nil {
                t.Error(err)
        }

        if len(resp) != 10 {
                t.Errorf("Expected 10 projects, got %d\n", len(resp))
        }
}

But we can get more complex. Let's say we want to test our ListProject function. ListProject() returns a channel that the caller can iterate over. Since the ListProject API method only returns 10 objects at a time, our method must make the call again at a new offset. Then the method must send the new objects to the channel.

Our mockHandler function has inspect the request, and send a different response for the different offset.

mockHandler := func(r *http.Request) (fileName string) {
        offset := r.URL.Query().Get("offset")

        // If our client requested the next page,
        // send a different json response
        if offset == "10" {
                fileName = "test_data/projects_three.json"
        } else {
                fileName = "test_data/projects_full.json"
        }

        return
}

/* . . . */
doneChan := make(chan bool)
projectChan, _ := api.ListProjects(doneChan)

cNum := 0
for _ = range projectChan {
        cNum += 1
}

if cNum != 13 {
        t.Errorf("Expected 13 projects, got %d", cNum)
}

This test verifies that we can iterate over a channel while the API method is making multiple calls to the backend.

Full source is on Github.