Unit Testing with Auto-generated REST Clients

Posted on | ~7 mins

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:

  1. Generate mocks with a tool such as gomock and write the proper expects on the methods
  2. 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.

There is also another way to write the tests without mocking the client: using a custom 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?