Benchmarks & Fuzzing
This lesson assumes 01-standard-library/01-testing — table-driven tests,
t.Run, and the difference between t.Error and t.Fatal are already
familiar ground. Here we cover the two other jobs the testing package
does: measuring performance, and searching for inputs that break your
code.
Benchmarks: func BenchmarkX(b *testing.B)
A benchmark looks like a test but its name starts with Benchmark and it
takes a *testing.B instead of a *testing.T. Plain go test never runs
benchmarks — you need -bench:
func BenchmarkReverse(b *testing.B) {
for b.Loop() {
Reverse("héllo")
}
}
go test -bench=. runs every benchmark in the package (the ASCII dot is
just the regexp — Go's benchmark selector, not something specific to this
example). Benchmarks are still valid go test targets, so -bench=. -run=^$ skips the regular tests if you only want timing output.
b.Loop() — the modern default
Older Go code loops for i := 0; i < b.N; i++: the framework picks b.N,
running your body enough times to get a stable measurement, growing it
across attempts. You'll still see it everywhere, so recognize it — but for
new code, prefer for b.Loop() (Go 1.24+):
func BenchmarkReverse(b *testing.B) {
for b.Loop() {
Reverse("héllo")
}
}
b.Loop() returns true until the framework has gathered enough
iterations, then false. Two things it buys you over b.N: timing starts
automatically on first call and excludes any setup you did before the
loop, and — the sharper edge — it prevents the compiler from noticing your
loop body's result is unused and optimizing the whole call away. With
b.N, a benchmark that never uses its computed value can get inlined into
nothing, and you end up measuring an empty loop without realizing it.
b.Loop() closes that hole structurally. Default to it.
Reading ns/op
Benchmark output looks like:
BenchmarkReverse-8 5000000 238 ns/op
-8 is GOMAXPROCS; the middle column is iteration count; ns/op is
nanoseconds per iteration, averaged. Compare ns/op across two
implementations of the same function to see which is actually faster —
never trust intuition here, and never compare runs from different
machines or load conditions.
Fuzzing: func FuzzX(f *testing.F)
A fuzz test explores inputs your table-driven tests didn't think of. It
starts from a seed corpus and takes a *testing.F:
func FuzzReverse(f *testing.F) {
f.Add("héllo")
f.Add("日本語")
f.Fuzz(func(t *testing.T, s string) {
// property checks, not exact-output checks
})
}
f.Add registers a seed input — a starting point you already know is
interesting. Plain go test runs only the seed corpus, as ordinary
subtests; that's fast, deterministic, and CI-safe. go test -fuzz=.
switches into fuzzing mode: it mutates inputs and keeps searching until it
finds a failure or you stop it. That's a local, interactive activity — not
something you wire into a CI pipeline that needs a fixed runtime.
The property-based mindset
Inside f.Fuzz, you don't know what string you're about to get, so you
can't write want := "expected output". Instead you assert properties
that must hold for any valid input: round-tripping (Reverse(Reverse(s)) == s), output validity (still valid UTF-8), or invariants (rune count
unchanged). This is a different design skill from table-driven testing —
you're describing the shape of correctness rather than enumerating
examples.
Where the corpus lives
When -fuzz finds a failing input, Go writes it to testdata/fuzz/<FuzzFuncName>/
as a new seed file, alongside whatever f.Add calls you wrote in code.
Commit that directory — a failure the fuzzer found once should never
regress silently.
Coverage, in one line
go test -cover reports the percentage of statements your tests
executed. It's a smell detector, not a target: 100% coverage doesn't mean
correct, and a drop between commits is worth investigating even if the
absolute number still looks fine.
Practice
1. Reverse, But Correctly
IntermediateThe starter compiles and even passes the ASCII cases — run the tests and watch the fuzz seeds tear it apart.
"héllo"reversed byte-by-byte splitsé(two bytes) in half and produces invalid UTF-8.Fix
Reverse(s string) stringso it reverses runes, not bytes:"héllo"→"olléh","日本語"→"語本日","go🚀"→"🚀og"- valid UTF-8 in, valid UTF-8 out;
Reverse(Reverse(s)) == s
(Scope note: we reverse code points, not grapheme clusters — combining characters are a deeper rabbit hole for another day.)
The hidden
FuzzReversechecks properties rather than exact outputs. On a plaingo testrun only thef.Addseed corpus executes — that's what grades you here. The lesson shows how-fuzzwould hunt for new inputs.package main import "fmt" // Reverse returns s with its characters in reverse order. // This first attempt reverses BYTES. Run the tests — the fuzz seed corpus // will show you exactly which inputs it shreds. 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) } func main() { fmt.Println(Reverse("héllo")) }Sign in to write and run your solution in the browser.
Sign in for hints and the solution.