Using Interfaces in Go to Quickly Write Lightweight Unit Tests

All posts

This is the last article in a three-part series detailing the CUJO AI GORE team’s experiences with using Golang to implement the GORE service, with a focus on Golang’s interface pattern. See Part 1 for an introduction to interfaces in Go and Part 2 to find out how Golang interfaces lead to the decoupling of parts.

In the previous article, we learned how smart usage of Go’s interfaces can decouple various parts of our software. In this post, we’ll focus on one of the benefits that comes from this decoupling: easier unit testing. We’ll see, in practical terms, how interfaces can help us write lightweight unit tests quickly.

What Is Unit Testing?

Click this box for a quick recap of what unit testing is.

Unit Testing Without Interfaces

When we started writing unit tests for GORE’s API layer, we realized that our first implementation passed specific types to our API handler functions. These types were themselves data storage / retrieval mechanisms (which we internally called data sources) and relied on specific infrastructure to operate correctly (think multiple Redis instances). Setting up this infrastructure for unit testing was out of the question since unit tests are supposed to be fast and lightweight.

This realization convinced us that these data sources needed to be abstracted under a single Go interface, and that our API handlers should receive this interface as an argument, as opposed to the specific data source types. Interestingly, this aligns with the “D” – dependency inversion principle – in the vaunted SOLID design principles.

Unit Testing with Interfaces

Once we had designed our data source interface, and our API handlers had been modified to accept any type that implemented this interface, it was straightforward to create a “mock” data source type that implemented this interface. Externally, these mock data sources seemed to emulate the behavior of the real “data sources”, but internally they did not use any external infrastructure or resources. Instead, they simply produced hard coded behavior based on an expected input. For our unit tests, we passed these mock data sources to the API handler function we were testing – no external Redis instances were required.

Unit Testing in Practice

To see how this works in practical terms, let’s extend the Go Storage Server example from Part 2, where we already have the FileStore interface implemented and could write a unit test for the DownloadFileHandler function.

To follow along, you will need to have completed the hands-on sections from Part 2 or, alternatively, you can clone the Go Storage Server repo. Also, we’ll be using the stretchr/testify Go package – it contains sub-packages that facilitate testing, mocking, and asserting the behavior of Go code. We’re specifically interested in its mock sub-package for writing our mock FileStore type.

Start by navigating to the project directory in your console window and install the package by running the following command:

go get github.com/stretchr/testify 

You’ll also need to update the project’s vendoring afterwards by running the following command:

go gmod vendor

Next, go ahead and create a new file called storage_mock.go inside the storage sub-directory – we’ll be implementing our mock type that uses the FileStore interface in this file. Place the following code inside it:

package storage 

import ( 

    "github.com/stretchr/testify/mock" 
) 

type MockStorage struct { 
    mock.Mock 
} 

func (m *MockStorage) Upload(fileContent []byte) (string, error) { 
    args := m.Called(fileContent) 
    return args.String(0), args.Error(1) 
} 

func (m *MockStorage) Download(fileName string) ([]byte, error) { 
    args := m.Called(fileName) 
    return args.Get(0).([]byte), args.Error(1) 
} 

Above, we’ve created a MockStorage type that returns specified values for a specified input. Note that this mock storage type satisfies the FileStore interface. We’ll be making use of the Download method when unit testing our DownloadFileHandler function.

Next, create a new file called handlers_test.go inside the server sub-directory – we’ll write our unit test inside this file. Place the following code inside this file:

package server 

import ( 

    "go_storage_server/storage" 
    "net/http" 
    "net/http/httptest" 
    "net/url" 
    "testing" 

)  

func TestDownloadFileHandler(t *testing.T) { 
    // Create mock storage and specify mock behavior 
    mockStorage := new(storage.MockStorage) 
    mockedFileName := "mystoredfile.zip" 
    mockFileContents := "boguszipfilecontents" 
    mockStorage.On("Download", mockedFileName).Return([]byte(mockFileContents), nil) 

    // Create an http request to pass to our handler, with the "filename" query parameter appended 
    queries := url.Values{} 
    queries.Add("filename", mockedFileName) 
    req, err := http.NewRequest("GET", "/download", nil) 
    if err != nil { 
        t.Fatal(err) 
    } 
    req.URL.RawQuery = queries.Encode() 

     // We create a ResponseRecorder (which satisfies http.ResponseWriter) to record the response. 
    rr := httptest.NewRecorder() 
    handler := DownloadFileHandler(mockStorage) 

    // Our handlers satisfy http.Handler, so we can call their ServeHTTP method 
    // directly and pass in our Request and ResponseRecorder. 
    handler.ServeHTTP(rr, req) 

    // Check the response body is what we expect. 
    if rr.Body.String() != mockFileContents { 
        t.Errorf("DownloadFileHandler returned unexpected body: got %v want %v", 
            rr.Body.String(), mockFileContents) 
    } 
} 

 

The test involves creating a MockStorage instance and specifying what should be returned by its “Download” function when it is passed a filename called “mystoredfile.zip”. Next, we create an HTTP request with the same filename passed as a query parameter. We then create an API handler function from the DownloadFileHandler function which receives the MockStorage instance as an argument.

The previously created request is then passed through the handler function, and its response is recorded. We are then able to check if the response matches our expectations – if we attempt to download a file with the filename “mystoredfile.zip”, the DownloadFuncHandler returns a file, the contents of which are “boguszipfilecontents”.

It is worth noting here that the MockStorage instance we passed to our DownloadFuncHandler didn’t really read or store any file in the background – we were not relying on any external file storage mechanism to run our unit test, thereby keeping it fast and lightweight. This would become more relevant if the underlying file storage mechanism was expensive (think dozens of unit testing sessions being run daily that require cloud storage!).

Auto-generating Interface Mocks with Mockery and Testify

A cool thing we discovered about using interfaces in Go: you can auto-generate mocks for them with the handy vektra/mockery tool. In the example above, we were able to write our own mock quickly but, in a real-world scenario where the interface keeps evolving, you would need to go back to your hand-crafted mock implementations to update them each time the interface changes, the mockery package helps you out.

To install mockery, open a console window, navigate to the project directory, and run the following command:

go install github.com/vektra/mockery/v2@latest 

Next, navigate to the storage sub-directory and run the following command to auto-generate the mock for the FileStore interface:

mockery –all --keeptree

You will notice that the storage sub-directory now contains a mocks sub-directory, where the auto-generated mock implementation resides. You can swap out our manual mock implementation with the auto-generated one from the server/handlers_test.go file by changing this line:

mockStorage := new(storage.MockStorage) 

to this one:

mockStorage := mocks.NewFileStore(t) 

Re-run the test and you’ll observe the same result.

Conclusion

In this post, we took a deep dive into exploring how interfaces help us write unit tests for APIs quickly and efficiently. Through effective use of abstraction and decoupling, you can ensure that your API layer is independent of any specific underlying types, so you can implement your unit tests without having to worry about instantiating specific types and provisioning external dependencies that are required to run them.

We also saw how Go’s open-source ecosystem facilitates auto-generating mocks for interfaces, which we felt were a game changer for maintaining unit tests that rely on interfaces.

Other posts by Syed Wajahat Ali

Labs
Labs