Writing tests in Go
Recently, I bumped into this article by Segment’s engineering team. It has a lot of good advice and some helpful links about writing good tests in Go. I wanted to discuss a few more things that I’ve found useful in the two-ish years that I’ve been using the language.
For those of you who haven’t used Go, consider using it for your next side project. It’s a very opinionated programming language with a spec that you can mostly hold in your head and a fairly comprehensive standard library. Also, unlike most other languages, you don’t have to deal with a ton of testing frameworks. Although the standard library provides good support for writing tests, I’ve found the following techniques useful in writing better testable code.
It’s okay to test unexposed functions
The default package layout in Go encourages housing code and tests in the same package. Tests can access “private” functions and members — this is okay. I’ve mostly encountered this in the context of helper functions that are only used within a package. The alternative is to expose them publicly, which has its drawbacks.
Control time
Avoid using the time
package to block or schedule execution. Consider using something like clockwork
to pass in a fake, controllable clock in unit tests. Controlling time lets you write more deterministic unit tests. This is useful when you’re testing behavior that depends on time — timeouts, retries, scheduled runs etc.
Use Go’s race detector
Data access races are really hard to debug. Fortunately, Go has support for detecting them — so use it. This is a good starting point to understand how to use the race detector. Remember that it will only test the code paths that your tests execute. So you still need to write a test that exercises the race.
Write benchmarks
Go makes writing benchmarks easy. This is a good starting point to understand how to write them. Make sure you have benchmarks for the performance sensitive parts of your code.
Use setup functions
This is useful if you want to setup some external state that is used by the function or implementation being tested. An example would be something that operates on a directory. Instead of having every test function create a temporary directory and clean up after itself, write a generator function that does this.
func withTempDir(t *testing.T, f func(d string)) {
dir, err := ioutil.TempDir(...)
if assert.NoError(err) {
defer os.RemoveAll(dir)
f(dir)
}
}func Test(t *testing.T) {
withTempDir(t, func(dir string) {
// use dir in test
})
}
Accept interfaces, return structs
Interfaces can be mocked; structs cannot. Having interfaces as member variables makes it easy to mock their behavior. Returning structs (concrete implementations) means that the caller gets to decide how to use the returned value.
That said, use mocks carefully. With mocks, you’re testing your understanding of the interface, at the time the test was written. While this is ideal, it’s not always practical — especially in high velocity codebases. If you think the underlying implementation is unstable, test it in a separate package to avoid diverging.
Lastly, use a mock generator like mockery
instead of writing them yourself.
Use self referential interfaces
This is a neat trick that I’ve found useful for testing behavior that is either non-deterministic or doesn’t fit well in a unit test because it makes network calls or depends on an external service. Let’s say you want to test the behavior of a function A()
on a struct
of type Foo
that makes a non-deterministic function call that uses a member variable (like a network connection) in Foo
. An easy way to do this is to move the non-determinism into a function B()
on Foo
and introduce a new member variable on Foo
that satisfies an interface exposed by B()
and call B()
on this member. The actual code can use an instance of Foo
as the member variable and the tests can provide a mock. The code below should make things clearer.
package mainimport (
"fmt"
)type doer interface {
B()
}type Foo struct {
msg string
d doer
}func (f *Foo) B() {
fmt.Printf("i am non deterministic: %v\n", f.msg)
}func (f *Foo) A() {
f.d.B()
fmt.Println("test me")
}func main() {
x := &Foo{
msg:"go",
}
x.d = x // x.d = MockDoer() in tests
x.A()
}
Although many of these techniques are useful, deciding where to use them is always a judgement call. Choose wisely!