#development #golang #http #testing

If you are new to testing, you might want to read my post How I write Go Tests first.

When writing tests in Go, it's often to test the connection with an external API. Because your tests need to be consistent, you need to be sure that you test the connection to the API but also cover items such as invalid URLs to the API, connection timeouts, body read errors, …

In many cases, writing your tests this way makes testing faster as well as they are using a local webserver instead of a remote one. You basically rule out all possible errors which can occur when connecting to a remote server (DNS problems, network problems, …).

Therefor, it would be handy to be able to mock the webserver part for certain tests. The good news is that the testing package in Golang has this covered with the net/http/httptest package.

Let's start with a basic example on how we might want to test a simple API client which looks as follows:

 1package exampleapiclient
 2
 3import (
 4    "io/ioutil"
 5    "net/http"
 6    "net/url"
 7    "time"
 8)
 9
10type APIClient struct {
11    URL        string
12    httpClient *http.Client
13}
14
15func NewAPIClient(url string, timeout time.Duration) APIClient {
16    return APIClient{
17        URL: url,
18        httpClient: &http.Client{
19            Timeout: timeout,
20        },
21    }
22}
23
24func (apiClient APIClient) ToUpper(input string) (string, error) {
25
26    req, err := http.NewRequest("GET", apiClient.URL, nil)
27    if err != nil {
28        return "", err
29    }
30
31    q := req.URL.Query()
32    q.Set("input", input)
33    req.URL.RawQuery = q.Encode()
34
35    resp, err := apiClient.httpClient.Do(req)
36    if err != nil {
37        return "", err
38    }
39    defer resp.Body.Close()
40
41    result, err := ioutil.ReadAll(resp.Body)
42    if err != nil {
43        return "", err
44    }
45
46    return string(result), nil
47
48}

There are a few things in the design of the API client which will help up with testing:

  • The URL is configurable so that we can override it during testing
  • The timeout of the HTTP requests is also overridable

Testing a valid request

Let's start with testing a valid request:

 1package exampleapiclient_test
 2
 3import (
 4    "net/http"
 5    "net/http/httptest"
 6    "strings"
 7    "testing"
 8    "time"
 9
10    exampleapiclient "github.com/pieterclaerhout/example-apiclient"
11    "github.com/stretchr/testify/assert"
12)
13
14func TestValid(t *testing.T) {
15
16    input := "expected"
17
18    s := httptest.NewServer(
19        http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
20            w.Write([]byte(strings.ToUpper(input)))
21        }),
22    )
23    defer s.Close()
24
25    apiClient := exampleapiclient.NewAPIClient(s.URL, 5*time.Second)
26
27    actual, err := apiClient.ToUpper(input)
28    assert.NoError(t, err, "error")
29    assert.Equal(t, strings.ToUpper(input), actual, "actual")
30
31}

This is a very basic test which basically check if you are getting the expected response, but without relying on the live server.

The first thing we are doing here is to use httptest.NewServer to create a new server. Calling this allows you to define a handler function and also allows you to retrieve the URL to that server by using s.URL. You can use this for example to return static responses to check for example the parsing part of your package.

The next step is to create a new API client which connects to the URL of the server. That can be retrieved by s.URL. Then we execute and check that what we are getting the expected result.

Don't forget to call the defer s.Close() function to clean up after running the test.

Using testdata

The beauty of this approach is that any HTTP handler can be used. A neat trick is to store the expected data in the testdata folder and expose that to the HTTP server you have just created. Knowing that the working directory of your tests is the path of the package, you can do something as follows (provided you have a file testdata/index.txt which contains the string expected):

 1package exampleapiclient_test
 2
 3import (
 4    "io/ioutil"
 5    "net/http"
 6    "net/http/httptest"
 7    "testing"
 8
 9    "github.com/stretchr/testify/assert"
10)
11
12func TestBasic(t *testing.T) {
13
14    s := httptest.NewServer(
15        http.FileServer(http.Dir("testdata")),
16    )
17    defer s.Close()
18
19    resp, err := http.Get(s.URL + "/index.txt")
20    assert.NoError(t, err, "error")
21
22    defer resp.Body.Close()
23    actual, err := ioutil.ReadAll(resp.Body)
24
25    assert.Equal(t, expected, string(actual), "actual")
26
27}

Testing invalid URLs

A next thing you probably want to check is how your package reacts when somebody passes in an invalid URL to the API. Before you can test this, yo need to ensure that your API package allows the user to override the URL.

1func TestInvalidURL(t *testing.T) {
2
3    apiClient := exampleapiclient.NewAPIClient("ht&@-tp://:aa", 5*time.Second)
4
5    actual, err := apiClient.ToUpper("hello")
6    assert.Error(t, err)
7    assert.Empty(t, actual)
8
9}

This test covers the fact that you might enter an invalid URL in the API client.

Testing read timeouts

Another thing you need to cover with your tests is that you are checking if you are properly handling time-outs in the API calls. That's why being able to set the timeout is important. A test can be written as follows:

 1func TestTimeout(t *testing.T) {
 2
 3    s := httptest.NewServer(
 4        http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
 5            time.Sleep(50 * time.Millisecond)
 6            w.Write([]byte("actual"))
 7        }),
 8    )
 9    defer s.Close()
10
11    apiClient := exampleapiclient.NewAPIClient(s.URL, 25*time.Millisecond)
12
13    actual, err := apiClient.ToUpper("hello")
14    assert.Error(t, err)
15    assert.Empty(t, actual)
16
17}

We are setting a timeout of 25 ms in the API client, but we are doing a sleep of 50 ms in the handler. If all goes well, the test should result in an error instead of a proper result.

Testing body read errors

The last thing you need to cover is what happens if there is a body read error. You can do this as follows:

 1func TestBodyReadError(t *testing.T) {
 2
 3    s := httptest.NewServer(
 4        http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
 5            w.Header().Set("Content-Length", "1")
 6        }),
 7    )
 8    defer s.Close()
 9
10    apiClient := exampleapiclient.NewAPIClient(s.URL, 25*time.Millisecond)
11
12    actual, err := apiClient.ToUpper("hello")
13    assert.Error(t, err)
14    assert.Empty(t, actual)
15
16}

The trick is to send an invalid Content-Length. This causes a body read error.

This way, we have the complete package covered:

$  go test -cover
PASS
coverage: 100.0% of statements
ok      github.com/pieterclaerhout/example-apiclient    0.077s

You can find the complete example app here.