Fuzz Testing in Go — Finding Bugs Your Unit Tests Miss
A practical guide to Go's built-in fuzz testing — how it works, when to use it, and real examples that catch edge cases your regular unit tests would never find.
Unit tests verify that your code works for the inputs you thought of. Fuzz testing verifies that your code doesn't break for inputs you didn't think of.
I first used fuzz testing when debugging a parser in a fintech payment gateway. Our unit tests passed with 100% coverage, but production kept crashing on malformed transaction strings from third-party integrations. A 5-minute fuzz test found the exact edge case — a UTF-8 multi-byte character that our strings.Split() couldn't handle.
Since Go 1.18, fuzzing is built into the standard testing package — no third-party tools needed. Let's see how it works.
📖 Go Fuzzing Tutorial • Go Fuzzing Reference
How Fuzz Testing Works
Traditional tests:
Input (you define) → Function → Expected Output (you assert)
Fuzz tests:
Random Input (Go generates) → Function → Crash? Panic? Unexpected behavior?
The Go fuzzer mutates seed inputs to generate thousands of random variations, trying to trigger panics, crashes, or unexpected behavior. When it finds a failing input, it saves it as a regression test so the bug never comes back.
┌───────────-──┐ Seed Corpus ┌──────────────┐
│ You provide │ ─────────────────▶ │ Go Fuzzer │
│ seed inputs │ │ (mutates & │
└──────────-───┘ │ generates) │
└──────┬───────┘
│ thousands of inputs
▼
┌──────────-────┐
│ Your Function│
│ Under Test │
└──────┬──-─────┘
│
┌───────────┴───────────┐
▼ ▼
✅ No crash ❌ Panic/Bug found
(keep mutating) (save failing input)
Your First Fuzz Test
Let's start with a simple example — a function that reverses a string:
The Function
// reverse.go
package stringutil
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)
}
Regular Unit Test
// reverse_test.go
func TestReverse(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"hello", "olleh"},
{"abc", "cba"},
{"", ""},
{"a", "a"},
}
for _, tc := range tests {
got := Reverse(tc.input)
if got != tc.expected {
t.Errorf("Reverse(%q) = %q, want %q", tc.input, got, tc.expected)
}
}
}
This passes ✅ — but can you think of every possible input? What about emoji? Multi-byte Unicode? Null characters?
Fuzz Test
// reverse_test.go
func FuzzReverse(f *testing.F) {
// Seed corpus — starting inputs for the fuzzer to mutate
f.Add("hello")
f.Add("world")
f.Add("")
f.Add("a")
f.Add("🇻🇳")
f.Fuzz(func(t *testing.T, original string) {
reversed := Reverse(original)
doubleReversed := Reverse(reversed)
// Property 1: Reversing twice should give the original
if original != doubleReversed {
t.Errorf("Double reverse mismatch: %q → %q → %q",
original, reversed, doubleReversed)
}
// Property 2: Length should be preserved
if len(original) != len(reversed) {
t.Errorf("Length changed: original=%d, reversed=%d",
len(original), len(reversed))
}
})
}
Running Fuzz Tests
# Run fuzz test for 30 seconds
go test -fuzz=FuzzReverse -fuzztime=30s
# Run with no time limit (until you press Ctrl+C)
go test -fuzz=FuzzReverse
# Run only the regular tests (fuzz seeds as unit tests)
go test -run=FuzzReverse
Output when a bug is found:
--- FAIL: FuzzReverse (0.50s)
--- FAIL: FuzzReverse/abc123 (0.00s)
reverse_test.go:25: Double reverse mismatch: "İ" → "i̇" → "i̇"
Failing input written to testdata/fuzz/FuzzReverse/abc123
Key insight: The fuzzer found that certain Unicode characters change when reversed (e.g., Turkish İ). This is a real bug that manual tests would almost never catch.
Fuzz Test Structure
Every fuzz test follows this pattern:
func FuzzXxx(f *testing.F) {
// 1. Add seed corpus (starting inputs)
f.Add(seedValue1)
f.Add(seedValue2)
// 2. Define the fuzz target
f.Fuzz(func(t *testing.T, input Type) {
// 3. Call your function
result := YourFunction(input)
// 4. Assert properties (not exact values!)
// - No panics
// - Output satisfies invariants
// - Round-trip consistency
})
}
Supported Input Types
The fuzzer can generate these types automatically:
| Type | Example Seeds |
|---|---|
string | f.Add("hello") |
[]byte | f.Add([]byte{0x01, 0x02}) |
int, int8...int64 | f.Add(42) |
uint, uint8...uint64 | f.Add(uint(10)) |
float32, float64 | f.Add(3.14) |
bool | f.Add(true) |
rune | f.Add('A') |
You can combine multiple parameters:
func FuzzMultiParam(f *testing.F) {
f.Add("hello", 5, true)
f.Fuzz(func(t *testing.T, s string, n int, flag bool) {
// Fuzzer generates random combinations
})
}
Real-World Examples
1. JSON Parser — Round-Trip Consistency
Test that marshaling and unmarshaling produces the same result:
type User struct {
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age"`
}
func FuzzJSONRoundTrip(f *testing.F) {
f.Add("Noka", "noka@hoclamdev.com", 28)
f.Add("", "", 0)
f.Add("José García", "josé@example.com", -1)
f.Fuzz(func(t *testing.T, name, email string, age int) {
user := User{Name: name, Email: email, Age: age}
// Marshal → Unmarshal should produce the same struct
data, err := json.Marshal(user)
if err != nil {
t.Skip("invalid input for marshal")
}
var decoded User
err = json.Unmarshal(data, &decoded)
if err != nil {
t.Fatalf("Failed to unmarshal valid JSON: %v", err)
}
if user != decoded {
t.Errorf("Round-trip failed:\n original: %+v\n decoded: %+v", user, decoded)
}
})
}
2. URL Parser — No Panic Guarantee
Ensure your parser handles any input without panicking:
func FuzzParseURL(f *testing.F) {
f.Add("https://hoclamdev.com/blog")
f.Add("http://localhost:8080/api/v1")
f.Add("")
f.Add("not-a-url")
f.Add("://missing-scheme")
f.Add("https://[::1]:8080/path?q=1#frag")
f.Fuzz(func(t *testing.T, rawURL string) {
// This should NEVER panic, regardless of input
parsed, err := url.Parse(rawURL)
if err != nil {
return // Invalid URL is fine — just don't crash
}
// If it parsed successfully, scheme should be valid
if parsed.Scheme != "" && parsed.Host == "" && parsed.Path == "" {
// Potentially suspicious parse result
t.Logf("Suspicious parse: %q → scheme=%q host=%q path=%q",
rawURL, parsed.Scheme, parsed.Host, parsed.Path)
}
})
}
3. Amount Validation — Business Logic
Test financial amount validation in a payment system:
func ValidateAmount(amount string) (float64, error) {
val, err := strconv.ParseFloat(amount, 64)
if err != nil {
return 0, fmt.Errorf("invalid amount: %s", amount)
}
if val <= 0 {
return 0, fmt.Errorf("amount must be positive: %f", val)
}
if val > 1_000_000 {
return 0, fmt.Errorf("amount exceeds maximum: %f", val)
}
// Round to 2 decimal places
return math.Round(val*100) / 100, nil
}
func FuzzValidateAmount(f *testing.F) {
f.Add("100.50")
f.Add("0.01")
f.Add("999999.99")
f.Add("0")
f.Add("-50")
f.Add("abc")
f.Add("1e308") // Max float64
f.Add("NaN")
f.Add("Inf")
f.Fuzz(func(t *testing.T, input string) {
result, err := ValidateAmount(input)
if err == nil {
// If validation passes, result must be valid
if result <= 0 || result > 1_000_000 {
t.Errorf("ValidateAmount(%q) = %f, should have been rejected",
input, result)
}
if math.IsNaN(result) || math.IsInf(result, 0) {
t.Errorf("ValidateAmount(%q) returned NaN/Inf", input)
}
}
})
}
This fuzz test will likely find that
"NaN"and"Inf"passParseFloatsuccessfully — a real bug in many payment systems!
The Corpus Directory
When the fuzzer finds a failing input, it saves it to testdata/fuzz/<FuncName>/:
mypackage/
├── reverse.go
├── reverse_test.go
└── testdata/
└── fuzz/
└── FuzzReverse/
├── abc123 ← auto-generated failing input
└── custom_seed ← you can add manual seeds here
Each file contains the failing input:
go test fuzz v1
string("İ")
Commit these files to Git! They serve as regression tests — every go test run will re-test these inputs.
Best Practices
1. Test Properties, Not Exact Values
You can't assert exact output for random input. Instead, assert properties:
| Property | Example |
|---|---|
| Round-trip | Decode(Encode(x)) == x |
| Idempotency | Parse(Format(x)) == Parse(Format(Format(x))) |
| No panic | Function handles any input without crashing |
| Invariants | Output length equals input length |
| Bounds | Result is within expected range |
2. Use t.Skip() for Invalid Inputs
Not all generated inputs make sense. Skip gracefully:
f.Fuzz(func(t *testing.T, input string) {
if len(input) == 0 {
t.Skip("empty input not relevant")
}
// ... test logic
})
3. Set a Time Limit in CI
Don't let fuzz tests run forever in CI pipelines:
# In CI: run for 10 seconds per fuzz test
go test -fuzz=. -fuzztime=10s ./...
4. Keep Seeds Minimal but Diverse
Good seed corpus:
f.Add("") // empty
f.Add("a") // single char
f.Add("hello world") // normal
f.Add("🇻🇳🎉") // emoji
f.Add("\x00\xff") // binary
5. Commit the testdata/fuzz/ Directory
Failing inputs become permanent regression tests. Always commit them.
Fuzz Testing vs Other Testing
| Type | Inputs | Best For |
|---|---|---|
| Unit tests | You define | Known behaviors and edge cases |
| Table-driven tests | You define (many) | Systematic coverage of known cases |
| Fuzz tests | Go generates | Unknown edge cases, crash discovery |
| Benchmark tests | You define | Performance measurement |
| Integration tests | You define | End-to-end flows |
Tip: Use fuzz testing alongside unit tests, not instead of them. Unit tests verify behavior; fuzz tests discover surprises.
Key Takeaways
- Fuzz testing finds bugs you didn't think of — Unicode edge cases, overflow, NaN, malformed input.
- Built into Go since 1.18 — just name your function
FuzzXxxand usef.Fuzz(). - Test properties, not exact values — round-trip consistency, no panics, invariant preservation.
- Commit failing inputs —
testdata/fuzz/files become permanent regression tests. - Set time limits in CI — use
-fuzztime=10sto prevent infinite runs. - Great for parsers, validators, and serializers — any function that accepts untrusted input.
Thanks for reading! Fuzz testing is one of those tools that takes 5 minutes to set up but can save you from production incidents. Also check out my Clean Architecture post for structuring Go projects, and my SOLID Principles guide for design fundamentals. 🚀