Day 34 — Fuzzing

Updated

July 30, 2026

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.

Note

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 → T or string → T with 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=30s

Without -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
  • Parse error ⇒ do not use value
  • Parse success ⇒ Format(Parse(x)) round-trip (when defined)
  • Output length bounds
  • errors.Is categories
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  # limited

Never 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=20s

Slower 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/day34

Implement a small parser (CSV ints, key=value config, or subset JSON). Add Fuzz with ≥4 seeds.

go test
go test -fuzz=FuzzParse -fuzztime=30s

Lab 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=5m

Optional: -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 Fuzz target with seeds
  • Ran -fuzz for ≥30s
  • Fixed at least one issue (planted or real)
  • Corpus under testdata/fuzz understood
  • Round-trip or no-panic invariant justified
  • go test passes 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.