Lesson 29. Fuzzing
Mission link: Table tests check the cases you thought of. Fuzzing finds the ones you did not, which is exactly the category that reaches production.
Primary source: Tutorial: Getting started with fuzzing, The Go Authors
Prerequisites: Lesson 28
Warm-up
- ▢ Why does
t.Cleanupbeatdeferin a helper used by a parallel subtest?
Check
defer fires when the helper returns, and a parent's defer fires while parallel subtests are still running. t.Cleanup runs when that specific test finishes.
- ▢ What is the
got, wantconvention for a failure message?
Check
t.Errorf("Parse(%q) = %v, want %v", in, got, want): the call, the result, the expectation, on one line, so the failure is readable without opening the file.
Know this
A fuzz test states a property and lets the toolchain generate inputs looking for a counterexample:
func FuzzRoundTrip(f *testing.F) {
f.Add("3h") // seed corpus, known-interesting inputs
f.Add("")
f.Add("1h30m")
f.Fuzz(func(t *testing.T, s string) {
d, err := Parse(s)
if err != nil {
return // rejecting bad input is correct behaviour, not a failure
}
got, err := Parse(Format(d))
if err != nil {
t.Fatalf("Format produced unparseable output %q: %v", Format(d), err)
}
if got != d {
t.Errorf("round trip: Parse(Format(%v)) = %v", d, got)
}
})
}
Two commands:
go test ./... # runs the seed corpus only, as an ordinary test
go test -fuzz=FuzzRoundTrip # generates inputs until it finds a failure or you stop it
That split matters. Fuzz targets run as normal tests in CI over their corpus, and the open-ended search is something you run deliberately, for minutes or hours, when working on the code.
When a failing input is found it is minimised and written to testdata/fuzz/FuzzRoundTrip/. Commit that file: from then on it is part of the seed corpus and every go test run checks it. A fuzz finding becomes a regression test with no work.
What makes a good property
The hard part is not the mechanics, it is finding an assertion that holds for every input. Four that usually exist:
| Property | Shape |
|---|---|
| Round trip | Decode(Encode(x)) == x |
| Invariant | output is always sorted, or always valid UTF-8, or length is preserved |
| Differential | your fast implementation agrees with the obvious slow one |
| Never panics | the function returns an error rather than crashing on any input |
"Never panics" is the weakest and still worth having for anything parsing untrusted input, which is every request body, every header, every filename. It is the whole reason fuzzing was invented.
Reject invalid input by returning early, not by failing. A parser rejecting garbage is correct; the fuzzer will move on and try something else.
Limits worth knowing
The fuzzing engine supports a fixed set of parameter types: []byte, string, the integer types, float32/float64, bool, and rune. To fuzz a struct, take a []byte and build the struct from it deterministically.
The search is coverage-guided but not exhaustive. A clean overnight run is evidence, not proof, the same caveat as the race detector in Lesson 20, for the same reason: both observe what actually executed.
Fuzzing is slow relative to unit tests, so keep the body fast. A target that touches a database will find almost nothing, because the engine gets a few hundred executions a second instead of a few hundred thousand.
Practice
- ▢ Your parser returns an error on random bytes. Is that a fuzz failure?
Check
No, it is the parser working. Return early from the fuzz function when the input is legitimately invalid.
Failing on any error would make the fuzzer report noise immediately and constantly, and the real property, that valid input round-trips and that nothing panics, would never get exercised.
- ▢ The fuzzer finds a crashing input. What do you do with it?
Check
Commit the file it wrote under testdata/fuzz/. It becomes part of the seed corpus, so every subsequent go test replays it, with no -fuzz flag.
That turns each finding into a permanent regression test at zero cost, which is the part of Go's fuzzing design that makes it worth adopting rather than just trying.
-
▢ Which is the strongest property to fuzz for a JSON encoder?
- a) That the output never contains a null byte
- b) That decoding the encoded value returns it
- c) That the output is shorter than the input
- d) That the function completes within one second
Check
b) That decoding the encoded value returns it.
A round trip constrains the whole encoding rather than one surface detail, so it catches quoting, escaping and Unicode bugs together. Option a is a narrow invariant, c is not even true, and d tests the machine more than the code.
- ▢ You want to fuzz a function taking a
Configstruct. The engine only accepts primitives. What do you do?
Check
Take a []byte and construct the Config from it deterministically: slice fields out of the bytes, or feed it through a decoder you already trust.
Deterministic matters: the engine minimises a failing input by shrinking the bytes and re-running, so the same bytes must always produce the same Config. Anything random in the construction makes the reported failure unreproducible.
- ▢ Interleaving Lesson 5: why is a UTF-8-handling function a good fuzz target?
Check
Because the input space is enormous and the interesting cases are exactly the ones nobody writes by hand: truncated multi-byte sequences, overlong encodings, lone surrogates, combining marks.
Lesson 5's rule, that len is bytes and indexing is bytes and only range decodes, is violated by code that slices a string at an arbitrary index. A fuzzer finds that in seconds by producing a string where the boundary lands mid-rune, which is the same bug that mangles a real user's name in production.
Real-world reps
- [ ] Write a
Format/Parsepair and fuzz the round trip. Run it for two minutes and see whether it finds anything. - [ ] Deliberately introduce a bug by slicing a string at a byte index, and watch the fuzzer produce the multi-byte input that breaks it. Commit the corpus file it writes.
- [ ] Tomorrow: find one function in a service you own that parses untrusted input. Write the "never panics" fuzz target for it, even if you write nothing else.
Going further
- Tutorial: Getting started with fuzzing
- Go Fuzzing documentation: supported types, corpus layout, and how minimisation works
- Toolchain Commands
- Resources
Not landing? Reread the primary source at the top, since this lesson compresses it and compression is where understanding leaks. Check the glossary for any term that felt slippery.
If the lesson itself is unclear rather than the material, that is a defect: open an issue.