Mocking in Go
Today I want to talk a little about the testify and gomock packages and doing mocks in Go.
Mocking is essential when writing code that depends on external services, e.g., microservices, message brokers, and datastores. For many people, this means web applications: My system has business rules and talks to multiple services. How do I test the business rules without setting up all the services locally?
Some people argue that mocks create a false sense of test coverage and introduce blind spots. I think that’s partially true but also too simplistic. In many cases, engineers gain sufficient value from testing “glue code” to make mocking worthwhile. This follows the principle of don’t let perfect be the enemy of good.
The testify
package provides assertFoo
styled functions and a mocking framework. The package was created 7 years
ago. While the initial creators appears to have left, the project still sees active development.
The mocking portion of testify
is entirely driven by github.com/stretchr/testify/mock.Mock
. Unlike frameworks
like Mockito, you need to write lots of boilerplate code whenever you want to mock a particular interface. Take a
simple example of net/http/FileSystem
type FileSystem interface {
Open(name string) (File, error)
}
When you mock this interface, you’ll add the following boilerplate to your code:
// MockFileSystem is a struct that implements FileSystem
type MockFileSystem struct {
mock.Mock
}
// Implements a mock method
func (m *MockFileSystem) Open(name string) (http.File, error) {
args := m.Called(name)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(http.File), args.Error(1)
}
/*
boilerplate code in test setup
file, _ := os.Open("/dev/null")
m := new(MockFileSystem)
m.On("Open", "input_a").Return(file, nil)
m.On("Open", "input_b").Return(nil, fmt.Errorf("error b"))
*/
Look at that Open()
method! This kind of code is ungainly. It is especially irksome when you are prototyping a module
with multiple related services. Making a change to one service often requires changing boilerplate for all of them,
which is tedious and error-prone. The problem is only slightly alleviated for code in maintenance mode. The idea of
refactoring code involving testify/mock
gives me shudders.
It is notable that Java, also a typed language, allows for much easier mocking through its support of generic programming. The Java compiler works well with code that do not have compile-time implementations.
FileSystem mockFileSystem = Mock(FileSystem.class);
mockFileSystem.Open("input_a"); // so easy!
The gomock
library is an alternative to testify
. It relies on code generation like the famous stringer
utility, which requires installing mockgen
first:
go get github.com/golang/mock/mockgen@latest
Once you have mockgen
installed, you create mocks by adding the appropriate go:generate
comments to your source
file. Building on our FileSystem
example, your code might look something like this:
//go:generate mockgen -source $GOFILE -destination mock_$GOPACKAGE/$GOFILE
package simple
import (
"net/http"
)
type FileSystem interface {
Open(name string) (http.File, error)
}
The first line of the above listing means:
//go:generate mockgen // Runs mockgen...
-source $GOFILE // against the current file...
-destination mock_$GOPACKAGE/ // place the result in a new package named "mock_<current_package>"...
$GOFILE // within a file with the same name as the current file.
The go:generate
statement generates a suitable struct that implements the interface, for every interface in the file!
Your additional boilerplate looks like:
/*
boilerplate code in test setup:
import (
mp "example.com/simple/mock_simple" // assuming your code was under the package `example.com/simple`.
)
file, _ := os.Open("/dev/null")
ctrl := gomock.NewController(t)
defer ctrl.Finish()
m := s.NewMockFileSystem(ctrl)
m.EXPECT().Open(gomock.Eq("input_a")).Return(file, nil)
m.EXPECT().Open(gomock.Eq("input_b")).Return(nil, fmt.Errorf("error b"))
*/
Where as boilerplate in testify
grows O(M) with the number of methods in your interfaces. The boilerplate for
gomock
grows with the number of source files you have. That’s a big win!
One downside is that you must ensure mockgen
’s version matches the version of gomock
used in the test cases. If the
code generator generates the wrong code, you’ll get errors when you run (compile, actually) your tests.
I mentioned that Java’s support for generic programming allows its compiler to know about the “shapes” of mocks without
writing additional code. We see with gomock
that Go requires a distinct code generation pass to get the same
benefits. This is quite inelegant in my opinion. Java took eight years to introduce generic programming. In 2020,
Go is about the same age as Java 1.5 and generics nowhere in sight. We’ll have to live with multiple passes during the
compilation step until we get there.