The bug you didn't know existed
You built a parser. It handles the happy path. It handles the empty string. You wrote tests for a dozen edge cases. Then a user sends a file that crashes your server. The input looks like garbage, but it triggers a stack overflow or an infinite loop. Unit tests can't cover the infinite space of possible inputs. You need a way to throw random mutations at your code and see what breaks.
Fuzzing fills that gap. It is automated testing where the computer generates inputs, feeds them to your function, and watches for crashes or assertion failures. The fuzzer keeps a corpus of interesting inputs and mutates them to explore new code paths. It learns from your code structure. It prioritizes inputs that trigger branches you haven't hit yet. Fuzzing is like a robot that throws every possible combination of buttons at your code until something explodes.
How fuzzing works in Go
Go includes a built-in fuzzer starting in version 1.18. You write a fuzz test just like a unit test, but with a different signature and a mutation engine behind the scenes.
The function name must start with Fuzz. The argument must be *testing.F. This is how the test runner identifies fuzz targets. The convention is strict. If you name it TestFuzz, the runner ignores it. The *testing.F handle lets you add seed inputs and define the fuzz target.
Seeds are crucial. The fuzzer doesn't start from pure randomness. It starts from your seeds and mutates them. If you seed a JSON parser with valid JSON, the fuzzer generates variations that look like JSON. It changes numbers, adds keys, breaks syntax. This keeps the fuzzer focused on realistic inputs rather than wasting time on noise.
Minimal fuzz test
Here's the skeleton of a fuzz test. It lives in a _test.go file alongside your unit tests.
func FuzzReverse(f *testing.F) {
// Seed the fuzzer with known good inputs so it starts from a valid state
f.Add("hello")
f.Add("")
f.Fuzz(func(t *testing.T, input string) {
// Reverse the string
result := Reverse(input)
// Reverse twice should get the original back
if Reverse(result) != input {
t.Fatalf("double reverse failed: %q -> %q -> %q", input, result, Reverse(result))
}
})
}
The f.Add calls populate the initial corpus. The f.Fuzz call defines the target. The function inside f.Fuzz receives a *testing.T and the fuzzed input. The input type must match the type of the seeds. If you add a string seed, the fuzz target must accept a string.
When you run go test -fuzz=FuzzReverse, the compiler instruments the code. It inserts probes at every branch point. The fuzzer uses these probes to measure coverage. If a mutation hits a new branch, the fuzzer saves the input. The corpus grows over time. The fuzzer mutates the corpus, creating a feedback loop that drives exploration.
Fuzzing is not random guessing. It is directed mutation guided by code coverage.
Realistic example with error handling
Real code returns errors. A fuzz test should check that errors are returned for bad input and valid results for good input. It should also ensure the function never panics. Panics are bugs. Fuzzing finds them.
Here's a fuzz test for a config parser. It checks that invalid input returns an error and valid input returns a non-nil result.
func FuzzParseConfig(f *testing.F) {
// Add a valid config to seed the corpus with realistic structure
f.Add("key=value\nother=123")
f.Fuzz(func(t *testing.T, data string) {
// Parse the config; expect either a map or an error, never a panic
cfg, err := ParseConfig(data)
if err != nil {
// If parsing fails, the result should be nil or empty
if cfg != nil {
t.Fatalf("expected nil config on error, got %v", cfg)
}
return
}
// If no error, config must not be nil
if cfg == nil {
t.Fatal("expected non-nil config when error is nil")
}
})
}
The if err != nil pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Fuzzing reinforces this discipline. If you skip the error check, the fuzzer might find an input that returns an error but leaves the result in a broken state. The test catches it.
Convention aside: fuzz targets should be stateless. Don't rely on global variables. The fuzzer runs inputs in parallel. If one input modifies global state, it corrupts other runs. Keep your fuzz functions pure.
Running and controlling the fuzzer
You run fuzz tests with go test -fuzz. The flag takes the name of the fuzz function.
# Run fuzzing for 30 seconds and save the corpus
go test -fuzz=FuzzParseConfig -fuzztime=30s ./pkg
The -fuzztime flag limits the run. Without it, the fuzzer runs forever. This is useful for continuous fuzzing in development. In CI, you want a time limit. The fuzzer saves the corpus in testdata/fuzz/. You can commit this directory to version control. It ensures the fuzzer starts from a rich set of inputs on every run.
If the fuzzer finds a bug, it prints the input that caused the failure. It also saves the input to the corpus. You can replay the input with a unit test. This turns a fuzzing find into a permanent regression test.
The fuzzer also reports coverage. It shows which branches are hit by the corpus. This helps you understand if your seeds are good enough. If the coverage is low, add more seeds or refine the mutation strategy.
Fuzzing is a stress test that never sleeps. It finds the cracks you didn't know were there.
Pitfalls and compiler errors
Fuzz tests have a few gotchas. The compiler catches some mistakes early. Others show up at runtime.
If you add a seed that doesn't match the fuzz target type, the compiler stops you. The compiler rejects this with f.Add: argument type string does not match fuzz target type []byte. Make sure the types align.
If you forget to use the fuzz target input, the fuzzer warns you. It prints fuzz target does not use input. This means the mutation isn't affecting the test. The fuzzer can't learn from the run.
If your code enters an infinite loop, the fuzzer kills the test after a timeout. This is a feature. It catches denial-of-service bugs. The fuzzer reports the input that caused the loop. You can fix the bug and add the input to the corpus.
Goroutine leaks can also trip up fuzzing. If your fuzz target spawns a goroutine that never exits, the test hangs. The fuzzer has a timeout, but it's better to avoid leaks. Use context.Context with a deadline or cancellation channel. Pass the context as the first parameter. Functions that take a context should respect cancellation.
The worst fuzzing bug is the one that never logs. If your code silently fails, the fuzzer won't catch it. Add assertions. Check invariants. Verify that the output makes sense. Fuzzing amplifies your checks. It runs them on thousands of inputs.
Fuzzing finds the bugs you didn't know existed. It doesn't prove the code is correct; it proves the code is robust against chaos.
When to use fuzzing
Fuzzing is a powerful tool, but it's not a replacement for other testing strategies. Use it where it adds value.
Use unit tests when you need deterministic verification of specific behaviors and expected outputs for known inputs.
Use fuzz tests when you handle untrusted input, parse complex formats, or want to find edge cases that manual testing misses.
Use integration tests when you need to verify interactions between components like databases, message queues, or external APIs.
Use property-based testing when you want to verify invariants across a range of inputs without the overhead of a full fuzzing engine.
Use benchmark tests when you need to measure performance and detect regressions in execution time or memory usage.
Fuzzing shines where input space is large and structure matters. It's ideal for parsers, decoders, validators, and crypto functions. It's less useful for business logic that depends on specific domain rules. Combine fuzzing with unit tests for maximum coverage.
Fuzzing is the safety net for the unknown. Throw garbage at your code and trust the fuzzer to find the weak spots.