Oftentimes in our application we have to call HTTP endpoints to send or request data. If we are lucky, the service maintainer provides a client implementation to make it easier to use the provided REST API from our application directly. If we are less lucky there is an OpenApi spec that we can use to generate the client so we don’t have to craft the requests and process the responses manually. Even if we are unlucky we can create a spec file with the api endpoints that we are interested in. But how should we write unit tests when we use such autogenerated clients?
Inspecting the generated client
For this exercise we will use the OpenAPI Generator to generate a Go client. We will use the Swagger Petstore example spec for our service. The developers of OpenAPI Generator provide us the spec in yaml format. You can find the OpenAPI spec and the generated go client in this module.
So what do we have in this client? The module exposes an APIClient
struct with three fields, one for each endpoint:
type APIClient struct {
cfg *Configuration
common service // Reuse a single struct instead of allocating one for each service on the heap.
// API Services
PetAPI *PetAPIService
StoreAPI *StoreAPIService
UserAPI *UserAPIService
}
The three fields are also structs, implementing methods to interact with the different endpoints.
Since these fields are pointer to structs, there is no way we can mock it in our tests. The go OpenAPI Generator however
has an option to generate interfaces with the generateInterfaces
option. If we did this, there would be two solutions
for us:
- Generate mocks with a tool such as gomock and write the proper expects on the methods
- Write mocks manually and provide implementation for the methods
Both approach can be unpleasant as we have to implement two methods for each path we want to test. For example, to add
a new pet, we have to use two methods of PetAPIService
: AddPet
and AddPetExecute
. The test code can quickly become
cluttered with mock setups and expectations. It can take away the attention from the actual logic that we want to test.
Also, we will mock the methods that are called but we won’t assert on that those methods actually calls the endpoint with
the parameters we want. This could lead to hidden bugs, for example wrong HTTP method or wrong query parameter used.
So instead of generating the client with interfaces and mocking those, we will once again use httptest
package to fake
the petstore server. You can check here how we used httptest
for integration test earlier.
RoundTripper
that mimics communicating with the server but never actually makes a network request. This however doesn’t make things easier: we still
have to handle routing based on the path if the client is used with different endpoints on the same host. Moreover
we lose the ability to mimic network level errors: timeout or unreachable hosts for example.Use-case of the Petstore API
We will write a simple function that takes an id as a parameter. We will query the /pet/{petId}
endpoint with the received
id. If the pet exists and available, we we place an order on the /store/order
endpoint. Lastly, we will return the order id
and an appropriate error depending on the responses received.
This example is very simple but effective to demonstrate the challenges we face when we have to test autogenerated clients. There are two different endpoints to call with different HTTP methods. Let’s see how the implementation of such method would look like.
func (c *client) Buy(ctx context.Context, id int64) (int64, error) {
pet, resp, err := c.client.PetAPI.GetPetById(ctx, id).Execute()
if err != nil {
return 0, err
}
defer resp.Body.Close()
if pet.GetStatus() != "available" {
return 0, errors.New("pet not available")
}
request := petstore_client.NewOrder()
request.PetId = pet.Id
order, orderResp, err := c.client.StoreAPI.PlaceOrder(ctx).Order(*request).Execute()
if err != nil {
return 0, err
}
defer orderResp.Body.Close()
return order.GetId(), nil
}
This is an oversimplified implementation with very basic error handling. The more interesting part is how to write unit tests for it. Let’s jump right into it.
Setting up the test server
We will call two different endpoints so the easiest way is to setup a ServerMux
for basic routing: one for the
/pet/
and one for the /store/
path, and pass that as a parameter to httptest.NewServer
call.
mux := http.NewServeMux()
mux.HandleFunc("/pet/", func(w http.ResponseWriter, r *http.Request) {
// logic here
})
mux.HandleFunc("/store/", func(w http.ResponseWriter, r *http.Request) {
// logic here
})
srv := httptest.NewServer(mux)
Very simple, right? Now let’s populate those handlers. First things first, let’s assert that the request path and HTTP method matches what we expect. After that, we can write the expected header and response body. But how should we determine what is expected? Let’s take a step back and see how our test will look like.
Table driven testing
Table driven testing in Go is a very powerful approach: it let’s you write your testing logic only once, while you define the input and expected output in a table: typically in map where the key is the test name. With subtests we can iterate through this table and have each row a separate subtest. If we want to cover a new use-case we only need to add a new row to the testing table, a new entry to the map.
Let’s see now how our table driven test looks like.
subject := NewClient(srv.URL)
tests := map[string]struct {
input int64
expected int64
err error
}{
"success": {
input: availablePet,
expected: availablePetOrderId,
},
"not_available": {
input: notAvailablePet,
err: ErrNotAvailable,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
id, err := subject.Buy(context.Background(), test.input)
require.ErrorIs(t, err, test.err)
assert.Equal(t, test.expected, id)
})
}
We have an input
as the id
of the pet that we want to buy. We also have an expected
field that represents the expected
order id returned by the Buy
method and err
for any expected errors. We iterate over the map, call Buy
with the input
parameter and assert on the outputs. So what we see here, is that we will call the server with a predefined id that we can
use in the server to determine what response we should give for each request. Easy!
Define the possible responses
The only thing we have to do now is to map the possible pet id to a response for both endpoints. That is easy to do with map as we can see below:
var pets = map[int64]struct {
pet petstore_client.Pet
responseCode int
}{
availablePet: {
pet: petstore_client.Pet{
Id: petstore_client.PtrInt64(availablePet),
Status: petstore_client.PtrString("available"),
},
responseCode: http.StatusOK,
},
notAvailablePet: {
pet: petstore_client.Pet{
Id: petstore_client.PtrInt64(availablePet),
Status: petstore_client.PtrString("pending"),
},
responseCode: http.StatusOK,
},
}
var orders = map[int64]struct {
order petstore_client.Order
responseCode int
}{
availablePet: {
order: petstore_client.Order{
Id: petstore_client.PtrInt64(availablePetOrderId),
PetId: petstore_client.PtrInt64(availablePet),
},
responseCode: http.StatusOK,
},
}
We define two maps pets
and orders
to be used in the two endpoint /pet/
and /order/
. The values contain the response code and the pet that we want to return. Note that for the key we use the same variable that we use as the input
in our tests. When the server receives the response it will look into the map to retrieve the appropriate response and status code
to be sent to the client.
pet, ok := pets[petId]
require.True(t, ok, "pet not found %d", petId)
w.Header().Add("content-type", "application/json")
w.WriteHeader(pet.responseCode)
require.NoError(t, json.NewEncoder(w).Encode(pet.pet))
The logic is the same for the /orders
endpoint as well.
Conclusion
After this simple example it is up to us to extend with more use-cases or apply the approach on our projects. As we saw, it is fairly easy to setup a test server to mock the endpoints. It is easier to maintain as we don’t have to rely on the name of functions and methods of the autogenerated client compared to using mocks. When the generated code changes our tests won’t break as long as the request path doesn’t change.
For this exercise we used the httptest
package to setup a simple test server and
we used table driven testing to write the unit test. But how cool would it be if the [petstore_client] provided the test server
fully configured?