Benchmarks That Keep You Honest, and a Fuzzer That Finds the Bug You Didn't
Measuring code with go test -bench and the modern b.Loop, reading a real ns/op B/op allocs/op line, then handing a naive string reverse to the fuzzer and watching it corrupt UTF-8 and write the failing input to disk. Coverage as a number, not a goal. Compiled and run against Go 1.26.5.
The last chapter established that go test runs your correctness tests. But the same tool, and the same _test.go files, do two more jobs that most languages farm out to separate frameworks: they measure how fast your code is, and they generate hostile inputs to break it. Benchmarks tell you whether a change made things faster or slower, in numbers instead of hunches. Fuzzing throws thousands of random inputs at a function and keeps the ones that make it misbehave. Both are built into the toolchain, both live beside your tests, and both are the kind of thing you reach for far less often than you should. Every number and every line of output below was run against Go 1.26.5.
Benchmarks measure, so you don’t have to guess
A benchmark is a function named BenchmarkXxx taking a *testing.B, in a _test.go file. The catch that makes benchmarks work is that you don’t pick how many times the code runs — the framework does, ramping the iteration count up until it has a stable measurement over a fixed slice of wall-clock time. Your job is to put the code under test inside the loop and let the runner decide the rest.
There are two ways to write that loop. The classic one, which you’ll see in every older codebase, ranges over b.N:
func BenchmarkConcat(b *testing.B) {
b.ReportAllocs()
for range b.N {
sink = Concat(words)
}
}
The modern one, added in Go 1.24 and present on 1.26.5, is b.Loop():
func BenchmarkBuild(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
sink = Build(words)
}
}
Prefer b.Loop() in new code. It reads more clearly, it runs the body a framework-chosen number of times just like b.N did, and it fixes a subtle old footgun: the compiler is not allowed to optimize away the work inside a b.Loop() body, and the loop’s setup runs exactly once. With the b.N form you had to defend against the optimizer yourself, which is why you’ll see that sink package-level variable — assigning the result somewhere visible stops the compiler from deciding the whole call is dead code and deleting it. The b.ReportAllocs() call asks for memory numbers alongside the timing.
The two functions being measured build the same string a hundred "book"s long, one by repeated += concatenation and one with a strings.Builder:
func Concat(words []string) string {
s := ""
for _, w := range words {
s += w
}
return s
}
func Build(words []string) string {
var b strings.Builder
for _, w := range words {
b.WriteString(w)
}
return b.String()
}
Run them with -bench (a regex of which benchmarks to run; . means all) and -benchmem (the memory columns):
$ go test -bench=. -benchmem
goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz
BenchmarkConcat-16 189102 6719 ns/op 21080 B/op 99 allocs/op
BenchmarkBuild-16 1834124 655.2 ns/op 1016 B/op 7 allocs/op
Read the columns left to right. The -16 is GOMAXPROCS, the number of CPUs the benchmark could use. The first number (189102, 1834124) is how many iterations the runner settled on — bigger means faster, since it fit more in the same time budget. Then the three that matter: ns/op is nanoseconds per call, B/op is bytes allocated per call, and allocs/op is the number of separate allocations per call.
The numbers tell a story. Concat allocates 21080 bytes across 99 allocations for a hundred words; Build allocates 1016 bytes across 7. That’s about a twentieth of the bytes (21080 → 1016) and a fourteenth of the allocations (99 → 7) — worth keeping straight, since the bytes ratio and the allocation ratio aren’t the same number. The timing moved the same direction, roughly an order of magnitude (the exact ns/op shifts run to run, so don’t hang a decision on the last digit), and the reason is baked into how strings work here: a Go string is immutable, so s += w can’t grow the existing string, it must allocate a whole new one and copy the old contents in — once per word, each copy longer than the last. strings.Builder keeps a growable byte buffer and appends into it, reallocating only when it runs out of room. This is the value of a benchmark: “concatenation in a loop is slow” is folklore until a number turns it into a decision. Run the benchmark before and after a change and you know which way it moved, instead of hoping.
Fuzzing finds inputs you’d never think to write
A table-driven test checks the cases you thought of. The bugs that reach production are, almost by definition, the cases you didn’t. Fuzzing closes that gap: you write a function that should hold for any input, and the fuzzer generates inputs — mutating your seeds, chasing new code paths — until it finds one that breaks the property, then shrinks it to something small and saves it.
A fuzz target is a function named FuzzXxx taking a *testing.F. You seed the corpus with f.Add, then call f.Fuzz with a function whose arguments after *testing.T are what gets fuzzed. Here’s one pointed at a naive string reverse — the kind of code that looks obviously correct:
func Reverse(s string) string {
b := []byte(s)
for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 {
b[i], b[j] = b[j], b[i]
}
return string(b)
}
The property we assert is modest: reversing valid UTF-8 should still produce valid UTF-8.
func FuzzReverse(f *testing.F) {
f.Add("hello")
f.Add("The Bookshop")
f.Fuzz(func(t *testing.T, s string) {
if !utf8.ValidString(s) {
return // only reason about valid input
}
rev := Reverse(s)
if !utf8.ValidString(rev) {
t.Errorf("Reverse produced invalid UTF-8 from valid input %q", s)
}
})
}
Fuzzing runs indefinitely, so you cap it. -fuzz takes a regex of which target to run, and -fuzztime bounds it:
$ go test -fuzz=FuzzReverse -fuzztime=10s
fuzz: elapsed: 0s, gathering baseline coverage: 2/2 completed, now fuzzing with 16 workers
fuzz: minimizing 38-byte failing input file
--- FAIL: FuzzReverse (0.04s)
reverse_test.go:17: Reverse produced invalid UTF-8 from valid input "Ӻ"
Failing input written to testdata/fuzz/FuzzReverse/550897f13842d703
To re-run:
go test -run=FuzzReverse/550897f13842d703
FAIL
It failed in four hundredths of a second. The bug: Reverse swaps bytes, and any rune outside ASCII is more than one byte. The character Ӻ is two bytes; reverse them and you get a byte sequence that isn’t valid UTF-8 at all. The fuzzer found a two-byte rune, shrank a 38-byte failing input down to that single character, and told you exactly which input to blame. The fix is to reverse runes, not bytes — but the point of this chapter is the tool that caught it.
Notice the last two lines. The failing input was written to testdata/fuzz/FuzzReverse/550897f13842d703, and that file is now a permanent regression test:
$ cat testdata/fuzz/FuzzReverse/550897f13842d703
go test fuzz v1
string("Ӻ")
From now on, a plain go test — no -fuzz flag — replays every file in testdata/fuzz/ as an ordinary test case. So the fuzzer discovers the bug once, and the corpus file makes sure it can never come back silently. Check that file into version control alongside the code it broke.
Coverage is a number, not a goal
The third measurement is which lines your tests actually ran. Add -cover:
$ go test -cover
ok example.com/textutil coverage: 66.7% of statements
For detail, write a profile and render it per-function:
$ go test -coverprofile=cover.out
$ go tool cover -func=cover.out
price.go:6: Format 66.7%
total: (statements) 66.7%
The tested function formats a price and has two branches, one for negative amounts; the test only exercised the positive branch, so a third of its statements never ran, hence 66.7%. go tool cover -html=cover.out opens the same data in a browser with the covered lines in green and the missed ones in red, which is the fastest way to see the gap.
Here’s the discipline to attach to that number: coverage tells you what your tests didn’t touch, and that’s genuinely useful — the red lines are where bugs hide unseen, exactly like the untested negative branch above. But high coverage does not mean correct. A test can execute a line and assert nothing meaningful about it, and 100% coverage of code with the wrong logic is 100% coverage of a wrong program. Chase coverage to find the holes, not to hit a percentage. A number you optimize for stops measuring what you wanted the moment it becomes the target.
Final thoughts
The one go test command measures three things beyond correctness, all from files sitting next to your code. Benchmarks (BenchmarkXxx, the modern for b.Loop(), -bench and -benchmem) turn “is this faster” into a real ns/op/B/op/allocs/op line you can compare across changes — and they’ll teach you things, like strings.Builder beating += by roughly an order of magnitude in time while cutting the allocations from 99 to 7 and the bytes to a twentieth. Fuzzing (FuzzXxx, f.Add, f.Fuzz) generates the hostile inputs you’d never write by hand, finds the one that breaks your property in a fraction of a second, and saves it to testdata/fuzz/ as a regression test that runs forever after. And coverage (-cover, -coverprofile) shows you what went untested — a map of where to look, not a score to win. Next we turn to generating HTML: the html/template package, and the contextual auto-escaping that shuts the door on cross-site scripting before you’ve thought to worry about it.
Next: HTML templates — generating HTML safely, with auto-escaping that stops XSS before you think to.
Comments