Go Unit Testing at Compass

The following insight was written by Chloé Powell, Senior Software Engineer at Compass.

At Compass, one of our driving principles is “Quality First.” One of the primary ways that we ensure that we are producing high-quality code is through unit testing.

Unit testing is often considered one of the building blocks of an effective testing strategy. When we write unit tests, the goal is to assert that one piece of logic does what it is intended to do, and therefore unit tests will typically have the narrowest scope of all the tests in a codebase. To understand the value of unit tests, though, it’s important to discuss their role within a larger test suite.

Most projects will benefit from various testing approaches to ensure that all of the pieces of a system are working together as expected. At Compass, we primarily rely on unit testing to test the logical components within a service or program. We also leverage integration tests to assert that those various components work together as intended and end-to-end tests to assess the flow of how our services and applications communicate. In this blog, we’ll dive into the unit testing strategy that we use at Compass.

The basics of Go unit testing

A unit test’s ultimate goal is to assert that one small “unit” of logic behaves as expected. We confirm this by calling the function within a test and asserting that the actual result matches the expected result.

Go’s standard testing package, testing, provides tools and support for writing tests.

Running the command go test will execute any test function found within the package that is being tested.

Test functions are found within a test suite — this is a file that ends in “_test.go”, which lives in the same package that it is testing. These files are excluded from regular package builds.

test function in Go has several characteristics:

  1. The signature of the function is of the format func TestXxx(*testing.T)
  2. The word or phrase following the word “Test” in the function name starts with a capital letter.
  3. The first and only parameter of the function is of type *testing.T
  4. The function calls methods of type T to indicate a failure — ex. t.Error() or t.Fail()

The code snippet below shows a function that we’d like to test, Sub, which accepts 2 integer parameters and returns the result of subtracting the second parameter from the first parameter.

Using Go’s testing library, a successful unit test could look like the below example. To test that the actual result matches the expected result, we use an if statement. If the actual value does not match the expected value, we return a failure.

Handling many test cases with test tables

The above example is relatively straightforward because the method we’re testing always does the same thing, and the expected result is easy to predict. If we imagine a slightly more complicated function, however, writing a thorough test becomes more involved.

When I review code, I look for tests to document the logic that they are testing. Thorough testing should cover the happy path, as well as expected error routes, and strong tests will be clear and purposeful about the behavior that they are responsible for testing.

We now have 3 cases to test. We could add 3 tests, one for each case, but adding 3 different tests for 9 lines of code is not necessarily ideal with respect to clarity and legibility. One way of testing multiple cases for a single function in a more readable way is by leveraging table-driven tests in Go.

The example below tests our new Sub function by using a table which provides the inputs and outputs as a slice of structs. Using this method, we’re able to write one test that tests multiple cases in a way that is readable, compact, and easy to expand to cover additional cases.

Testify for enhanced assertions and mocking

At Compass, we enhance our Go testing tools through the use of various libraries. My team uses the stretchr/testify library. Testify has many useful packages that allow us to build comprehensive test suites. The assert package provides us with a large array of methods that we can use for asserting success within a test. Other than a variety of comparison methods, these assertions also result in readable failure descriptions and code simplification — every if statement” in a test can become one assertion.

Using Testify, we’re able to simplify the example table test above by using an assertion instead of an if statement.

In addition to added readability and simplicity, Testify assertions provide us with more informative error messages. Below is an example of the output of a failing test written with Go’s standard testing package.

In comparison, this is the same failing test output, using a Testify assertion rather than an if statement.

Separate layers for logic isolation and testing

On my squad, I push my team to organize our code into independent layers that represent the flow of data through our system. We find that a major benefit of following this “separation of concerns” principle for logic organization is that we are able to test each layer independently of the other parts of the system that they rely on.

In order to help teams create new services following this pattern (as well as other Compass best practices and patterns) we have a reference service that can be easily copied and modified. This example service encourages separation of concerns organization and comes with example tests that demonstrate how to use the separate layers to effectively test our code. This tool has been particularly useful for encouraging unit test best practices, following the unit test pattern that we will walk through below.

To better explain the benefits of this structure, imagine that we have a library service. Users can check a book out as long as the book is not currently checked out. Now, let’s imagine that we have an endpoint for checking out a book.

The logic for this endpoint can be split into a few layers:

  1. Server layer or “controller”. Handles the incoming request and transformation of the response. In this example, this layer might make sure that the userID and the bookID were provided to the endpoint.
  2. Business logic. In this example, this layer might check that the book being requested is available.
  3. Data. In this example, this layer might be responsible for updating the book record as “checked out” in the database.

Often, the “business logic” will live in a service layer of the application. We’ll call this layer the LibraryApi. It will need to access the database in order to check the current status of the book and potentially update it, so we will need an interface responsible for that data layer — let’s call that the LibraryDB interface. Because this flow needs to use the data interface, LibraryDB is passed as a dependency of the LibraryApi.

The LibraryApi has a method, CheckOutBook, which expects a book ID and a user ID. The method is responsible for ensuring that the book is available and then recording it as checked out, through a method of the DB layer.

The DB layer is responsible for communicating with the database. In this case, it will do that by offering two methods defined in the interface below.

Now that we’ve defined the LibraryApi object and the interface that it relies on for database communication, we can start testing this layer. By defining each of these layers as an interface and injecting those interfaces into the components that depend on them, we can test each layer independently through mocking.

Controlling dependencies with mocked interfaces

At Compass, we leverage mocking to test each layer of an application without also testing the components that they rely on. A mock will meet all the requirements of the interface it is mocking so that it can stand in its place during a test run. This allows us to essentially run a controlled experiment against each method that we are testing, by controlling every variable except that specific method, and forego the necessity of actually having to set up the method’s dependencies.

In the example above, let’s say we wanted to write a unit test for a small component of the business logic layer of the library service. We know that the business logic layer depends on the data layer, meaning that if we were to call a business logic method that uses the data layer, then we’d also be testing the data layer.

We can get around this by generating a mock of the data layer’s interface and then injecting that into the business logic layer, rather than injecting the actual data interface. We can then tell the test how the data layer will behave without actually using it, effectively isolating the business logic method as the only variable in the test.

Two useful libraries that provide tools for mock generation are golang/mock and vektra/mockery. Teams here at Compass will use either, and then couple that with Testify’s mock package for mock usage in tests. Below is the mock for the LibraryDB interface, generated through Mockery.

We can now inject this mock of the LibraryDB into the LibraryApi implementation that we want to test because it meets all the LibraryDB interface requirements. In our test, we can add a method that provides us with an implementation of the LibraryApi that uses the mock to make it’s DB requests.

The Testify mock package provides a wide range of tools for stubbing out methods of mock interfaces and ensuring that the mock is interacted with as expected. Through the use of this package, we are able to determine how the LibraryDB interface will act during the test run. We’re also able to ensure that the proper expected parameters are sent to the methods. The below test tests the “happy path” of the CheckOutBook method — meaning that the book is available and subsequently successfully checked out.

We can test this case by stubbing out the GetBook method to return a db.DBBook object where the CheckOutByUserID field is empty. This means that the book is available. We then stub out the CheckOutBook method of LibraryDB to expect the bookID value and the userID value as parameters and complete successfully. If the test passes successfully, then we’ll know that the method handles the case that the book is available as expected.

If either LibraryDB mock methods receive values that they don’t expect, they’ll cause an error. Let’s say the logic is accidentally written to request the book record with the userID rather than the bookID. Because we have the mock stubbed to expect “book1” as a parameter, and not “user1”, the test will fail.

We can also test the case that the book is unavailable by stubbing out the GetBook method to return a book unavailable for check out. We do so by having the GetBook method return a book object that has the CheckOutByUserID field set. We know that the test will error if a method is called that has not been stubbed, and so we can be sure that the case where the book is unavailable does not result in a call to the CheckOutBook method.

Conclusion

Using the strategies outlined in this article, my team can effectively isolate and test pieces of logic. By leveraging interfaces, mocking of dependencies, and separating layers of logic, we are able to build unit tests in Go that target each layer of our applications and control all external variables involved.

On my team, we couple unit testing with tracking code coverage, integration testing, and end-to-end testing to feel confident in the quality of the code we ship and ensure that functionality is behaving as expected. Ultimately, unit tests are most powerful as the most targeted component of a larger testing strategy.

author Author: Chloe Powell

Comment test

Get insights when you need them

An original look at DC's high-growth startups, tech jobs, tech events, and more. Straight to your inbox.