Testing#

Go ships its own test runner (go test) and assertion-free testing package (testing). The convention is table-driven tests, one function per behaviour, sub-tests via t.Run. The race detector (go test -race) and the fuzzer (go test -fuzz) come with the toolchain.

testify is the most common third-party addition (rich assertions, mocks); for greenfield code the operator often stays on stdlib plus cmp.Diff from github.com/google/go-cmp.

For errors.Is / errors.As in test assertions, see Errors.

Layout#

Tests live alongside source in *_test.go files. Test functions take *testing.T and start with Test.

// string_helpers.go
package strutil

func Trim(s string) string { return strings.TrimSpace(s) }

// string_helpers_test.go
package strutil

import "testing"

func TestTrim(t *testing.T) {
    got := Trim("  hi  ")
    if got != "hi" {
        t.Errorf("Trim: got %q, want %q", got, "hi")
    }
}
$ go test ./...
ok      github.com/op/scan/strutil       0.003s

Table-driven#

The default structure for any function with a small input space.

func TestTrim(t *testing.T) {
    tests := []struct {
        name string
        in   string
        want string
    }{
        {"clean",        "hi",        "hi"},
        {"trailing",     "hi   ",     "hi"},
        {"leading",      "   hi",     "hi"},
        {"both",         "  hi  ",    "hi"},
        {"empty",        "",          ""},
        {"whitespace",   "   ",       ""},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            got := Trim(tc.in)
            if got != tc.want {
                t.Errorf("Trim(%q) = %q, want %q", tc.in, got, tc.want)
            }
        })
    }
}

t.Run produces a sub-test you can filter on with go test -run TestTrim/empty.

Setup and teardown#

Avoid global setup. t.Cleanup registers a function to run when the test (or sub-test) finishes.

func TestServer(t *testing.T) {
    srv := startServer(t)
    t.Cleanup(func() { srv.Close() })

    // …
}

func startServer(t *testing.T) *Server {
    t.Helper()                    // report errors from the caller
    s, err := NewServer(":0")
    if err != nil { t.Fatal(err) }
    return s
}

t.Helper tells the test runner this function is plumbing; failures point at the caller, not the helper line.

Fixtures#

For per-test temporary directories, t.TempDir returns a fresh path and cleans up automatically.

func TestWriteAtomic(t *testing.T) {
    dir := t.TempDir()
    path := filepath.Join(dir, "out.txt")
    if err := WriteAtomic(path, []byte("hi")); err != nil {
        t.Fatal(err)
    }
    got, _ := os.ReadFile(path)
    if string(got) != "hi" { t.Errorf("got %q", got) }
}

Comparing complex values#

For deep-equality on structs and maps, cmp.Diff from github.com/google/go-cmp produces readable diffs.

import "github.com/google/go-cmp/cmp"

func TestParse(t *testing.T) {
    got, _ := Parse(input)
    want := &Config{Host: "0.0.0.0", Port: 80}
    if diff := cmp.Diff(want, got); diff != "" {
        t.Errorf("Parse mismatch (-want +got):\n%s", diff)
    }
}

For floating point, cmpopts.EquateApprox.

Assertion libraries#

testify is the most common; pick the require package to halt on first failure or assert to collect them.

$ go get github.com/stretchr/testify
import (
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestParse(t *testing.T) {
    c, err := Parse(input)
    require.NoError(t, err)
    assert.Equal(t, "0.0.0.0", c.Host)
    assert.Equal(t, 80,        c.Port)
}

Race detector#

-race instruments the binary to catch concurrent reads / writes on the same location. Mandatory in CI for any code touching goroutines.

$ go test -race ./...

Fuzz tests#

Go 1.18+ ships a fuzzer. The operator declares seed inputs and the fuzz function generates more.

func FuzzParse(f *testing.F) {
    f.Add("host=0.0.0.0\nport=80")
    f.Add("")
    f.Add("\x00\x01")

    f.Fuzz(func(t *testing.T, s string) {
        _, err := Parse(s)        // must not panic
        _ = err                   // any error is fine
    })
}
$ go test -fuzz=FuzzParse -fuzztime=30s

A crashing input gets saved under testdata/fuzz/FuzzParse/; running go test after replays them as regression cases.

Benchmarks#

Benchmarks are functions starting with Benchmark; the runner picks b.N to stabilise.

func BenchmarkParse(b *testing.B) {
    data := []byte(loadFixture())
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _, _ = Parse(data)
    }
}
$ go test -bench=. -benchmem ./...

Coverage#

$ go test -cover ./...
$ go test -coverprofile=cover.out ./...
$ go tool cover -html=cover.out

HTTP tests#

net/http/httptest lets the operator stand up a real HTTP server backed by a test http.Handler.

import "net/http/httptest"

func TestHandler(t *testing.T) {
    srv := httptest.NewServer(myHandler)
    defer srv.Close()

    resp, err := http.Get(srv.URL + "/ping")
    if err != nil { t.Fatal(err) }
    if resp.StatusCode != 200 { t.Errorf("status %d", resp.StatusCode) }
}

For end-to-end tests of HTTP clients (not servers), httptest.NewServer plays the role of the upstream API.

References#