Post

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.