Unit testing in Go
Why Write Unit Tests?
Unit tests validate that each of the component parts of your application are functioning as expected. They are the most granular level of test, and test small chunks of code in isolation. They are often considered cheap: they don’t take a long time to write and should execute quickly.
Since unit tests are cheap, it makes sense that they are used to catch and test the error boundaries of your function under test. Indeed, ideally you should have a unit test for each possible code path through your function (the extent to which this is true is called “coverage").
Martin Fowler has previously made a distinction between solitary and sociable unit tests.
Like all things in the domain of software testing, the line can be blurry here. In general, err on the side of solitary testing. The more a unit test is sociable, the more it looks like an integration test. If it is difficult to test a specific unit of code in a solitary fashion, this may hint at an architectural flaw in your code: the other units with which your code interacts may need to be injected as dependencies, so that they can be mocked.
How to Test in Isolation
In order to isolate a unit of code under test, its dependencies must be substituted for a test double. To facilitate the substitution, the dependencies must be provided to the function under test as an argument or as a property on the receiving struct
, for example via dependency injection.
Test doubles come in the flavours of stubs, fakes, spies and mocks, in order of increasing complexity.
Stubs
Stubs are the most basic form of test double, which simply return some predefined output regardless of input. They are typically written for one particular test, as they have hardcoded expectations and assumptions.
Here’s a simple example in Go:
// foobar.go
type Fooer interface {
Foo() int
}
func Bar(foo Fooer, i int) int {
return foo.Foo() * i
}
// foobar_test.go
type FooStub struct{}
func (f *FooStub) Foo() int {
return 5;
}
func TestBar(t *testing.T) {
stub := &FooStub{}
got := Bar(stub, 2)
expected := 10
if got != expected {
t.Fatalf("%d != %d", got, expected)
}
}
Mocks
In more complex scenarios, or in situations where you need to substitute the same dependency in multiple tests, mocks come in handy. Mocks allow you to configure different expectations on inputs and set different return values accordingly.
I typically use mocks wherever a stub is not sufficient, as they provide all the functionality of fakes and spies.
GoMock
GoMock is a handy tool that makes it dead easy to generate mock code for your interfaces. Simply execute the mockgen
binary providing your source file as input:
mockgen -source=foobar.go
Then you can set expectations and return values with a simple API:
func TestBar(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
m := NewMockFooer(ctrl)
m.EXPECT().Bar(gomock.Eq(2)).Return(10)
Bar(m, 2)
}
sqlmock
If you need to mock a database connection, sqlmock
does an excellent job of allowing you to set expectations to match SQL queries. It implements sql/driver
, allowing you to substitute it wherever you use database/sql
.
// foobar.go
func Foo(db *sql.DB, a, b int) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec("INSERT INTO some_table (a, b) VALUES (?, ?)", a, b)
if err != nil {
return err
}
err = tx.Commit()
return err
}
// foobar_test.go
func TestFoo(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to open mocked db connection: %s", err)
}
defer db.Close()
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO some_table").
WithArgs(2, 3).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
err = Foo(db, 2, 3)
if err != nil {
t.Fatalf("Foo returned unexpected error: %s", err)
}
}
Conclusion
Unit tests are an essential part of building robust, reliable and maintainable software. I typically have the unit tests automatically run whenever I change a file in my codebase, so I can instantly see when I break something.
Don’t make the mistake of thinking the upfront cost of writing tests outweighs their benefit. The main cost relating to software comes after it has been written, when it must be maintained. Over time, unit tests will save you and your company thousands of developer hours.
How do you write unit tests in Go? Hit me up on Twitter!