Introduction
Manual test cases only cover the scenarios you think of. Fuzz testing flips the script: Go's built-in fuzzer generates thousands of random inputs per second, probing your code for crashes, panics, and logic errors you never anticipated. Introduced in Go 1.18, fuzzing has become a standard tool for hardening parsers, validators, and any function that accepts untrusted input.
Key Concepts
- testing.F: The type passed to fuzz test functions. It provides
f.Add()for seeding the corpus andf.Fuzz()for defining the fuzz target. - Seed corpus: A set of known-good inputs provided via
f.Add()that the fuzzer uses as starting points for mutation. - Fuzz target: The function passed to
f.Fuzz()that receives randomly generated inputs and checks invariants. - Corpus directory: The
testdata/fuzz/FuzzName/directory where the fuzzer saves inputs that triggered failures, turning discovered bugs into permanent regression tests.
Real World Context
You maintain a JSON configuration parser that has worked reliably for months. You write a fuzz test with a round-trip invariant: parse the input, serialize the result, and parse again. Within 10 seconds, the fuzzer discovers that a deeply nested object with escaped Unicode sequences causes a panic in your parser. The failing input is automatically saved to testdata/fuzz/FuzzConfig/, so every future go test run replays it as a regression test. You fix the bug and the fuzz test becomes a permanent safety net.
Deep Dive
Fuzz test functions live in _test.go files and start with Fuzz followed by a capitalized name. They accept a single *testing.F parameter.
The basic structure has two parts: seeding the corpus and defining the fuzz target.
gofunc FuzzParse(f *testing.F) { // Add seed corpus f.Add("hello") f.Add("world") f.Add("") f.Fuzz(func(t *testing.T, input string) { result, err := Parse(input) if err != nil { return // Expected for invalid input } // Round-trip check encoded := Encode(result) if encoded != input { t.Errorf("round-trip failed: %q -> %v -> %q", input, result, encoded) } }) }
The f.Add() calls provide seed values that the fuzzer mutates. Each f.Add() argument must match the parameter types in f.Fuzz(). The fuzz target function receives a *testing.T (not *testing.F) so you use standard test assertions inside it.
Notice the return on error: invalid input is expected during fuzzing. Your fuzz target should only fail on logic violations (broken invariants), not on gracefully handled parse errors.
Running Fuzz Tests
Fuzzing is not enabled by default. You must pass the -fuzz flag.
bashgo test -fuzz=FuzzParse # Run fuzzer indefinitely go test -fuzz=FuzzParse -fuzztime=30s # Run for 30 seconds go test -run=FuzzParse # Run seeds only (no fuzzing)
The -fuzz flag starts the fuzzer, which runs continuously until it finds a failure or you stop it. The -fuzztime flag limits the duration. Running with -run=FuzzParse (no -fuzz) executes the seed corpus as a regular test, which is what CI should do.
The Corpus
When the fuzzer discovers an input that causes a failure, it saves the input to testdata/fuzz/FuzzParse/ in your project. Every subsequent go test run replays these saved inputs, ensuring the bug never regresses. You should commit the testdata/fuzz/ directory to version control.
Supported Parameter Types
The fuzz target can accept multiple parameters. Supported types include string, []byte, int, int8 through int64, uint through uint64, float32, float64, rune, and bool.
gofunc FuzzMultiParam(f *testing.F) { f.Add("test", 42, true) f.Fuzz(func(t *testing.T, s string, n int, flag bool) { // Fuzzer varies all three parameters independently }) }
Each call to f.Add() must provide values matching the parameter list exactly.
Writing Effective Invariants
The best fuzz targets check properties that must hold for all inputs. Common patterns include round-trip consistency (parse then serialize), idempotency (applying an operation twice gives the same result), and no-panic guarantees.
gofunc FuzzReverse(f *testing.F) { f.Add("hello") f.Add("world") f.Fuzz(func(t *testing.T, s string) { rev := Reverse(s) doubleRev := Reverse(rev) if s != doubleRev { t.Errorf("Reverse(Reverse(%q)) = %q", s, doubleRev) } }) }
This test verifies that reversing a string twice returns the original. The fuzzer will find edge cases like multi-byte UTF-8 characters, empty strings, and strings with combining marks.
As of Go 1.26, fuzz tests can leverage t.ArtifactDir() inside the fuzz target to write debug output when investigating complex failures. The testing/synctest package (stable since Go 1.25) can be used alongside fuzz tests when your fuzz target exercises concurrent code, allowing deterministic control over goroutine scheduling even under random inputs.
Common Pitfalls
- Treating parse errors as test failures -- During fuzzing, most random inputs are invalid. If your fuzz target calls
t.Fatalon every parse error, the fuzzer cannot explore interesting mutations. Return early on expected errors and only fail on invariant violations. - Not committing the testdata/fuzz directory -- Failing inputs saved by the fuzzer are your regression tests. If you do not commit them, CI never replays them and the same bug can return.
- Running fuzzing in CI without a time limit -- Fuzzing runs indefinitely by default. Always use
-fuzztimein CI pipelines (e.g.,-fuzztime=60s) to prevent jobs from hanging.
Best Practices
- Provide diverse seed corpus values -- Include empty strings, Unicode characters, maximum-length inputs, and known edge cases. The fuzzer mutates seeds, so diverse starting points lead to better coverage.
- Check invariants, not specific outputs -- Fuzz targets should verify properties like round-trip consistency, no-panic guarantees, and output bounds rather than comparing against a fixed expected value.
- Run fuzzing locally before merging -- Spend a few minutes fuzzing locally with
go test -fuzz=. -fuzztime=2mto catch low-hanging bugs before they enter the main branch.
Summary
- Fuzz tests use
func FuzzXxx(f *testing.F)withf.Add()for seeds andf.Fuzz()for the target. - The fuzzer generates random inputs to find crashes and invariant violations you did not anticipate.
- Failing inputs are saved to
testdata/fuzz/as permanent regression tests. - Use
-fuzzto start fuzzing and-fuzztimeto limit duration; use-runwithout-fuzzin CI. - Go 1.26 adds
t.ArtifactDir()for debug output, andtesting/synctest(stable since Go 1.25) helps fuzz concurrent code deterministically.
Code Examples
func FuzzReverse(f *testing.F) {
f.Add("hello")
f.Add("世界")
f.Fuzz(func(t *testing.T, s string) {
rev := Reverse(s)
doubleRev := Reverse(rev)
if s != doubleRev {
t.Errorf("Reverse(Reverse(%q)) = %q", s, doubleRev)
}
})
}