Day 34 — Fuzzing
Day 34 — Fuzzing
Stage IV · ~3h (theory-heavy)
Goal: Use Go’s built-in fuzzing (go test -fuzz) to stress parsers and codecs, seed corpora wisely, and turn crashers into regression tests.
Fuzzing finds inputs you didn’t think of. It complements—not replaces—table-driven tests (Day 16).
Why this day exists
Table tests encode human imagination. Fuzzers encode mutation search:
- Parsers (
strconv, custom config, URL path rules)
- Decoders (JSON subsets, binary headers)
- Any function
[]byte → Torstring → Twith invariants
If you ship parsers without fuzzing, production becomes your fuzzer—with worse UX.
Theory 1 — Fuzz targets in Go
func FuzzParse(f *testing.F) {
// seed corpus
f.Add("42")
f.Add("-1")
f.Add("")
f.Fuzz(func(t *testing.T, in string) {
_, _ = Parse(in) // must not panic
})
}| Piece | Role |
|---|---|
FuzzXxx |
Entry; name starts with Fuzz |
f.Add(...) |
Seed interesting inputs |
f.Fuzz(fn) |
fn args after *testing.T are fuzzed params |
| Supported param types | e.g. []byte, string, integrals, bool, … |
Run:
go test -fuzz=FuzzParse -fuzztime=30sWithout -fuzz, Fuzz tests still execute seed corpus like tests (good for CI smoke).
Theory 2 — What to assert
Fuzz targets should check invariants, not full equality to an oracle you don’t have.
Good invariants:
- No panic
Parseerror ⇒ do not use value
Parsesuccess ⇒Format(Parse(x))round-trip (when defined)
- Output length bounds
errors.Iscategories
f.Fuzz(func(t *testing.T, in string) {
v, err := Parse(in)
if err != nil {
return
}
out, err := Format(v)
if err != nil {
t.Fatalf("format: %v", err)
}
v2, err := Parse(out)
if err != nil || v2 != v {
t.Fatalf("roundtrip %q → %q → %v", in, out, v2)
}
})Theory 3 — Corpus management
On failure, Go writes a file under:
testdata/fuzz/FuzzParse/<hash>
Commit these files—they become regression seeds.
go test # runs seeds
go test -fuzz=FuzzParse -fuzztime=1x # limitedNever discard crashers without minimizing understanding.
Theory 4 — Structured fuzzing with multiple args
f.Add(uint8(1), []byte("ab"))
f.Fuzz(func(t *testing.T, n uint8, b []byte) {
// ...
})Engine mutates each argument. Keep domains meaningful—or clamp inside:
if len(b) > 1<<20 {
t.Skip()
}Prefer fixing parser bounds over huge skips.
Theory 5 — Fuzzing vs race detector
You can combine:
go test -race -fuzz=FuzzFoo -fuzztime=20sSlower but valuable for concurrent parsers. Day 36 deepens concurrent testing; today keep most fuzz targets single-goroutine pure functions.
Theory 6 — When fuzzing won’t help
- UI layout
- Business workflows needing external systems
- Functions without interesting input domains
- Non-deterministic time/random without injection
Fuzz pure, deterministic cores first.
Worked example — integer list parser
package parse
import (
"fmt"
"strconv"
"strings"
)
// ParseList parses "1,2,3" into ints; empty string → empty slice.
func ParseList(s string) ([]int, error) {
if s == "" {
return nil, nil
}
parts := strings.Split(s, ",")
out := make([]int, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
return nil, fmt.Errorf("empty element")
}
n, err := strconv.Atoi(p)
if err != nil {
return nil, err
}
out = append(out, n)
}
return out, nil
}
func FormatList(xs []int) string {
parts := make([]string, len(xs))
for i, n := range xs {
parts[i] = strconv.Itoa(n)
}
return strings.Join(parts, ",")
}Fuzz:
func FuzzParseList(f *testing.F) {
f.Add("")
f.Add("1")
f.Add("1,2,3")
f.Add(" 1 , 2 ")
f.Fuzz(func(t *testing.T, s string) {
xs, err := ParseList(s)
if err != nil {
return
}
s2 := FormatList(xs)
ys, err := ParseList(s2)
if err != nil {
t.Fatal(err)
}
if len(xs) != len(ys) {
t.Fatalf("%v vs %v", xs, ys)
}
for i := range xs {
if xs[i] != ys[i] {
t.Fatalf("%v vs %v", xs, ys)
}
}
})
}Note: round-trip may fail if you allow spaces in input but format without spaces—that’s a spec decision. Fix Format/Parse or loosen invariant.
Lab 1 — Seed and run
Workspace: ~/lab/90daysofx/01-go/day34
mkdir -p ~/lab/90daysofx/01-go/day34 && cd ~/lab/90daysofx/01-go/day34
go mod init example.com/day34Implement a small parser (CSV ints, key=value config, or subset JSON). Add Fuzz with ≥4 seeds.
go test
go test -fuzz=FuzzParse -fuzztime=30sLab 2 — Plant a bug, catch it
Introduce a panic on a rare input (e.g. single "%" character). Run fuzz until it fails. Commit the crasher corpus file. Fix the bug; ensure seeds still pass.
Lab 3 — Invariant design
Write three invariants for your parser. Rank them by strength. Implement the strongest that is true.
Lab 4 — CI-friendly fuzz smoke
Document in README:
# CI
go test ./...
# Nightly / local deep
go test -fuzz=FuzzParse -fuzztime=5mOptional: -fuzzminimizetime awareness when minimizing crashers.
Common gotchas
| Gotcha | Fix |
|---|---|
| Non-deterministic fuzz body | Inject clocks/rng |
| Huge allocations hanging host | Bound input size |
| Asserting exact error strings | Assert classes/invariants |
| Not committing testdata/fuzz | Commit crashers |
| Fuzzing through network | Fuzz pure decode only |
Checkpoint
- Wrote a
Fuzztarget with seeds
- Ran
-fuzzfor ≥30s
- Fixed at least one issue (planted or real)
- Corpus under
testdata/fuzzunderstood
- Round-trip or no-panic invariant justified
go testpasses seeds in non-fuzz mode
Commit
git add .
git commit -m "day34: fuzzing — parser Fuzz target + corpus"Write three personal gotchas before continuing.
Tomorrow
Day 35 — Golden files & t.Cleanup: snapshot testing for encodings and deterministic artifacts with safe cleanup.