Unit testing multiple HTTP calls in GoLang
What is Unit Testing?
Unit testing is a software testing technique in which individual units or components of a software application are tested in isolation from the rest of the application to ensure that each unit works correctly. It involves writing test cases for individual units of code, executing these tests, and evaluating the results to ensure that the code behaves as expected.
The purpose of unit testing is to detect and isolate defects in individual units of code before they are integrated into the more extensive system. By catching errors early in the development process, fixing them is easier and less expensive. Additionally, unit testing helps ensure that code changes do not introduce new errors into the system.
HTTP Calls
HTTP (Hypertext Transfer Protocol) calls refer to the communication between a client and a server over the internet using the HTTP protocol. When a client makes an HTTP call to a server, it sends a request message to the server and waits for a response message.
Example Call
func DirectGet() ([]byte, error) {
res, err := http.Get("https://httpbin.org/get")
if err != nil {
println("Failed to make request err: ", err)
return nil, err
}
resByte, err := io.ReadAll(res.Body)
if err != nil {
println("Failed to read body err: ", err)
return nil, err
}
defer res.Body.Close()
return resByte, nil
}
To write a unit test for DirectGet
the method there is no other way than to make an actual HTTP call.
Let’s change this to use a Client
HTTP Call with a client:
func Client() ([]byte, error) {
url := "https://httpbin.org/get"
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
c := &http.Client{Timeout: time.Second * 10}
res, err := c.Do(req)
if err != nil {
return nil, err
}
resByte, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
defer res.Body.Close()
return resByte, nil
}
http.Client
does have a method called Do.
Do(req http.Request) (http.Response, error)
But we are not injecting the client into the method instead we are creating the client so it still can’t be mocked. We need to use an interface to mock the client.
Let’s add an interface with the Do method
type Requester interface {
Do(req *http.Request) (*http.Response, error)
}
Also, a struct to pass the client.
type HTTPRequest struct {
Client Requester
}
Let's refactor the method now to use the struct.
func (h *HTTPRequest) GetHTTPBin() ([]byte, error) {
url := "https://httpbin.org/get"
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
res, err := h.Client.Do(req)
if err != nil {
return nil, err
}
resByte, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode > 299 {
return resByte, errors.New("http status not okay")
}
return resByte, nil
}
Now call the method as
client := &http.Client{Timeout: time.Second * 10}
r := HTTPRequest{
Client: client,
}
r.GetHTTPBin()
Writing Unit Test for GetHTTPBin
method.
We will need a struct to mock the Do Request, also other structs to capture requests, and also provide mock responses with code and error.
type mockReq struct {
method string
url string
payload []byte
}
type mockRes struct {
response []byte
statusCode int
err error
}
// Mock client will be used to make the request
type mockClient struct {
req mockReq
res mockRes
}
Now the mock Do
method
func (m *mockClient) Do(req *http.Request) (*http.Response, error) {
m.req = mockReq{
method: req.Method,
url: req.URL.String(),
}
if req.Body != nil {
b, err := req.GetBody()
if err != nil {
return nil, err
}
payload, err := io.ReadAll(b)
if err != nil {
return nil, err
}
m.req.payload = payload
}
r := io.NopCloser(bytes.NewReader(m.res.response))
res := &http.Response{
StatusCode: m.res.statusCode,
Body: r,
}
return res, m.res.err
}
Let’s prepare for a successful response.
Mock Client
client := &mockClient{
res: mockRes{
statusCode: 200,
response: []byte(`{"message": "I am okay"}`)},
}
Now let's use the client with the HTTPRequester.
req := HTTPRequest{
Client: client,
}
Call the method with the mocked client.
res, err := req.GetHTTPBin()
This shouldn't return an error so we can check.
assert.NoError(t, err, "unexpected error %v", err) (using
github.com/stretchr/testify/assert
for assertions)
Method and URL Check for the request
assert.Equal(t, client.req.method, "GET")
assert.Equal(t, client.req.url, "https://httpbin.org/get")
Also, let's say we need to check the failure. To mock error from the client we can create a client as:
client := &mockClient{
res: mockRes{err: errors.New("something went wrong")},
}
To mock an error as an HTTP request not okay we can
client := &mockClient{
res: mockRes{statusCode: 400},
}
This is okay when we have only one HTTP request, let’s say we have multiple HTTP calls from a method and need to mock each of them. The above mock will return the last payload and the response would be the same for each request.
Let's separate making HTTP calls to make it easier.
func (h *HTTPRequest) makeHTTPCall(method, url string, payload []byte) ([]byte, error) {
client := h.Client
req, err := http.NewRequest(method, url, bytes.NewBuffer(payload))
if err != nil {
return nil, err
}
res, err := client.Do(req)
if err != nil {
return nil, err
}
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode > 299 {
return nil, fmt.Errorf("http status code %d", res.StatusCode)
}
return body, nil
}
When we have to make multiple requests from the same method.
func (h *HTTPRequest) getUUID() (string, error) {
url := "https://httpbin.org/uuid"
resp, err := h.makeHTTPCall("GET", url, nil)
if err != nil {
return "", err
}
type uuid struct {
UUID string `json:"uuid"`
}
var u uuid
err = json.Unmarshal(resp, &u)
if err != nil {
return "", err
}
return u.UUID, nil
}
func (h *HTTPRequest) postUUID(uuid string) error {
url := "https://httpbin.org/post"
body := map[string]string{"id": uuid}
bt, _ := json.Marshal(body)
_, err := h.makeHTTPCall("POST", url, bt)
return err
}
func (h *HTTPRequest) normalGet() ([]byte, error) {
url := "https://httpbin.org/get"
return h.makeHTTPCall("GET", url, nil)
}
// GetHTTPBin gets data from http bin
func (h *HTTPRequest) MultipleHTTP() ([]byte, error) {
id, err := h.getUUID()
if err != nil {
return nil, err
}
err = h.postUUID(id)
if err != nil {
return nil, err
}
return h.normalGet()
}
MultipleHTTP()
method makes three requests first, gets UUID, next use the response from there to make a post
request, and lastly get
request.
So we would need to mock three HTTP calls here.
Let’s modify our mock client to have multiple req/res.
type mockClient struct {
called int
req map[int]mockReq
res map[int]mockRes
}
Now we have called
to check how many times the request is made
Map of request and response to track multiple req and res.
Last we need the Do
method to capture requests properly and pass the response for each request.
func (m *mockClient) Do(req *http.Request) (*http.Response, error) {
m.called++
r := mockReq{
method: req.Method,
url: req.URL.String(),
}
if req.Body != nil {
b, err := req.GetBody()
if err != nil {
return nil, err
}
payload, err := io.ReadAll(b)
if err != nil {
return nil, err
}
r.payload = payload
}
m.req[m.called] = r
mockResponse := m.res[m.called]
respBody := io.NopCloser(bytes.NewReader(mockResponse.response))
res := &http.Response{
StatusCode: mockResponse.statusCode,
Body: respBody,
}
return res, mockResponse.err
}
We have all set up for mocks, now let's prepare for the test.
First, we need a mock client with proper responses.
client := &mockClient{
res: map[int]mockRes{
1: mockRes{
statusCode: 200,
response: []byte(`{"uuid": "3c95e984-b50c-471b-8f67-c2ace3809b06"}`),
},
2: mockRes{
statusCode: 200,
response: []byte(`{"message": "all good from post"}`),
},
3: mockRes{
statusCode: 200,
response: []byte(`{"message": "all good from get"}`),
},
},
}
So for the map, the key is which HTTP request is being made.
Now call MultipleHTTP()
method and check the responses
_, err := req.MultipleHTTP()
assert.NoError(t, err, "unexpected error %v", err)
// testing first http call
assert.Equal(t, "GET", client.req[1].method)
assert.Equal(t, "https://httpbin.org/uuid", client.req[1].url)
// testing second http call
assert.Equal(t, "POST", client.req[2].method)
assert.Equal(t, "https://httpbin.org/post", client.req[2].url)
assert.Equal(t, []byte(`{"id":"3c95e984-b50c-471b-8f67-c2ace3809b06"}`), client.req[2].payload)
// testing third http call
assert.Equal(t, "GET", client.req[3].method)
assert.Equal(t, "https://httpbin.org/get", client.req[3].url)
Failure cases can be tested in a similar way as above. Fail the first request.
client := &mockClient{
req: map[int]mockReq{},
res: map[int]mockRes{
1: {
statusCode: 400,
response: []byte(`{"message": "something went wrong"}`),
},
2: {
statusCode: 200,
response: []byte(`{"message": "all good from post"}`),
},
3: {
statusCode: 200,
response: []byte(`{"message": "all good from get"}`),
},
},
}
Now we can test the HTTP call is made only once.
assert.Equal(t, 1, client.called, "expected to have called once.")
Similarly, we can fail any one of the requests.
You can find all the code here.