DEV Community

Tobiloba Ogundiyan
Tobiloba Ogundiyan

Posted on • Originally published at aramide.dev

1 1 1 1 1

Testing Go HTTP Clients: Mocks Servers, Edge Cases and Fuzzing

tobiloba aramide ogundiyan

Introduction

I was building a project that involves making http requests to a public api returning a JSON response,
and I hit a snag testing this area of the project.
This has always been a challenge for many because,
when testing http requests, there are important considerations to keep in mind.

  • validate end-to-end behavior within API clients – headers, query parameters and deserialization in one go.
  • catch real-world issues that can actually occur when making real calls.

Unit testing with round tripper mocks alone won't mimic this behavior,
so I settled for a test server with mocked responses.
This post is a walkthrough of the process, step by step, showing how it all works in practice.

Client Set Up

first, make sure you have go version 1.24.2 installed. then, create a new api.go file in your desired directory:

echo "package api" > api.go
Enter fullscreen mode Exit fullscreen mode

now let's define our types…first for the JSON response:

type (

APIResponse struct {
Response []*FixturesResponse `json:"response"`
}
FixturesResponse struct {
Fixture Fixture `json:"fixture"`
}
Fixture struct {
Id        int    `json:"id"`
Timezone  string `json:"timezone"`
Date      string `json:"date"`
}
)
Enter fullscreen mode Exit fullscreen mode

And then the API client:

type APIClient struct {
Client   *http.Client
Timezone string
Apikey   string
Date     string
BaseUrl  string
}
Enter fullscreen mode Exit fullscreen mode

Remember to import:

"net/http"
Enter fullscreen mode Exit fullscreen mode

the APIClient struct holds all dependencies needed to make the http requests. instead of hardcoding *http.Client we
added it as type so we can easily replace it with our test server client during testing.
now, let's define the constructor function for our APIClient:

func NewAPIClient(baseURL, apikey, date, timezone string, client *http.Client) *APIClient {
return &APIClient{
BaseUrl:  baseURL,
Apikey:   apikey,
Client:   client,
Timezone: timezone,
Date:     date,
}
}
Enter fullscreen mode Exit fullscreen mode

we have only defined our baseURL type, we need to define the method to build our fixtures url which would be our full
URL path to be used during the request:

func (api *APIClient) buildFixturesUrl() string {
params := url.Values{}
params.Add("date", api.Date)
params.Add("timezone", api.Timezone)
return fmt.Sprintf("%s/fixtures?%s", api.BaseUrl, params.Encode())
}
Enter fullscreen mode Exit fullscreen mode

Remember to import:

"net/url"
"fmt"
Enter fullscreen mode Exit fullscreen mode

Now, let's define our Getfixtures() method where we tie everything together:

func (api *APIClient) GetFixtures() ([]*FixturesResponse, error) {
fullUrl := api.buildFixturesUrl()

req, err := http.NewRequest("GET", fullUrl, nil)
if err != nil {
return nil, err
}

req.Header.Add("x-rapidapi-host", "v3.football.api-sports.io")
req.Header.Add("x-rapidapi-key", api.Apikey)
req.Header.Add("Accept", "application/json")
resp, err := api.Client.Do(req)

if err != nil {
return nil, err
}

if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
defer resp.Body.Close()
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
}

defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var wrapper APIResponse
if err = json.Unmarshal(body, &wrapper); err != nil {
return nil, err
}
return wrapper.Response, nil

}
Enter fullscreen mode Exit fullscreen mode

In the above method, we started by building out our full url path for the request, creating a request object, adding our
headers as specified by the apifutbol documentation. And using our injected api client to make the request. We handled
errors where possible, decoded the JSON response and, finally returned the response.
Remember to import :

"encoding/json"
"io"
Enter fullscreen mode Exit fullscreen mode

Testing

Now let's create the test file in same directory as the api.go file:

echo "package api" > api_test.go
Enter fullscreen mode Exit fullscreen mode

Let's create our go mod file in the same directory using

go mod init playground
Enter fullscreen mode Exit fullscreen mode

Then let's import the required packages in our api_test.go :

import (
    "encoding/json"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
    "net/http"
    "net/http/httptest"
    "testing"
)
Enter fullscreen mode Exit fullscreen mode

Then we can always fetch our dependencies using:

go mod tidy

Enter fullscreen mode Exit fullscreen mode

Now let's create our mock response and initialize our helpers:

func TestAPIClient_GetFixtures(t *testing.T) {  
    assert := assert.New(t)  
    require := require.New(t)  
    mockResponse := APIResponse{  
       Response: []*FixturesResponse{{Fixture: Fixture{Id: 123, Timezone: "Europe/London", Date: "2023-10-10"}}},  
    }  
}
Enter fullscreen mode Exit fullscreen mode

The assert and require helpers are just like if statements but with extra context to validate if our mocked response matches our expected outcome.
Now let's
extend our test function by creating our test server and the assert statements that will occur when the request hits:

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        assert.Equal("/fixtures", r.URL.Path)
        assert.Equal("today", r.URL.Query().Get("date"))
        assert.Equal("Europe/London", r.URL.Query().Get("timezone"))
        assert.Equal("testkey", r.Header.Get("x-rapidapi-key"))
        w.Header().Set("Content-Type", "application/json")
        _ = json.NewEncoder(w).Encode(mockResponse)
    }))
    defer ts.Close()

Enter fullscreen mode Exit fullscreen mode

If you notice, we are not just asserting only the responses alone; it now includes URL paths and header params, which is beneficial to catch bugs if our url path is wrong. lets initialize our NewAPIClient using the constructor function. here, we inject our test server client alongside parameters :

client := NewAPIClient(ts.URL, "testkey", "today", "Europe/London", ts.Client())
Enter fullscreen mode Exit fullscreen mode

Now let's complete our test function by calling the Getfixtures() method and asserting any possible outcomes:

fixtures, err := client.GetFixtures()  
require.NoError(err)  
assert.Len(fixtures, 1)  
assert.Equal(123, fixtures[0].Fixture.Id)
Enter fullscreen mode Exit fullscreen mode

we run the test by using:

go test -cover
Enter fullscreen mode Exit fullscreen mode

Our tests should pass. You should see something like this:

PASS
coverage: 74.1% of statements
ok            0.214s
Enter fullscreen mode Exit fullscreen mode

We shouldn't aim for 100% coverage by adding tests that are not meaningful. We have only tested if everything works correctly. What if it doesn't. For instance, what happens when our api client gets passed a bad url?

Let's figure this out by adding another test case:

func TestAPIClient_GetFixtures_BadUrl(t *testing.T) {
    client := &http.Client{}
    baseUrl := "http://example.com/foo%zz"
    api := NewAPIClient(baseUrl, "testkey", "today", "Europe/London", client)
    _, err := api.GetFixtures()
    require.Error(t, err)

}
Enter fullscreen mode Exit fullscreen mode

You should be wondering why we did not use a test server?
We don't need a test server for it because it would panic before any request happens. Let's test again with the cover flag, and we should see an increase in coverage.

What if our server returns a bad JSON. In most cases it is rare, but it can also occur.
Let's figure it out by adding another test case:

func TestAPIClient_GetFixtures_BadJSON(t *testing.T) {
    ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        _, _ = w.Write([]byte(`{not-json`))
    }))
    ts.Close()
    client := NewAPIClient(ts.URL, "testkey", "today", "Europe/London", ts.Client())
    _, err := client.GetFixtures()
    require.Error(t, err)
}
Enter fullscreen mode Exit fullscreen mode

Our tests should pass, and our coverage should also increase. We still have more edge cases to cover such as non 200 status codes. You can easily add this by creating another test case, but now writing this to the header and body to touch that error path:

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error": "something went wrong"}`))

Enter fullscreen mode Exit fullscreen mode

If you notice, An error path such as the body, err := io.ReadAll(resp.Body) can be hard to hit because most servers…
even if they return an error, they will write a response body providing insights on what's happening. However,
the body can also be nil if we chose to ignore errors from bad urls or redirects during our request.
We will see this in practice by fuzzing our function.

Fuzzing

We shouldn't think of fuzzing as looking for errors. fuzzing is basically throwing some garbage into our code to look for hidden bugs that can cause our code to panic or crash in production.

Traditional tests check if code works by giving it known good inputs. fuzzing is giving bad and weird inputs to see what happens.

First lets ignore the error from request response:

resp, _ := api.Client.Do(req)  

//if err != nil {  
//  return nil, err  
//}
Enter fullscreen mode Exit fullscreen mode

Now let's throw in some bad urls in a fuzz function:

func FuzzAPIClient_GetFixtures(f *testing.F) {  
    f.Add("http://example.com")  
    f.Add("http://example.com/%zz")  
    f.Add("://broken-url")  

    client := &http.Client{}  

    f.Fuzz(func(t *testing.T, url string) {  
       api := NewAPIClient(url, "key", "today", "Europe/London", client)  
       _, _ = api.GetFixtures()  
    })  
}
Enter fullscreen mode Exit fullscreen mode

the inputs in a fuzz function are called seeds. if you notice, we are ignoring both response and errors from our get fixtures function because we are not interested in it. let's test our code using

go test -fuzz Fuzz
Enter fullscreen mode Exit fullscreen mode

the code should panic with a nil pointer dereference:

apprentice@Apps-MacBook-Pro pg % go test -fuzz Fuzz
--- FAIL: TestAPIClient_GetFixtures_BadJSON (0.00s)
panic: runtime error: invalid memory address or nil pointer dereference [recovered]
        panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x2 addr=0x10 pc=0x1006e1648]

Enter fullscreen mode Exit fullscreen mode

What caused the panic?
Because we ignored the error from the request and io attempts to read from a response body that's nil. Handling errors can be cumbersome in Go, but it's a safety net to prevent these kinds of hidden bugs.

return the code back to normal and run the fuzz function again and you will notice everything works perfectly fine.

You can check this repo for the full code used in this post

Summary

Together, we have covered:

  • Using httptest.Server to test real HTTP behavior including headers, query params, and JSON decoding.
  • Handling edge cases like bad URLs, non-200 status code, and malformed JSON responses.
  • Testing failure scenarios using real HTTP servers instead of mocking internal client behavior.
  • Fuzzing the client with malformed input to catch panics and unexpected crashes.
  • Reinforcing the importance of proper error handling and guarding against nil response bodies.

— Tobiloba Ogundiyan

Top comments (1)

Collapse
 
ogundiyantobiloba profile image
Tobiloba Ogundiyan • Edited

if anything here breaks or sparks curiosity...please feel free to reach out