Go testing tips
Go has some excellent resources on testing. I recommend the Go Test Comments page on the Go wiki along with this 2014 talk by Go maintainer Andrew Gerrand. This page describes some additional strategies I’ve found useful when testing Go programs.
Swap
Here is a function I use frequently in test code.
1
2
3
4
5
6
func swap[T any](t *testing.T, orig *T, with T) {
t.Helper()
o := *orig
t.Cleanup(func() { *orig = o })
*orig = with
}
swap
is useful when modifying global values, which is not uncommon during tests. Once the test finishes, swap
returns orig
to its initial value.
Note that this does not work with t.Parallel()
. Not all tests will be parallelizable. It is more important for tests to be fast, reliable, and clear than it is to focus on the parallelization of individual tests. Note that different packages’ test suites are already run in parallel to each other.
Funcname
One of the Go best practices during testing is to print the name of the function alongside its inputs and (got, want) outputs when calling t.Errorf
or t.Fatalf
. I often rename my functions partway through development and forget to update their names in these test output strings. To avoid this, I use funcname(someFunc)
to insert function names where they’re needed.
1
2
3
4
5
6
func funcname(a any) string {
s := strings.Split(
runtime.FuncForPC(reflect.ValueOf(a).Pointer()).Name(), ".",
)
return s[len(s)-1]
}
Deep equality and diffs
Aside from familiarity, I think one of the reasons people often reach for assertion libraries despite that being not recommended is the idea that it might be difficult to achieve deep equality and readable diffs without them. While not being an official part of the Go project, github.com/google/go-cmp
makes deep comparisons easy without needing to pull in a separate DSL for testing.
1
2
3
4
5
6
7
8
9
10
11
import (
"testing"
"github.com/google/go-cmp/cmp"
)
func TestSomething(t *testing.T) {
if got, want := post, wantPost; !cmp.Equal(got, want) {
t.Errorf("blog post: -want +got\n%s", cmp.Diff(want, got))
}
}
cmp
is very customizable, arguably more so than most popular assert libraries. See cmpopts
.
Underscore test packages
A common enough misunderstanding among new Gophers is that packages should be tested in a “black box” mode, and test packages such as package main_test
are there to facilitate this.
This is actually to avoid import loops: for example, the fmt
package uses fmt.Sprintf
during its own tests. It is normal for most test files to be in the same package as the code under test.
Additionally, *_test.go
files are only built during go test
. Test code is not built into Go binaries or compiled when a library is imported. This is a property of the file naming convention, not a property of the package name.
Test hooks
Test hooks are function literals that allow for overriding a function’s behavior during test time. You can find examples of this strategy in the Go standard library, especially where network calls are involved.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main
import "fmt"
var testHookGreet func(string) string
func Greet(s string) string {
if h := testHookGreet; h != nil {
return h(s)
}
return fmt.Sprintf("Hello, %s!", s)
}
func main() { println(Greet("world")) }
In the above example, testHookGreet
can be set during testing to override the behavior of Greet
. The if-with-a-short-statement form is used here to prevent having to repeat (and therefore potentially typo) the testHookGreet
identifier.
Test hooks are useful when you have tests that aren’t testing the implementation of a function, but are incidentally calling one. Using a test hook, you can return consistent values, record calls, or insert fakes.
One thing that bothers me about this strategy is that it is possible, though unlikely, for something in the program under test to set testHookGreet
. To solve this problem, I created a tool: lesiw.io/testdetect
.
Adding a go run
of that tool as a //go:generate
directive will create a new package-local type, testingDetector
, which can be used as an additional guard against overrides when outside of test time.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//go:generate go run lesiw.io/testdetect@latest
package main
import "fmt"
var t testingDetector
var testHookGreet func(string) string
func Greet(s string) string {
if h := testHookGreet; t.Testing() && h != nil {
return h(s)
}
return fmt.Sprintf("Hello, %s!", s)
}
func main() { println(Greet("world")) }
A nice property of (testingDetector).Testing()
, unlike the native testing.Testing()
, is that it is package-local: it will never return true if your package code is pulled in during another package’s tests. testing.Testing()
is global, and may result in your package exhibiting different behavior when running other tests which import it.
Another nice property of this function is that it is designed for the entire if
statement to be optimized out by most Go compilers. (The test suite of lesiw.io/testdetect
actually validates this behavior.)
Function variables
To override global functions in imported packages, assign them to a variable, then swap()
them during testing.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main
import (
"fmt"
"io"
"os"
"time"
)
var sleep = time.Sleep
var stdout io.Writer = os.Stdout
func main() {
sleep(time.Second)
fmt.Fprintln(stdout, "Hello world!")
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main
import (
"bytes"
"io"
"testing"
"time"
)
func TestHello(t *testing.T) {
var (
buf bytes.Buffer
duration time.Duration
)
swap[io.Writer](t, &stdout, &buf)
swap(t, &sleep, func(d time.Duration) { duration += d })
main()
if got, want := duration, time.Second; got != want {
t.Errorf("slept for %d, want %d", got, want)
}
if got, want := buf.String(), "Hello world!\n"; got != want {
t.Errorf("printed %q, want %q", got, want)
}
}
// See the Swap section above for the implementation of swap.
Third party types
One of the places I feel we have very little guidance as a community is on dealing with types with lots of methods. These types are common in API clients such as the AWS SDK.
Most of the conversation around dealing with these involves the misuse or misunderstanding of interfaces. I don’t find any of these strategies particularly satisfying. Thick interfaces require constant maintenance to keep up with the evolution of the genuine type; thin interfaces can overly narrow the capabilities of a type that you might like to pass between multiple functions. I also find that this more dependency injection based approach makes the code less comprehendible: reading a function with many API clients injected into it, for example, can make it difficult to work out what the important inputs and outputs are. And it’s not trivial to backsolve a narrow interface into its larger API client type: the experienced Gopher may recognize a PutObjecter as a wrapper for the AWS S3 client, but in a vacuum it reads like nonsense.
I have two strategies for handling these.
The first, more “native Go” way, is to make a hookable function that wraps the type’s actual behavior.
1
2
3
4
5
6
7
8
9
10
11
var testHookPutObject func(string) string
func putObject(
ctx context.Context, s3 *s3.Client,
params *s3.PutObjectInput, opts ...func(*s3.Options),
) (*s3.PutObjectOutput, error) {
if h := testHookPutObject; h != nil {
return h(ctx, s3, params, opts...)
}
return s3.PutObject(ctx, params, opts...)
}
But I find this to be pretty tedious and a lot of looking up and copy-pasting function signatures. So more often than not, when handling types like these, I use another one of my own utilities: lesiw.io/moxie
moxie
lets me wrap a type in another one and generate proxy methods that are only compiled for tests.
1
2
//go:generate go run lesiw.io/moxie@latest s3Client
type s3Client struct{ *s3.Client }
During normal operation, s3Client.PutObject()
will refer to the genuine (*s3.Client).PutObject()
method. But when go test
is run, it will instead refer to an implementation of s3Client.PutObject()
whose behavior can be overridden and whose calls can be inspected. See the README for more.