Testing and Benchmarking in Go

AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.

Go 1.24

Testing is part of the toolchain: go test is the whole framework. No assertion library, no test runner to configure. This chapter covers how to write tests, how to run them, and how to find what is slow.

Before you start

The examples are verified on Go 1.24 and use only the standard library.

The first test

// calc/calc.go
package calc

import "errors"

var ErrDivideByZero = errors.New("division by zero")

func Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, ErrDivideByZero
    }
    return a / b, nil
}
// calc/calc_test.go
package calc

import (
    "errors"
    "testing"
)

func TestDivide(t *testing.T) {
    got, err := Divide(10, 2)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if got != 5 {
        t.Errorf("Divide(10, 2) = %v, want 5", got)
    }
}

The rules:

  • The file ends in _test.go and lives in the same package (package calc) or an external test package (package calc_test).
  • The function is TestXxx(t *testing.T) with an uppercase Xxx.
  • t.Errorf records a failure and continues; t.Fatalf stops the current test function immediately.
  • Test files are not part of a normal build and never end up in the binary.

Table-driven tests

Cases become a slice; adding one is a single line:

func TestDivide_Table(t *testing.T) {
    tests := []struct {
        name    string
        a, b    float64
        want    float64
        wantErr error
    }{
        {name: "whole", a: 10, b: 2, want: 5},
        {name: "fraction", a: 1, b: 4, want: 0.25},
        {name: "negative", a: -8, b: 2, want: -4},
        {name: "by zero", a: 1, b: 0, wantErr: ErrDivideByZero},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := Divide(tt.a, tt.b)

            if tt.wantErr != nil {
                if !errors.Is(err, tt.wantErr) {
                    t.Fatalf("err = %v, want %v", err, tt.wantErr)
                }
                return
            }
            if err != nil {
                t.Fatalf("unexpected error: %v", err)
            }
            if got != tt.want {
                t.Errorf("got = %v, want %v", got, tt.want)
            }
        })
    }
}

t.Run creates a subtest whose name shows up in the output (TestDivide_Table/by_zero) and can be run alone:

go test ./... -run 'TestDivide_Table/by_zero'

Since Go 1.22 the loop variable is per iteration, so tt is safe to capture. The old tt := tt ritual is unnecessary — but explicit parameters still read more clearly in goroutines.

Helpers and cleanup

func newTestDB(t *testing.T) *sql.DB {
    t.Helper()                                  // failures point at the caller's line

    db, err := sql.Open("sqlite", "file::memory:?cache=shared")
    if err != nil {
        t.Fatalf("open db: %v", err)
    }

    t.Cleanup(func() { db.Close() })            // runs when the test and its subtests end
    return db
}

t.TempDir() gives you a directory that is removed automatically — the right tool for anything file-related:

func TestWriteFile(t *testing.T) {
    dir := t.TempDir()
    path := filepath.Join(dir, "out.txt")

    if err := os.WriteFile(path, []byte("hi"), 0o644); err != nil {
        t.Fatal(err)
    }

    data, err := os.ReadFile(path)
    if err != nil || string(data) != "hi" {
        t.Fatalf("read = %q, %v", data, err)
    }
}

For parallel tests, call t.Parallel() at the top of the test function — and be sure nothing shares mutable state.

Testing an HTTP handler

func TestHandler(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/hello?name=go", nil)
    rec := httptest.NewRecorder()

    hello(rec, req)

    res := rec.Result()
    defer res.Body.Close()

    if res.StatusCode != http.StatusOK {
        t.Fatalf("status = %d", res.StatusCode)
    }
    body, _ := io.ReadAll(res.Body)
    if !strings.Contains(string(body), "go") {
        t.Errorf("body = %s", body)
    }
}

httptest never binds a port, so these tests are fast and do not collide in CI.

Benchmarks

func BenchmarkJoin(b *testing.B) {
    parts := []string{"a", "b", "c", "d"}

    for b.Loop() {               // Go 1.24 and later
        _ = strings.Join(parts, ",")
    }
}

The older form, for i := 0; i < b.N; i++, still works. Migrating matters because b.Loop() keeps the loop body from being optimised away and excludes setup from the measurement automatically.

go test -bench=. -benchmem ./...
BenchmarkJoin-8   12345678   95.2 ns/op   32 B/op   1 allocs/op

Three numbers matter: time per operation, bytes allocated per operation, and allocation count. Cutting allocs/op usually pays better than shaving nanoseconds — every allocation is future GC work.

To compare two implementations, use benchstat (golang.org/x/perf/cmd/benchstat):

go test -bench=. -count=10 > old.txt
# change the code
go test -bench=. -count=10 > new.txt
benchstat old.txt new.txt

A single run is mostly noise; sample repeatedly and compare the statistics.

Coverage

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

Coverage measures executed lines, not assertion quality. 80% is not a goal — the interesting part is which branches are missing: error paths, boundary values, empty input.

Fuzzing

func FuzzReverse(f *testing.F) {
    f.Add("hello")
    f.Add("abc")

    f.Fuzz(func(t *testing.T, s string) {
        got := Reverse(Reverse(s))
        if got != s {
            t.Fatalf("double reverse = %q, original %q", got, s)
        }
    })
}
go test -fuzz=FuzzReverse -fuzztime=30s

The fuzzer generates inputs and writes any failing case to testdata/fuzz/, where go test replays it as a regression test from then on. Parsers, serialisers and string handling benefit the most.

Example tests

func ExampleDivide() {
    q, _ := Divide(10, 4)
    fmt.Println(q)
    // Output: 2.5
}

The // Output: comment makes go test compare stdout, so the example is both a test and documentation that cannot drift.

Habits that hold up

  • Keep tests next to the package under test; no separate tests/ tree.
  • Use in-package tests to reach unexported identifiers, _test packages to test only the public API.
  • One behaviour per test, and every table case needs a readable name.
  • Run go test ./... -race in CI. Concurrency bugs rarely show up otherwise.
  • Gate slow tests with testing.Short():
func TestIntegration(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping integration test")
    }
    // ...
}
go test -short ./...

Common mistakes

1. Using t.Log as an assertion

Logging never fails a test. Assertions go through t.Error / t.Fatal.

2. Calling t.Fatalf from a goroutine

Fatalf must run on the test goroutine. In a worker, use t.Errorf and report through a channel.

3. Forgetting defer res.Body.Close()

Connections stay open and a large suite can exhaust them.

4. Tests that depend on order

Go does not guarantee order, and -shuffle exposes it. Each test prepares its own state.

5. Real network and real time

External services make CI flaky. Use httptest and injected interfaces; inject now func() time.Time instead of calling time.Now() inline.

6. Measuring setup in a benchmark

Initialise before the loop and call b.ResetTimer() when preparation is unavoidable.

7. Running go test without -race

Data races often stay invisible. Add -race to CI.

Summary

  • The testing package is enough: table-driven tests plus t.Run is the default shape.
  • t.Helper moves failure lines to the caller, t.Cleanup and t.TempDir manage resources.
  • Benchmarks: watch ns/op, B/op, allocs/op; sample repeatedly before believing a difference.
  • Coverage finds untested branches, fuzzing finds crashes at the edges, examples document behaviour.
  • CI should at least run go test ./... -race.

Next: propagating cancellation and deadlines with context.