Top Go Modules: Writing Unit Tests with Testify

UPDATE: As of May 1, 2021 – GoCenter central repository has been sunset and all features will be deprecated. For more information on the sunsetting of the centers read the deprecation blog post

 

 

Every month GoCenter awards top performing modules a Gopher Badge as a mark of achievement. We’re writing about some of these top modules and how they’re used in Go.

All developers have seen them, even in well-structured Golang programs: comments suggesting you keep away from lines of code since they seem to be working in a magic way. These warnings make us timid, fearing we might break something. But applications need to change, to improve and innovate.

That’s why unit tests are a vital part of software development. They help developers know whether the small parts of their software perform their intended function correctly. With the right amount of unit test coverage in place, developers feel more confident to change their implementations, and even refactor it from scratch, knowing they can easily check if the new version is still working as intended.

As software grows in complexity, so does the importance of unit testing and a solid set of tools for it in the same language. And since test code with good coverage can be substantial, it needs to be as readable and maintainable as product code, to encourage developers to use it and gain its benefits.

For our Go community projects such as GoCenter, we make extensive use of the popular Testify module, which provides a set of Golang packages for performing essential unit test functions. 

This article shows how you can use Testify’s main features to write unit tests in Go that are easy to read and maintain. It does that by showing how unit tests would look like when using pure Go, introducing Testify’s packages that can help with the task being performed and then showing the resulting code after Testify adoption. We’ll show some best practices for how to perform assertions and write mocks for dependencies.

Testify: A Top Gopher

Testify is a developer-friendly set of packages with over 11,000 stars on GitHub, and has great community support. Testify extends the lightweight testing framework of Go to perform assertions and mock dependencies.

These features, as well the everyday reliance of our Go community team on it, are a big part of why the Testify module is honored as a “Top Gopher” in GoCenter. When you view GoCenter’s rich metadata about the Testify module, you can see why:

  • The module’s ReadMe directs you to comprehensive documentation. We can learn more details about the module’s code through the GoDoc tab, which shows automatically generated documentation of functions and more.
  • GoCenter’s Used By and Metrics tabs show that this module is popular and broadly trusted, with many downloads, forks, contributors, and usage by other Go modules.
  • GoCenter’s Security tab also reveals that the current version of this module and its dependencies have no known NVD vulnerabilities, as verified by a JFrog Xray deep scan.

A Simple GoLang Unit

To start writing the unit tests first we need a component to test. For this exercise we will use the following service definition:

type ProductService interface {
	IsProductReservable(id int) (bool, error)
}

 

For this service definition we have an implementation and we are interested in testing it. The implementation has some business logic to determine if a product is reservable or not. The implementation also depends on a Data Access Object component to provide information about the products. The implementation needs to pass the following simplified test cases:

  • The service implementation needs to respect the service definition
  • Products added to the catalog more than 1 year ago are reservable
  • Other products are not reservable
  • Products not in the catalog should cause a product not found error

The service implementation looks like this:

type ProductServiceImpl struct {
	productDAO persist.ProductDAO
}

// Constructor
func NewProductServiceImpl(dao persist.ProductDAO) *ProductServiceImpl {
	return &ProductServiceImpl{
		productDAO: dao,
	}
}

func (s *ProductServiceImpl) IsProductReservable(id int) (bool, error) {
	// Get product information from database
	product, err := s.productDAO.GetProduct(id)
	if err != nil {
		return false, fmt.Errorf("failed to get product details: %w", err)
	}

	if product == nil {
		return false, fmt.Errorf("product not found for id %v", id)
	}

	// Only products added more than 1 year ago to the catalog can be reserved
	return product.CreatedAt.Before(time.Now().AddDate(-1, 0, 0)), nil
}

Using Testify

Now that we have a simple service, we can use Testify to create unit tests that assure it operates as intended.

Performing Assertions

The most basic tasks performed by unit tests are assertions. Assertions are usually used to verify if the actions performed by the test using determined input produce the expected output. They can also be used to check if the components follow the desired design rules. 

Using pure Go to run the assertions needed to check if the first test case is being honored and also if our service implementation is being initialized properly, we get the following code:

import (	
	"service"
	"testing"
)

func TestNewProductServiceImpl(t *testing.T) {
	productDaoMock := ProductDaoMock{} // Ignore the mock for now
	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	// Asserts ProductServiceImpl implements ProductService. Will break the compiler if it does not.
	var _ service.ProductService = productServiceImpl

	if productServiceImpl == nil {
		t.Fatal("Product Service not initialized")
	}

	if productServiceImpl.productDAO == nil {
		t.Fatal("Product Service dependency not initialized")
	}
}

To help with the assertions, Testify provides package  github.com/stretchr/testify/assert. This package has several methods that can help to compare values against expected results. If we replace our comparisons by those methods we get the following:

import (
	"github.com/stretchr/testify/assert"
	"service"
	"testing"
)

func TestNewProductServiceImpl(t *testing.T) {
	assertions := assert.New(t)
	productDaoMock := ProductDaoMock{} // Ignore the mock for now
	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	if !assertions.Implements((*service.ProductService)(nil), new(ProductServiceImpl)) {
		t.Fatal("Product Service Implementation does not honor service definition")
	}

	if !assertions.NotNil(productServiceImpl, "Product Service not initialized") {
		t.Fatal("Product Service not initialized")
	}

	if !assertions.NotNil(productServiceImpl.productDAO, "Product Service dependency not initialized") {
		t.Fatal("Product Service dependency not initialized")
	}
}

Besides helping with the assertions, Testify packages also provide better messaging when one of those operations fail. For example, if we forgot to set the productDAO field in the service implementation constructor, we would get the following test failure:

=== RUN   TestNewProductServiceImpl
    TestNewProductServiceImpl: product_service_impl_test.go:22:
        	Error Trace:	product_service_impl_test.go:22
        	Error:      	Expected value not to be nil.
        	Test:       	TestNewProductServiceImpl
        	Messages:   	Product Service dependency not initialized
    TestNewProductServiceImpl: product_service_impl_test.go:23: Product Service dependency not initialized
--- FAIL: TestNewProductServiceImpl (0.00s)

So far, even though we have better messaging and more convenient methods to run the assertions, we were not able to reduce the size of our test. We still have a repeating pattern of if-not-assertion-break that can make it harder to read our test code. To help with that, Testify provides package github.com/stretchr/testify/require. This package has the same assertion methods provided by the assert package but it will break the test immediately when an assertion fails. Introducing that package, we get the following shorter and easier to read test code:

import (
	"github.com/stretchr/testify/require"
	"service"
	"testing"
)

func TestNewProductServiceImpl(t *testing.T) {
	assertions := require.New(t)

	productDaoMock := ProductDaoMock{} // Ignore the mock for now
	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	assertions.Implements((*service.ProductService)(nil), new(ProductServiceImpl), "Product Service Implementation does not honor service definition")
	assertions.NotNil(productServiceImpl, "Product Service not initialized")
	assertions.NotNil(productServiceImpl.productDAO, "Product Service dependency not initialized")
}

 

Mocking Dependencies

When testing a component, we ideally want to isolate it completely to avoid having failures elsewhere to compromise our tests. This is especially harder when the component we want to test has dependencies on other components from different layers in our software. In the scenario we are using here, our service implementation depends on a component from the Data Access Object (DAO) layer to access information about the products.

To promote the desired isolation, it is common for developers to write fake simplified implementations of those dependencies to be used during the tests. Those fake implementations are called mocks.

We can create a mock implementation of the ProductDAO to be injected into the service implementation for the test execution. The ProductDAO interface that our mock needs to implement looks like this:

type ProductDAO interface {
	GetProduct(id int) (*model.Product, error)
}

To enable the test execution, it is necessary that the mock provides a behavior compatible with all test cases we want to validate, otherwise we cannot achieve the desired test coverage. Using pure Go, our test case with the mock would look like the following:

import (
	"errors"
	"model"
	"persist"
	"testing"
	"time"
)

type ProductDaoMock struct {
}

func (m *ProductDaoMock) GetProduct(id int) (*model.Product, error) {
	switch id {
	case 1:
		return &model.Product{
			Id:          1,
			Description: "Product created 2 years ago",
			CreatedAt:   time.Now().AddDate(-2, 0, 0),
		}, nil
	case 2:
		return &model.Product{
			Id:          2,
			Description: "Product recently created",
			CreatedAt:   time.Now(),
		}, nil
	case 999:
		return nil, persist.ErrProductNotFound
	}
	return nil, nil
}

func TestProductServiceImpl_IsProductReservable(t *testing.T) {
	testDataSet := map[int]bool {
		1: true,
		2: false,
	}

	productDaoMock := ProductDaoMock{}
	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	for productId, expectedResult := range testDataSet {
		reservable, err := productServiceImpl.IsProductReservable(productId)
		if err != nil {
			t.Fatalf("Failed to check if product %v is reservable: %s", productId, err)
		}

		if reservable != expectedResult {
			t.Fatalf("Got wrong reservable info for product id %v. Expected: %v. Got: %v", productId, expectedResult, reservable)
		}
	}
}

func TestProductServiceImpl_IsProductReservable_NotFound(t *testing.T) {
	productDaoMock := ProductDaoMock{}
	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	_, err := productServiceImpl.IsProductReservable(999)
	if !errors.Is(err, persist.ErrProductNotFound) {
		t.Fatalf("Got unexpected error result: %s", err)
	}
}

 

The main issue with the approach above is that now our test case logic is distributed. Part of it is implemented in the test case itself, where we send events to the component being tested and run assertions with the results, while the other part is implemented in the mock, which needs to provide behavior compatible to what the test case is testing. It is easy to see how our test case could break now not because of an issue in the test itself, but because the mock is not returning the required data. 

Another issue that can make it even more frustrating is that we are also sharing the mock between multiple test cases. It is possible for changes applied to the mock to satisfy one test case needs to break other ones. In our scenario we have only 3 test cases we care about, but you can imagine how messy it could become if we had more complex test cases. Splitting the mock into multiple ones would not necessarily help it as well, and could potentially make it worse with the complexity spread even more. Also, if our mocked interface changes, we would need to update several mocks to keep them compatible.

What we need is to keep the test case logic centralized and independent. To help with that, Testify provides package github.com/stretchr/testify/mock. This package provides tools to create mocks that allow the behavior to be injected in runtime, which allow the test case itself to do it keeping the mocked logic close to the test logic.

Using Testify mock package to create our DAO mock and moving the mock behavior initialization to the test cases, and also adding Testify require package to run our assertions, our test code looks like this:

import (
      "github.com/stretchr/testify/require"
      "github.com/stretchr/testify/mock"
	"errors"
	"model"
	"persist"	
	"testing"
	"time"
)

type ProductDaoTestifyMock struct {
	mock.Mock
}

func (m *ProductDaoTestifyMock) GetProduct(id int) (*model.Product, error) {
	args := m.Called(id)
	return args.Get(0).(*model.Product), args.Error(1)
}

func TestProductServiceImpl_IsProductReservable(t *testing.T) {
	assertions := require.New(t)

	// Register test mocks
	productDaoMock := ProductDaoTestifyMock{}
	productDaoMock.On("GetProduct", 1).Return(&model.Product{
		Id:          1,
		Description: "Product created 2 years ago",
		CreatedAt:   time.Now().AddDate(-2, 0, 0),		
	}, nil)
	productDaoMock.On("GetProduct", 2).Return(&model.Product{
		Id:          2,
		Description: "Product recently created",
		CreatedAt:   time.Now(),
	}, nil)

	testDataSet := map[int]bool {
		1: true,
		2: false,
	}

	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	for productId, expectedResult := range testDataSet {
		reservable, err := productServiceImpl.IsProductReservable(productId)
		assertions.NoErrorf(err,"Failed to check if product %v is reservable: %s", productId, err)
		assertions.Equalf(expectedResult, reservable,"Got wrong reservable info for product id %v", productId)
	}
}

func TestProductServiceImpl_IsProductReservable_NotFound(t *testing.T) {
	assertions := require.New(t)

	// Register test mocks
	productDaoMock := ProductDaoTestifyMock{}
	productDaoMock.On("GetProduct", 1).Return((*model.Product)(nil), persist.ErrProductNotFound)

	productServiceImpl := NewProductServiceImpl(&productDaoMock)

	_, err := productServiceImpl.IsProductReservable(1)

	if !errors.Is(err, persist.ErrProductNotFound) {
		assertions.Failf("Got unexpected error result", "Got unexpected error result: %s", err)
	}
}

In the implementation above, notice how the mock behavior and the test logic are both centralized inside the test case. Also, notice how the registered mock behavior is exclusive to the test case where it is placed, since it belongs to a single mock instance that is not shared between multiple tests. The tests even register different behaviors for product id 1 with no issues at all. ProductDaoTestifyMock can be safely reused between multiple test cases since it has no behavior.

Conclusion

I hope you’ve found useful information in this article and I hope it can help you enjoy writing better unit tests in your projects. To add Testify to you project using Go modules and start playing with it, just run the following commands:

$ export GOPROXY=https://gocenter.io
$ go get github.com/stretchr/testify

Check out Testify on GoCenter or search to discover even more great Go modules.