Go Fuzz Testing Tutorial
Write fuzz tests in Go to discover edge cases and bugs automatically. Learn the fuzzing API, corpus management, and fixing found crashes.
What you'll learn
- ✓How Go's built-in fuzzing engine works
- ✓Writing fuzz functions with seed corpus entries
- ✓Running fuzz tests and interpreting results
- ✓Fixing bugs found by the fuzzer and preventing regressions
Prerequisites
None — this post is self-contained.
Unit tests verify behavior for inputs you think of. Fuzz tests verify behavior for inputs you did not think of. Since Go 1.18, the standard testing package includes a fuzzing engine that generates random inputs, feeds them to your code, and reports any that cause panics, crashes, or assertion failures. This catches edge cases that hand-written tests miss.
Your First Fuzz Test
A fuzz test is a function that starts with Fuzz and takes *testing.F as its parameter. You add seed corpus entries with f.Add() and define the fuzz target with f.Fuzz():
// reverse.go
package str
func Reverse(s string) string {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}
// reverse_test.go
package str
import (
"testing"
"unicode/utf8"
)
func FuzzReverse(f *testing.F) {
// Seed corpus: known inputs that exercise basic behavior
f.Add("hello")
f.Add("world")
f.Add("")
f.Add("a")
f.Add("ab")
f.Fuzz(func(t *testing.T, input string) {
reversed := Reverse(input)
// Property 1: reversing twice returns the original
doubleReversed := Reverse(reversed)
if doubleReversed != input {
t.Errorf("double reverse mismatch: %q -> %q -> %q",
input, reversed, doubleReversed)
}
// Property 2: length is preserved
if len(reversed) != len(input) {
t.Errorf("length changed: input %d, reversed %d",
len(input), len(reversed))
}
// Property 3: reversed string is valid UTF-8 if input is
if utf8.ValidString(input) && !utf8.ValidString(reversed) {
t.Errorf("reversed string is invalid UTF-8: %q", reversed)
}
})
}
Running the Fuzzer
Run fuzz tests with the -fuzz flag:
go test -fuzz=FuzzReverse
The fuzzer runs indefinitely, generating random strings and checking your properties. To limit the duration:
go test -fuzz=FuzzReverse -fuzztime=30s
Run seed corpus entries as regular tests (without fuzzing):
go test -run=FuzzReverse
This executes the f.Add() entries through the fuzz target, which is useful in CI where you do not want open-ended fuzzing.
Understanding the Output
While fuzzing, you see output like:
fuzz: elapsed: 3s, execs: 28394 (9464/sec), new interesting: 12 (total: 17)
fuzz: elapsed: 6s, execs: 59211 (10283/sec), new interesting: 14 (total: 19)
- execs: total inputs tested
- new interesting: inputs that increased code coverage and were added to the corpus
When the fuzzer finds a failing input:
--- FAIL: FuzzReverse (0.85s)
--- FAIL: FuzzReverse (0.00s)
reverse_test.go:27: reversed string is invalid UTF-8: "\xc0\xb1"
Failing input written to testdata/fuzz/FuzzReverse/a1b2c3d4
The Corpus Directory
Failing inputs are saved to testdata/fuzz/<FuzzFuncName>/. Each file contains the input that triggered the failure:
go test fance input #1
string("café\xfe")
These files are automatically included in future test runs. Once you fix the bug, the previously-failing input becomes a regression test. Commit the testdata/fuzz/ directory to version control to preserve these discoveries.
Supported Parameter Types
The fuzz target function can accept these types as parameters:
string,[]byteint,int8,int16,int32,int64uint,uint8,uint16,uint32,uint64float32,float64bool
Each f.Add() call must provide values matching the fuzz target’s parameter list:
func FuzzParseAge(f *testing.F) {
f.Add("25")
f.Add("-1")
f.Add("0")
f.Add("999")
f.Add("not a number")
f.Fuzz(func(t *testing.T, input string) {
age, err := ParseAge(input)
if err != nil {
return // invalid input is acceptable
}
if age < 0 || age > 150 {
t.Errorf("ParseAge(%q) = %d, out of valid range", input, age)
}
})
}
Fuzzing a JSON Parser
Fuzzing is particularly effective for parsers and deserializers:
type Config struct {
Name string `json:"name"`
Port int `json:"port"`
Debug bool `json:"debug"`
}
func ParseConfig(data []byte) (*Config, error) {
var c Config
if err := json.Unmarshal(data, &c); err != nil {
return nil, err
}
if c.Name == "" {
return nil, fmt.Errorf("name is required")
}
if c.Port < 1 || c.Port > 65535 {
return nil, fmt.Errorf("port must be 1-65535, got %d", c.Port)
}
return &c, nil
}
func FuzzParseConfig(f *testing.F) {
f.Add([]byte(`{"name":"app","port":8080,"debug":false}`))
f.Add([]byte(`{}`))
f.Add([]byte(`invalid json`))
f.Add([]byte(`{"name":"","port":0}`))
f.Fuzz(func(t *testing.T, data []byte) {
config, err := ParseConfig(data)
if err != nil {
return // parser correctly rejected input
}
// If parsing succeeded, validate invariants
if config.Name == "" {
t.Error("parsed config has empty name")
}
if config.Port < 1 || config.Port > 65535 {
t.Errorf("parsed config has invalid port: %d", config.Port)
}
// Round-trip: marshal and parse again
reencoded, err := json.Marshal(config)
if err != nil {
t.Fatalf("failed to re-marshal: %v", err)
}
config2, err := ParseConfig(reencoded)
if err != nil {
t.Fatalf("failed to re-parse: %v", err)
}
if config.Name != config2.Name || config.Port != config2.Port {
t.Error("round-trip mismatch")
}
})
}
Fuzzing With Multiple Parameters
Test functions that take multiple inputs by adding multiple parameters:
func FuzzContains(f *testing.F) {
f.Add("hello world", "world")
f.Add("foo", "bar")
f.Add("", "")
f.Add("abc", "")
f.Fuzz(func(t *testing.T, haystack, needle string) {
result := strings.Contains(haystack, needle)
// Property: if needle is empty, result must be true
if needle == "" && !result {
t.Error("Contains should return true for empty needle")
}
// Property: if result is true, haystack length >= needle length
if result && len(haystack) < len(needle) {
t.Error("Contains true but haystack shorter than needle")
}
})
}
Writing Good Fuzz Properties
The fuzz target must check properties, not specific expected outputs. Good properties include:
- Round-trip: encode then decode, or transform then reverse, and verify you get the original.
- Invariants: output length, valid UTF-8, values within expected ranges.
- No panics: the function should not crash on any input, even garbage.
- Consistency: calling the same function twice with the same input should produce the same result.
- Comparison: if you have two implementations, they should agree on all inputs.
Integrating Fuzzing Into CI
Continuous fuzzing runs indefinitely, but CI needs bounded execution. Run seed corpus entries as regular tests and periodically fuzz for a fixed duration:
jobs:
test:
steps:
- run: go test ./... # Runs seed corpus entries
fuzz:
steps:
- run: go test -fuzz=. -fuzztime=2m ./...
Commit any new corpus entries found by local fuzzing sessions to keep the seed corpus growing over time.
Wrap Up
Go’s built-in fuzzing engine generates random inputs and checks them against properties you define. It discovers edge cases in parsers, validators, and transformations that hand-written tests miss. Failing inputs are saved as corpus entries that become permanent regression tests. Add fuzz tests for any function that processes untrusted input, and run them regularly to catch bugs before your users do.
Related articles
- Go Go Testing Package Tutorial
Write effective tests in Go with the standard testing package: table-driven tests, subtests, fixtures, parallelism, benchmarks, and fuzzing.
- Go Dependency Injection Patterns in Go
Learn practical dependency injection in Go using interfaces, constructor injection, and functional options without heavy frameworks.
- Go Table-Driven Tests and Test Helpers in Go
Write clear, maintainable Go tests using table-driven patterns, test helpers, subtests, and cleanup functions.
- Go Testing Strategies in Go
Write effective Go tests — table-driven tests, subtests, test helpers, mocking with interfaces, benchmarks, and integration testing patterns.