Skip to main content

Fuzz Testing in Go — Finding Bugs Your Unit Tests Miss

· 8 min read
Hieu Nguyen
Senior Software Engineer at OCB

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 TutorialGo 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:

TypeExample Seeds
stringf.Add("hello")
[]bytef.Add([]byte{0x01, 0x02})
int, int8...int64f.Add(42)
uint, uint8...uint64f.Add(uint(10))
float32, float64f.Add(3.14)
boolf.Add(true)
runef.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" pass ParseFloat successfully — 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:

PropertyExample
Round-tripDecode(Encode(x)) == x
IdempotencyParse(Format(x)) == Parse(Format(Format(x)))
No panicFunction handles any input without crashing
InvariantsOutput length equals input length
BoundsResult 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

TypeInputsBest For
Unit testsYou defineKnown behaviors and edge cases
Table-driven testsYou define (many)Systematic coverage of known cases
Fuzz testsGo generatesUnknown edge cases, crash discovery
Benchmark testsYou definePerformance measurement
Integration testsYou defineEnd-to-end flows

Tip: Use fuzz testing alongside unit tests, not instead of them. Unit tests verify behavior; fuzz tests discover surprises.

Key Takeaways

  1. Fuzz testing finds bugs you didn't think of — Unicode edge cases, overflow, NaN, malformed input.
  2. Built into Go since 1.18 — just name your function FuzzXxx and use f.Fuzz().
  3. Test properties, not exact values — round-trip consistency, no panics, invariant preservation.
  4. Commit failing inputstestdata/fuzz/ files become permanent regression tests.
  5. Set time limits in CI — use -fuzztime=10s to prevent infinite runs.
  6. 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. 🚀