rgoussu@goussu: ~/library/go/testing
~/library/go/testing cat property-based-and-fuzzing.md

Property-based testing & fuzzing — rapid and go test -fuzz

# Generated-input testing in Go — native fuzzing since 1.18 (f.Fuzz, corpus, crashers) and property-based testing with rapid, and when each fits.

Conceptsaved 2026-08-09 #go#testing#fuzzing#property-based#security

Overview

Example-based tests only check the inputs someone thought of. Two related techniques generate inputs instead: fuzzing throws coverage-guided semi-random data at code to find crashes and panics, while property-based testing (PBT) checks stated invariants over structured random inputs and shrinks failures to minimal counterexamples. Go ships one of them natively — fuzzing has been in the toolchain since 1.18 — and the community's best PBT library is pgregory.net/rapid (flyingmutant/rapid), with gopter as the older, heavier option.

Key points

  • Native fuzzing: func FuzzXxx(f *testing.F), seed with f.Add(...), then f.Fuzz(func(t *testing.T, data []byte, n int) { ... }). Runs as a normal seed-driven test under go test; actual fuzzing needs go test -fuzz=FuzzXxx.
  • Coverage-guided: the engine mutates inputs and keeps those that reach new coverage — the same idea as libFuzzer/AFL, integrated with the build cache.
  • Corpus management: interesting inputs accumulate in the build cache ($GOCACHE/fuzz); crashing inputs are written to testdata/fuzz/FuzzXxx/ and — this is the payoff — run as regression seeds in every plain go test forever after. Commit them.
  • Fuzz-arg types are limited to primitives, string and []byte; decode richer structures from []byte inside the fuzz target.
  • OSS-Fuzz integration: Google's OSS-Fuzz runs native Go fuzz targets continuously for accepted open-source projects — free compute for parser-heavy libraries.
  • rapid for PBT: rapid.Check(t, func(t *rapid.T) { ... }) with combinator generators (rapid.Int(), rapid.SliceOf, rapid.Custom, .Filter, .Map) drawn via Draw(t, "name"); failures shrink automatically to a minimal case, no reflection circus. gopter predates it — more machinery, less ergonomic.
  • State-machine testing: rapid's t.Repeat drives random sequences of operations against your implementation and a simple model — the technique that finds lifecycle bugs (put/get/delete interleavings) no table test will.
  • Complementary, not competing: keep the table test for known cases, add a property for the invariant, add a fuzz target at trust boundaries — all three run under go test.

Details

When each fits

Technique Sweet spot Typical properties
Fuzzing Parsers, decoders, protocol handlers, anything consuming untrusted bytes "never panics", "never reads OOB", differential vs reference impl
PBT (rapid) Business invariants, data structures, codecs round-trip (decode(encode(x)) == x), idempotence, commutativity, model conformance

Fuzzing optimises for reaching weird states (coverage feedback, byte-level mutation); PBT optimises for expressing invariants over well-typed inputs and explaining failures (shrinking). A round-trip check is often worth writing both ways.

Working habits

  • Seed fuzz corpora from real-world samples and past bug reports — the mutator starts far ahead.
  • Keep fuzz targets deterministic and side-effect-free; sub-second per input, or the engine starves.
  • In CI, run go test (seeds + committed crashers) always; schedule bounded fuzzing (-fuzz -fuzztime=2m per target) nightly rather than per-PR.
  • Name Draw calls in rapid — they label the shrunken counterexample output.

Examples

func FuzzParseAddr(f *testing.F) {
    f.Add("127.0.0.1:8080")
    f.Add("[::1]:80")
    f.Fuzz(func(t *testing.T, s string) {
        addr, err := ParseAddr(s)
        if err != nil {
            return // invalid input is fine; panics are not
        }
        // Round-trip property doubles as the oracle.
        if got, err := ParseAddr(addr.String()); err != nil || got != addr {
            t.Fatalf("round-trip failed: %q -> %v -> %v (%v)", s, addr, got, err)
        }
    })
}

func TestQueueModel(t *testing.T) {
    rapid.Check(t, func(t *rapid.T) {
        q := NewQueue[int]()
        var model []int
        t.Repeat(map[string]func(*rapid.T){
            "push": func(t *rapid.T) {
                v := rapid.Int().Draw(t, "v")
                q.Push(v)
                model = append(model, v)
            },
            "pop": func(t *rapid.T) {
                if len(model) == 0 {
                    return
                }
                if got := q.Pop(); got != model[0] {
                    t.Fatalf("pop = %d, want %d", got, model[0])
                }
                model = model[1:]
            },
        })
    })
}

Related