19 / 20 · Day 7
Day 7 · Concept 19

Testing & benchmarks

No framework. No external runner. go test finds every _test.go file under the current package and runs anything matching TestXxx / BenchmarkXxx / ExampleXxx. Table-driven tests are the idiom. The race detector is one flag away.


1 · Try it — the simplest test

go math.go
package math

func Add(a, b int) int { return a + b }
go math_test.go
package math

import "testing"

func TestAdd(t *testing.T) {
    got := Add(2, 3)
    want := 5
    if got != want {
        t.Errorf("Add(2,3) = %d; want %d", got, want)
    }
}
shell terminal
$ go test
PASS
ok      example.com/math   0.003s

$ go test -v          # verbose
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
PASS

2 · Table-driven tests — the idiom

The canonical Go test pattern: declare a slice of test cases, loop. Each case gets a subtest via t.Run, which means individual cases can fail independently and you can go test -run TestAdd/case-3 to narrow.

go math_test.go · table-driven
func TestAdd(t *testing.T) {
    tests := []struct {
        name    string
        a, b    int
        want    int
    }{
        {"two positives", 2, 3, 5},
        {"zero",          0, 0, 0},
        {"negatives",    -1, -2, -3},
        {"mixed",         5, -3, 2},
        {"overflow",      1<<62, 1<<62, -(1 << 63)}, // 2^62 + 2^62 wraps to math.MinInt64
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := Add(tt.a, tt.b)
            if got != tt.want {
                t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, got, tt.want)
            }
        })
    }
}

3 · t.Helper, t.Cleanup, t.Skip, t.Parallel

go testing utilities
func assertEqual(t *testing.T, got, want any) {
    t.Helper()  // makes errors point at the caller, not this line
    if got != want {
        t.Errorf("got %v, want %v", got, want)
    }
}

func TestThing(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping in short mode")
    }

    t.Parallel()  // run in parallel with other Parallel tests

    f, err := os.CreateTemp("", "test")
    if err != nil { t.Fatal(err) }
    t.Cleanup(func() {       // runs on test end (success or failure)
        f.Close()
        os.Remove(f.Name())
    })

    // ... test body ...
    assertEqual(t, 1+1, 2)
}
t.Fatal vs t.Error. Fatal stops the test immediately. Error records a failure but keeps going. Use Fatal when continuing makes no sense (setup failed). Use Error for assertions where you'd like to see all failures.

4 · Benchmarks

go bench_test.go
func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(2, 3)
    }
}

// Subtests apply to benchmarks too — useful for parameter sweeps
func BenchmarkSort(b *testing.B) {
    for _, size := range []int{10, 100, 1000, 10000} {
        b.Run(fmt.Sprintf("n=%d", size), func(b *testing.B) {
            data := makeData(size)
            b.ResetTimer()  // exclude setup
            for i := 0; i < b.N; i++ {
                sort.Ints(append([]int{}, data...))
            }
        })
    }
}
shell terminal
$ go test -bench=. -benchmem
goos: linux
goarch: amd64
BenchmarkAdd-8           1000000000   0.301 ns/op   0 B/op   0 allocs/op
BenchmarkSort/n=10-8       18432651   65.4 ns/op   8 B/op   1 allocs/op
BenchmarkSort/n=100-8       1234567    972 ns/op   8 B/op   1 allocs/op
BenchmarkSort/n=1000-8        87654   13520 ns/op   8 B/op   1 allocs/op
BenchmarkSort/n=10000-8        9123  131234 ns/op   8 B/op   1 allocs/op

5 · The race detector

shell terminal
# Run all tests with the race detector
$ go test -race ./...

# 2-10x slower but catches every data race the test triggers.
# CI should run with -race on every PR.

If two goroutines access the same memory without synchronization and at least one writes, the race detector fires. It instruments memory access at compile time. A test that doesn't fail but reports "DATA RACE" is still a failure — your code has a bug.

6 · Examples — tests that are documentation

go example_test.go
func ExampleAdd() {
    fmt.Println(Add(2, 3))
    // Output: 5
}

// Multi-line output
func ExampleSum() {
    for _, v := range Sum([]int{1, 2, 3}) {
        fmt.Println(v)
    }
    // Output:
    // 1
    // 3
    // 6
}

Functions named ExampleXxx are tested AND shown as documentation in go doc and pkg.go.dev. The // Output: comment is matched against actual output — example breaks if output changes. Free documentation that can't drift.

7 · Fuzz testing (Go 1.18+)

go fuzz_test.go
func FuzzReverse(f *testing.F) {
    // Seed corpus
    f.Add("hello")
    f.Add("")
    f.Add("a")

    f.Fuzz(func(t *testing.T, in string) {
        out := Reverse(Reverse(in))
        if in != out {
            t.Errorf("Reverse(Reverse(%q)) = %q; want %q", in, out, in)
        }
        if !utf8.ValidString(out) {
            t.Errorf("invalid UTF-8: %q", out)
        }
    })
}

// $ go test -fuzz=FuzzReverse -fuzztime=30s
// The runtime mutates the seed corpus, generates new inputs, looks for
// crashes. Catches things you'd never think to test by hand.

8 · Common mistakes

  • Reaching for testify/Ginkgo/Gomega. The standard library has everything. if got != want { t.Errorf(...) } is enough. Reach for libraries only when reflection-based assertions actually pay off.
  • Forgetting t.Helper(). Without it, assertion errors point at your helper, not the test that called it.
  • Tests that share state. Two tests modifying a global. Make each test set up its own state via t.TempDir(), t.Cleanup.
  • Tests that depend on order. go test may run subtests in parallel. Don't depend on side effects between tests.
  • Slow tests in the default set. Use testing.Short() + -short flag for the slow integration suite.
  • No benchmark baseline. A bench result alone is meaningless. Run before and after the change; compare with benchstat.

9 · Exercises (~15 min)

  1. Table-driven for a Reverse function. 5 cases including empty, single char, palindrome, mixed.
  2. Benchmark slice vs array. Compare slicing a 1000-element array vs allocating a slice. go test -bench=. -benchmem.
  3. Run with -race. Spawn two goroutines writing to a shared int. Run the test under -race. Watch it fire.
  4. Add an Example. Write ExampleAdd for your function with // Output: comment. Run go test — the example runs and validates.

10 · When it clicks

  • You write tests in the same commit as the code, not after.
  • You reach for table-driven structure by reflex — even for 3 cases.
  • Your CI runs go test -race ./... on every PR.
  • You write benchmarks before optimisations and compare with benchstat.
Found this useful?