🎲 Analogy
A normal test asks “does it work for the cases I thought of?” A fuzzer asks “does it work for the cases I didn’t?” It’s a tireless monkey at the keyboard, mashing mutated inputs into your function and watching for the moment it panics or breaks a rule you swore was always true. You don’t supply answers — you supply a property that must always hold.
A fuzz target
Native fuzzing arrived in Go 1.18. A fuzz target is func FuzzXxx(f *testing.F): you seed it with f.Add, then hand f.Fuzz a function whose arguments the fuzzer fills with generated values. The body asserts a property:
// reverse_test.go
package strutil
import "testing"
func FuzzReverse(f *testing.F) {
// seed corpus: starting points the fuzzer mutates from
f.Add("")
f.Add("abc")
f.Add("héllo")
f.Fuzz(func(t *testing.T, in string) {
// property: reversing twice returns the original
got := Reverse(Reverse(in))
if got != in {
t.Errorf("Reverse(Reverse(%q)) = %q, want %q", in, got, in)
}
// property: reversing preserves rune count
if len([]rune(Reverse(in))) != len([]rune(in)) {
t.Errorf("Reverse(%q) changed rune count", in)
}
})
}
Run it with -fuzz. Without that flag, go test just runs the seed corpus as ordinary cases:
# seed corpus only (fast, runs in CI)
$ go test -run FuzzReverse
# actually fuzz: generate inputs until you stop it or it finds a failure
$ go test -fuzz=FuzzReverse
fuzz: elapsed: 0s, gathering baseline coverage: 0/3 completed
fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing
fuzz: elapsed: 3s, execs: 412300 (137401/sec), new interesting: 9
^Cfuzz: elapsed: 5s, execs: 700120 (0/sec), interrupted
If it finds a failing input, Go writes it to testdata/fuzz/FuzzReverse/ so the failure becomes a permanent regression test that reruns every time:
$ go test -fuzz=FuzzReverse
--- FAIL: FuzzReverse (0.01s)
--- FAIL: FuzzReverse/a1b2c3 (0.00s)
reverse_test.go:14: Reverse(Reverse("\xed...")) = "...", want "..."
Failing input written to testdata/fuzz/FuzzReverse/a1b2c3
What the fuzzer does
The fuzzer takes your seeds, mutates them, and runs the body on each result — guided by code coverage, so it steers toward inputs that reach new branches:
graph TD
S["f.Add seeds"] --> C["seed corpus"]
C --> M["mutate / generate input"]
M --> R["run f.Fuzz body"]
R --> P{"panic or t.Errorf?"}
P -->|"no, new coverage"| C
P -->|"no, same coverage"| M
P -->|"yes"| W["save input to testdata/fuzz/ as regression"]Here’s the property running over sample inputs in main, so you can see why Reverse(Reverse(s)) == s should always hold — and edit Reverse to watch it break:
package main
import "fmt"
// Reverse is the function under test (rune-safe).
func Reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
func main() {
inputs := []string{"", "a", "abc", "level", "héllo", "Go 1.25"}
for _, in := range inputs {
round := Reverse(Reverse(in))
fmt.Printf("in=%q reverse=%q roundtrip=%q holds=%t\n",
in, Reverse(in), round, round == in)
}
}
⚠️ f.Add types must match f.Fuzz exactly
The arguments to f.Add must line up — in count, order, and type — with the parameters of the f.Fuzz function (after the leading *testing.T). If f.Fuzz takes (t *testing.T, in string, n int), every seed must be f.Add("x", 7). A mismatch fails at runtime, not compile time. Also note fuzzing only supports a fixed set of argument types (string, []byte, the numeric types, bool, rune/byte) — not arbitrary structs.
See also
- table-driven tests — cover the cases you know; fuzz the ones you don’t.
- testing basics — the
*testing.Tyourf.Fuzzbody receives. - benchmarks — the third
testingmode, for speed rather than correctness. - property-style invariants — round-trips and “never panics” are the bread and butter of fuzz properties.
Next: measuring how fast that code actually runs — benchmarks.
Related topics
The idiomatic Go pattern — a slice of named cases looped through t.Run subtests, with t.Parallel, so one test scales to dozens of inputs.
testingTestingThe testing package and go test — writing TestXxx functions, Errorf vs Fatalf, t.Helper, and the got/want convention that runs with go test ./...
testingBenchmarksMeasuring speed with BenchmarkXxx and b.N — reading ns/op, B/op and allocs/op, and the classic += vs strings.Builder comparison.
Check your understanding
Score: 0 / 51. What does a fuzzer actually do?
A fuzz target asserts a property over an input. The fuzzer mutates the seed corpus to generate new inputs, running coverage-guided to reach new code paths, and reports any input that panics or trips a t.Errorf/t.Fatalf.
2. What kind of check makes a good fuzz property?
Fuzzing shines on universal properties — round-trips, idempotence, 'never panics', output always valid — because they hold for every input the fuzzer can dream up. A single fixed input/output pair is just a normal table case.
3. What is f.Add used for?
f.Add provides seed inputs. The fuzzer runs them directly and uses them as a starting point for mutation. Good seeds (edge cases, realistic values) help the fuzzer reach interesting code faster. The arg types must match the f.Fuzz function signature.
4. When the fuzzer finds a failing input, what happens to it?
A crashing input is written under testdata/fuzz/<FuzzName>/ (commit it). After that, even a normal `go test` run replays it as a regular case, so the bug can't silently come back once fixed.
5. Why run a fuzz target with plain `go test` (no -fuzz) in CI?
`-fuzz` runs until you stop it, so it's not for normal CI. Plain `go test` executes the f.Add seeds plus everything in testdata/fuzz as quick deterministic cases — so the corpus and past failures guard the code on every run. Schedule real fuzzing separately.
Comments
Sign in with GitHub to join the discussion.