Skip to content
System nominalUTC --:--:--Articles 080
MODULE 06 Lesson 16 / 18 40 MIN

Production

Table Tests and Benchmarks

The table, subtest and parallel run Go tests are built around, benchmarks that measure memory honestly, and a fuzzer that finds the input you did not think of.

The tests you write first are the ones that are easiest to write, and that is the problem. A function with three cases gets three test functions, each with its own copy of the setup, and by the fourth case you are copying a block and hoping you changed every line that needed changing. A test that passes because it still checks the old input is worse than no test: it is a claim about the code that nobody verified.

Go's testing package has one shape that removes most of that: a slice of cases, one loop, one subtest per case. Parallel subtests, benchmarks and fuzz targets all build on it, and the tooling is designed around the names t.Run produces.

A table is the default shape, and t.Run is what makes it readable

The parser in this lesson reads a line of the form topic|partition|bytes|key=value,.... Eight behaviours matter and they are all the same kind of thing: an input, and either a result or an error. That is a table:

func TestParse(t *testing.T) {
	tests := []struct {
		name       string
		line       string
		wantTopic  string
		wantParts  int
		wantErr    error
	}{
		{name: "full line", line: line, wantTopic: "orders.created", wantParts: 3},
		{name: "no tags", line: "metrics|0|64", wantTopic: "metrics"},
		{name: "empty", line: "", wantErr: ErrShortLine},
		{name: "two fields", line: "topic|1", wantErr: ErrShortLine},
		{name: "partition is not a number", line: "topic|x|4", wantErr: ErrNotANumber},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := Fast(tt.line)
			if !errors.Is(err, tt.wantErr) {
				t.Fatalf("Fast(%q) error = %v, want %v", tt.line, err, tt.wantErr)
			}
			if tt.wantErr != nil {
				return
			}
			if got.Topic != tt.wantTopic {
				t.Errorf("Topic = %q, want %q", got.Topic, tt.wantTopic)
			}
		})
	}
}

Two decisions in that snippet carry most of the value. The cases name themselves, so a failure points at the case rather than a line number. And the expected error is a sentinel compared with errors.Is, not a string compared with ==. A test asserting on the exact text of a strconv error breaks the day somebody adds context to it, and breaks for the wrong reason: the behaviour did not change, the message did.

Passing cases print one line each when you ask for them, which is how a table tells you it covered what you thought it covered:

$ go test ./internal/feed/ -run TestParse -v
=== RUN   TestParse
=== RUN   TestParse/full_line
=== RUN   TestParse/no_tags
=== RUN   TestParse/empty
=== RUN   TestParse/two_fields
--- PASS: TestParse (0.00s)
    --- PASS: TestParse/full_line (0.00s)
    --- PASS: TestParse/no_tags (0.00s)
    --- PASS: TestParse/empty (0.00s)
    --- PASS: TestParse/two_fields (0.00s)
PASS

That same name is a filter. -run 'TestParse/two_fields' runs one case and skips the rest. -run 'TestParse/partition' runs every case whose name contains partition, so a table with sixty cases can be bisected by naming part of the input you are chasing. The separator is a slash and the pattern is a regular expression, not a glob.

Subtests also give each case its own *testing.T, which is what makes t.Fatalf correct inside the loop. Without t.Run, a Fatal in case three abandons cases four through sixty and the report never says which input did it.

t.Parallel plus a captured loop variable is the trap vet still checks

Cases that touch no shared state are worth running at the same time, and the marker for that is t.Parallel() as the first statement inside the subtest. The subtests pause, the parent function finishes building the table, and the parallel cases then run together at the end of the parent.

The mistake that used to come with it is worth understanding even though the language changed. Before Go 1.22 the loop variable was one variable reused by every iteration, so a closure that ran later saw the last value it held, which under t.Parallel() is the last case for every subtest. go vet caught it and still checks it. Point vet at a module that declares go 1.21:

$ go vet ./...
catalog/catalog_test.go:58:19: loop variable tt captured by func literal
catalog/catalog_test.go:58:34: loop variable tt captured by func literal
catalog/catalog_test.go:59:40: loop variable tt captured by func literal
catalog/catalog_test.go:59:52: loop variable tt captured by func literal

From Go 1.22 onward, with the module's go line at 1.22 or higher, each iteration of a for loop declares fresh variables, so the capture is safe and the same code reports nothing. It is a change in the language, and it only applies to modules that declare it: a module on go 1.21 keeps the old semantics under a newer toolchain. Tests written before 1.22 often carry tt := tt as the first line of the subtest; vet does not flag it and there is no reason to touch it.

The other half of t.Parallel is that it makes a data race in the code under test far more likely to appear, which is a feature. Run the suite with -race and a table whose subtests share a parser or a map reports the race instead of hiding it behind a lucky schedule.

-benchmem turns a benchmark into two numbers instead of one

A benchmark is a function whose name starts with Benchmark and which takes *testing.B. The framework calls it repeatedly, raising b.N until the run is long enough to measure, and reports time per iteration. A benchmark that reports only time cannot see what usually causes the time:

func BenchmarkParse(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		m, err := parse(line)
		if err != nil {
			b.Fatal(err)
		}
		consume(m) // keep the result observable or the compiler may remove the call
	}
}
$ go test ./internal/feed/ -run '^$' -bench '^BenchmarkParse$' -benchmem -count=8
BenchmarkParse-8   	 3254022	       364.4 ns/op	     448 B/op	       4 allocs/op

Four allocations per parse, 448 bytes. The number after the benchmark name is GOMAXPROCS, so BenchmarkParse-8 ran on a machine configured for eight threads, and two runs on different machines are not comparable. -count=8 shows the spread rather than one number that might be the good one. Nothing in a benchmark is trustworthy until you have seen it repeat.

The allocation count is the number to watch when a profile says the collector is busy: 4 allocations per call on a hot path is 4 million per million requests, and each one is work the collector has to undo. Cutting allocations moves flat CPU time in a way that hand-tuning instructions rarely does.

A benchmark without b.ResetTimer after setup measures your setup

The timer starts before the benchmark function is called, so everything it does before the loop is charged to the measurement. Code that reads a fixture, opens a file or waits for a connection is setup, and leaving it inside the timed region makes the reported number a property of the fixture. At -benchtime=1x, which runs the loop exactly once and is the honest way to see this, the difference is the whole number:

$ go test ./internal/feed/ -run '^$' -bench 'ParseWithSetup' -benchtime=1x
BenchmarkParseWithSetup-8        	       1	 201206625 ns/op
BenchmarkParseWithSetupReset-8   	       1	     27375 ns/op

Both benchmarks sleep 200 milliseconds and then parse one line. The first reports 201 milliseconds because the sleep is inside the timed region; the second reports 27 microseconds because b.ResetTimer() sits between the setup and the loop. Twenty-seven microseconds is a sensible number for parsing a 60-byte line with a map allocation, and 201 milliseconds is a number about a sleep.

The subtler version of the same bug is a benchmark whose setup is small, so the number looks plausible. At -benchtime=5x the same two benchmarks report around 40 milliseconds and 5.5 microseconds, and the first now looks like a slow parser rather than a broken measurement. The fix is always the same three lines: build the fixture, call b.ResetTimer(), enter the loop.

Two smaller pieces are worth knowing. b.StopTimer() and b.StartTimer() bracket work inside the loop that is not part of what you are measuring, such as regenerating an input. And if the compiler can prove a result is unused it deletes the call, so assign to a package-level variable or hash it.

benchstat compares two runs; your eyes compare two moods

benchstat is not in the Go distribution. It is a command from the golang.org/x/perf module and it is installed with the standard go install form:

$ go install golang.org/x/perf/cmd/benchstat@latest
$ go test ./internal/feed/ -run '^$' -bench '^BenchmarkParse$' -benchmem -count=8 > before.txt
# change the implementation
$ go test ./internal/feed/ -run '^$' -bench '^BenchmarkParse$' -benchmem -count=8 > after.txt
$ benchstat before.txt after.txt
        │ before-parse.txt │          after-parse.txt           │
        │      sec/op      │   sec/op     vs base               │
Parse-8        378.0n ± 5%   183.3n ± 3%  -51.49% (p=0.000 n=8)

        │ before-parse.txt │         after-parse.txt          │
        │       B/op       │   B/op    vs base                │
Parse-8         448.0 ± 0%   0.0 ± 0%  -100.00% (p=0.000 n=8)

        │ before-parse.txt │          after-parse.txt           │
        │    allocs/op     │ allocs/op   vs base                │
Parse-8         4.000 ± 0%   0.000 ± 0%  -100.00% (p=0.000 n=8)

The change was the parser: instead of splitting the line into a []string and building a fresh map per call, the new version walks the string by index and reuses a map the caller owns. Allocations per parse went from 4 to 0, bytes from 448 to nothing, and the time halved, from 378 nanoseconds to 183.

What benchstat adds is the second number on each line. ± 5% is the spread across the eight runs, p=0.000 the probability of a difference this large if the two runs were the same, and n=8 the sample count. A result of -3% (p=0.42) is noise; -3% (p=0.001) is a real three percent. Without that column, the only thing separating a real improvement from a warm laptop is which run you did second, and that is usually the one you believe.

A fuzzer finds the input you did not put in the table

A table is a list of the cases you thought of, which is its weakness. Fuzzing generates inputs instead, from a function that takes any supported type plus a seed corpus of examples you already know about. string and []byte are the two types that matter for a parser.

The target worth writing is differential: two implementations of one contract, which must agree on which lines are valid.

func FuzzFastAgainstSlow(f *testing.F) {
	f.Add(line)
	f.Add("metrics|0|64")
	f.Add("a|1|2|x=1,y=2")
	f.Add("||")

	f.Fuzz(func(t *testing.T, s string) {
		if n := strings.Count(s, "|"); n != 2 && n != 3 {
			t.Skip() // not a line the feed can send
		}
		want, werr := Slow(s)
		got, gerr := Fast(s)
		if (werr == nil) != (gerr == nil) {
			t.Fatalf("disagree on error for %q: Slow=%v Fast=%v", s, werr, gerr)
		}
		if werr != nil {
			return
		}
		if got.Topic != want.Topic || got.Partition != want.Partition || got.Bytes != want.Bytes {
			t.Fatalf("disagree for %q: Slow=%+v Fast=%+v", s, want, got)
		}
	})
}

The t.Skip() is not hiding a failure: the wire format has a fixed number of separators, so an input the feed cannot produce is outside the contract. Without the guard the fuzzer spends its budget on strings with forty pipes in them.

Run it for a fixed time, not forever:

$ go test ./internal/feed/ -run '^$' -fuzz FuzzFastAgainstSlow -fuzztime 30s
fuzz: elapsed: 0s, gathering baseline coverage: 0/6 completed
fuzz: elapsed: 0s, gathering baseline coverage: 6/6 completed, now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 433579 (144521/sec), new interesting: 108 (total: 114)
fuzz: elapsed: 30s, execs: 6558593 (219828/sec), new interesting: 195 (total: 201)
PASS
ok  	lab.example/m6/internal/feed	30.380s

Six and a half million inputs and no disagreement, which is itself a result. The target that found something checked a property of one implementation: Fast calls strconv.Atoi on two fields, and an early draft discarded the error with _, so a malformed line came back as a message with partition zero. The property is direct: if the parser returns no error, its fields must be numbers that were in the input. The fuzzer minimised the failure to four bytes.

--- FAIL: FuzzFastKeepsAtoiErrors (0.00s)
    --- FAIL: FuzzFastKeepsAtoiErrors/56b5842c3bd02ba4 (0.00s)
        fuzz_test.go:105: Fast("|0|A") returned no error for bytes "A", which is not a number

    Failing input written to testdata/fuzz/FuzzFastKeepsAtoiErrors/56b5842c3bd02ba4

Four bytes, and the file it wrote is the regression test. A failing input is stored under testdata/fuzz/<TargetName>/, it replays on every later go test run without the -fuzz flag, and it should be committed. The bug cannot come back, because the case that found it is now part of the suite.

Lab 45 minutes

Take the line parser from this lesson and do three things to it.

First, convert a hand-written test into a table with named cases and check that a deliberate failure names the case rather than a line. Then re-run only that failing case with -run and a subtest name.

Second, benchmark the parser with -benchmem -count=8 and save the output. Now make the parser allocate one map per call, benchmark again, and run benchstat over the two files. Write down the allocs/op and the p-value, and say whether the difference is real.

Third, write a fuzz target for the tag list with the property that parsing a valid list and re-serialising it produces the same set of pairs. Run it for 60 seconds. If it finds nothing, say what that does and does not prove.

Show the solution and the reasoning

The first part is mechanical, but the failing case is the informative one. If the output names TestParse/partition_is_not_a_number, the table is doing its job. If it names a line number, the case is missing its name or the assertions are outside t.Run.

For the second part, the map-per-call version reports one more allocation per operation than the reuse version. What matters is the habit: two files, one tool, no conclusion from a single run. If the p-value comes back above 0.05, the honest reading is that your change did not move the metric, which is worth learning before shipping it.

The third part has a real trap: proving a negative. A fuzzer that runs for 60 seconds and finds nothing has shown the property held for the inputs it tried, not that it is true. Two things raise confidence without raising the runtime. Seed the corpus with the awkward inputs you already know, such as an empty tag list, a tag with no =, a value containing a comma, or a whitespace key, because the seed corpus is where the fuzzer starts and a target that never reaches a branch never tests it. And make the property specific enough to fail: "the parse succeeds" is nearly unfalsifiable, while "re-serialising the pairs produces the same set" fails the moment a tag is dropped.

One detail is worth checking by hand before you trust the result. If two tags share a key, the map keeps the last one and the round trip loses a pair, so either the property is "same set of pairs" for maps or the parser has to reject duplicate keys. Deciding which you mean is the actual work, and the fuzzer will say so as soon as it generates a=1,a=2.

Official reference

The Go standard library documentation on pkg.go.dev for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.