Mocking HTTP Call in Golang

tech · Apr 11, 2021 · ~4 min
Photo by @kellysikkema on Unsplash
Photo by @kellysikkema on Unsplash

This blog post code is running on go1.16.2

API Interface to be tested

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type API interface {
  // this function will do http call to external resource
  FetchPostByID(ctx context.Context, id int) (*APIPost, error)
}

type APIPost struct {
  ID     int    `json:"id"`
  UserID int    `json:"userId"`
  Title  string `json:"title"`
  Body   string `json:"body"`
}

We can simply mock the API interface FetchPostByID function result in our unit test by creating a mock implementation of the API interface like this:

API Mock implementation

1
2
3
4
5
type APIMock struct {}

func (a APIMock) FetchPostByID(ctx context.Context, id int) (*APIPost, error) {
  return nil, fmt.Errorf(http.StatusText(http.StatusNotFound))
}

But by doing that, it doesn’t increase the test coverage and it will skip the rest of the code inside the FetchPostByID real implementation.

So we’re gonna make the testable real implementation first of the API interface.

Implementation

To mock only the HTTP Call, we need to create http.Client mock implementation. the real http.Client have Do function that executed whenever we want to do HTTP call. So we need to mock the Do function. Because http.Client doesn’t have any interface implemented by it, we need to create one.

HTTP Client Mock

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
type HTTPClient interface {
  Do(*http.Request) (*http.Response, error)
}

type HTTPClientMock struct {
  // DoFunc will be executed whenever Do function is executed
  // so we'll be able to create a custom response
  DoFunc func(*http.Request) (*http.Response, error)
}

func (H HTTPClientMock) Do(r *http.Request) (*http.Response, error) {
  return H.DoFunc(r)
}

API Implementation Struct

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
func NewAPI(client HTTPClient, baseURL string, timeout time.Duration) API {
  return &apiV1{
    c:       client,
    baseURL: baseURL,
    timeout: timeout,
  }
}

type apiV1 struct {
  // we need to put the http.Client here
  // so we can mock it inside the unit test
  c       HTTPClient
  baseURL string
  timeout time.Duration
}

func (a apiV1) FetchPostByID(ctx context.Context, id int) (*APIPost, error) {
  u := fmt.Sprintf("%s/posts/%d", a.baseURL, id)

  ctx, cancel := context.WithTimeout(ctx, a.timeout)
  defer cancel()

  req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
  if err != nil {
    return nil, err
  }

  resp, err := a.c.Do(req)
  if err != nil {
    return nil, err
  }
  defer resp.Body.Close()

  if resp.StatusCode != http.StatusOK {
    return nil, fmt.Errorf(http.StatusText(resp.StatusCode))
  }

  var result *APIPost
  return result, json.NewDecoder(resp.Body).Decode(&result)
}

Unit Test

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
var (
  // our custom client
  client = &HTTPClientMock{}
  // our api
  api = NewAPI(client, "", 0)
)

func TestApiV1_FetchPostByID(t *testing.T) {
  // test table
  tt := []struct {
    // Body mock the response body
    Body string
    // StatusCode mock the response statusCode
    StatusCode int

    // Expected Result
    Result *APIPost
    // Expected Error
    Error error
  }{
    {
      Body:       `{"userId": 1,"id": 1,"title": "test title","body": "test body"}`,
      StatusCode: 200,
      Result: &APIPost{
        ID:     1,
        UserID: 1,
        Title:  "test title",
        Body:   "test body",
      },
      Error: nil,
    },
    {
      Body:       `{"userId": 2,"id": 2,"title": "test title2","body": "test body2"}`,
      StatusCode: 200,
      Result: &APIPost{
        ID:     2,
        UserID: 2,
        Title:  "test title2",
        Body:   "test body2",
      },
      Error: nil,
    },
    {
      Body:       ``,
      StatusCode: http.StatusNotFound,
      Result:     nil,
      Error:      fmt.Errorf(http.StatusText(http.StatusNotFound)),
    },
    {
      Body:       ``,
      StatusCode: http.StatusBadRequest,
      Result:     nil,
      Error:      fmt.Errorf(http.StatusText(http.StatusBadRequest)),
    },
  }

  for _, test := range tt {
    // we adjust the DoFunc for each test case
    client.DoFunc = func(r *http.Request) (*http.Response, error) {
      return &http.Response{
        // create the custom body
        Body: io.NopCloser(strings.NewReader(test.Body)),
        // create the custom status code
        StatusCode: test.StatusCode,
      }, nil
    }

    // execute the func
    p, err := api.FetchPostByID(context.Background(), 0)

    // validation
    if err != nil && err.Error() != test.Error.Error() {
      t.Fatalf("want %v, got %v", test.Error, err)
    }

    if !reflect.DeepEqual(p, test.Result) {
      t.Fatalf("want %v, got %v", test.Result, p)
    }
  }
}

Because we only change the http.Client, our FetchPostByID func is tested as it is except for this line:

1
resp, err := a.c.Do(req)

Because the a.c.Do is already adjusted with our mock DoFunc inside the unit test, the a.c.Do behavior will be changed according to this line:

1
2
3
4
5
6
client.DoFunc = func(r *http.Request) (*http.Response, error) {
  return &http.Response{
    Body: io.NopCloser(strings.NewReader(test.Body)),
    StatusCode: test.StatusCode,
  }, nil
}

Let’s run the test

1
$ go test ./... -race -coverprofile /tmp/coverage.out && go tool cover -html=/tmp/coverage.out

Test Coverage
Test Coverage

Thank you for reading!

· · ·

Love This Content?

Kindly support me via Bitcoin, Ko-fi, Trakteer, or just continue to read another content. Any kind of supports is greatly appreciated!