# Go Testing Advanced

> Guides Go's advanced testing techniques — native fuzzing (FuzzXxx, f.Add seed corpus, f.Fuzz property body, the testdata/fuzz corpus, go test -fuzz); benchmarks with the Go 1.24 `for b.Loop()` loop that replaces the error-prone `for i := 0; i less than b.N; i++`, plus b.ReportAllocs, b.ResetTimer, b.RunParallel, and keeping the result alive so the optimizer can't delete the body; testing/synctest (Go 1.25) for deterministic, instant time-based concurrency tests instead of flaky time.Sleep; go test -cover as a guide not a target; and the stdlib-over-testify stance. Auto-invokes when writing or editing benchmarks, fuzz tests, FuzzXxx, b.Loop/b.N, testing/synctest, coverage, or deciding stdlib testing vs testify, or on "benchmark this" / "fuzz this" / "why is this concurrency test flaky" requests. Routes table tests, subtests, helpers, and golden files to go-testing-tabledriven.

- Skill: `ctoth/go-testing-advanced` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-testing-advanced`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-testing-advanced/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-testing-advanced

---


# Go Testing — Advanced

> "Benchmarks should either use Loop or contain a loop to b.N, but not both. Loop offers more automatic management of the benchmark timer, and runs each benchmark function only once per measurement."
> — [pkg.go.dev/testing](https://pkg.go.dev/testing#B.Loop)

> "go test and the testing package support fuzzing, a testing technique where a function is called with randomly generated inputs to find bugs not anticipated by unit tests."
> — [pkg.go.dev/testing](https://pkg.go.dev/testing)

This skill owns the testing techniques that go *past* a table of cases: **fuzzing** (generate inputs that break a property), **benchmarks** (measure, with the modern `b.Loop`), **`testing/synctest`** (make time-based concurrency deterministic), **coverage**, and the **stdlib-vs-testify** stance. Test *structure* — tables, `t.Run` subtests, `t.Helper`, `t.Cleanup`, `t.Parallel`, golden files, `cmp.Diff` — belongs to **`go-testing-tabledriven`**; read it first for the floor, then this for the techniques on top.

All Go below builds clean under `gofmt`, `go vet`, and `go test` (including `-race`) on Go 1.26.

| Reach for | When | Version floor |
|---|---|---|
| **Fuzzing** (`FuzzXxx`) | A function takes untrusted `string`/`[]byte`/numbers and you can state a property (round-trip, no-panic, agrees-with-reference) | Go 1.18 |
| **Benchmark** (`b.Loop`) | You need to measure time or allocations, or compare two implementations | `b.Loop` Go 1.24 |
| **`testing/synctest`** | A test waits on time (timeouts, tickers, retries) or goroutine ordering and is slow or flaky | Go 1.25 (stable) |
| **Coverage** (`-cover`) | You want to *find* untested branches — as a tool, not a target | any |

---

## 1. Fuzzing — Generate the Input You Didn't Think Of

A fuzz test feeds randomly generated, coverage-guided inputs into a function to find ones that crash or violate a property. "A fuzz test must be a function named like `FuzzXxx`, which accepts only a `*testing.F`, and has no return value" ([go.dev/security/fuzz](https://go.dev/security/fuzz/)). Inside it you seed the corpus with `f.Add` and then call `f.Fuzz` exactly once with the **fuzz target** — "a method call to `(*testing.F).Fuzz` which accepts a `*testing.T` as the first parameter, followed by the fuzzing arguments" ([go.dev/security/fuzz](https://go.dev/security/fuzz/)).

```go
func FuzzReverse(f *testing.F) {
	for _, seed := range []string{"", "a", "abc", "Hello, 世界"} {
		f.Add(seed) // seed corpus: types must match the fuzz arguments exactly
	}
	f.Fuzz(func(t *testing.T, in string) {
		if !utf8.ValidString(in) {
			return // Reverse is rune-based; only meaningful for valid UTF-8
		}
		rev := Reverse(in)
		if got := Reverse(rev); got != in {        // property: round-trip
			t.Errorf("Reverse(Reverse(%q)) = %q, want %q", in, got, in)
		}
		if !utf8.ValidString(rev) {                 // property: stays valid
			t.Errorf("Reverse(%q) produced invalid UTF-8: %q", in, rev)
		}
	})
}
```

Two non-negotiables. **Seed it:** `f.Add` entries (and files in `testdata/fuzz/{FuzzName}`) "must have types which are identical to the fuzzing arguments, in the same order" ([go.dev/security/fuzz](https://go.dev/security/fuzz/)) — seeds give the engine a foothold and double as regression cases. **Assert a property:** a fuzz target with no `t.Errorf` only catches outright panics; the value is in checking an *invariant* — a round-trip (`decode(encode(x)) == x`), a "never panics," a "two implementations agree," an output that stays well-formed.

The allowed fuzzing argument types are exactly: `[]byte`, `string`, `bool`, `byte`, `rune`, the sized `int`/`uint` family, `float32`, `float64` ([pkg.go.dev/testing](https://pkg.go.dev/testing)). A plain `go test` runs only the **seed** corpus (fast, deterministic, CI-safe). Active fuzzing needs the flag: "To enable fuzzing, run `go test` with the `-fuzz` flag, providing a regex matching a single fuzz test" ([go.dev/security/fuzz](https://go.dev/security/fuzz/)) — e.g. `go test -fuzz=FuzzReverse -fuzztime=30s`. It matches exactly one fuzz test and "By default, it continues to run until a failing input is found" ([go.dev/security/fuzz](https://go.dev/security/fuzz/)) or the time runs out.

When it finds a counterexample it minimizes it and saves it: the "Failing input written to `testdata/fuzz/FuzzReverse/...`" is a file the engine "wrote … to the seed corpus for that fuzz test, and it will now be run by default with `go test`, serving as a regression test once the bug has been fixed" ([go.dev/security/fuzz](https://go.dev/security/fuzz/)). **Commit that file** — it is the captured bug, and a plain `go test` re-checks it forever. (The *generated* corpus, by contrast, "is stored in `$GOCACHE`/fuzz" and is not committed.) Fuzzing earns its place wherever a function parses or decodes untrusted `string`/`[]byte` — parsers, unmarshalers, validators, anything where the input space is too large to enumerate in a table.

---

## 2. Benchmarks — Use `for b.Loop()` (Go 1.24), Not `b.N`

"Functions of the form `func BenchmarkXxx(*testing.B)` are considered benchmarks, and are executed by the `go test` command when its `-bench` flag is provided" ([pkg.go.dev/testing](https://pkg.go.dev/testing)). The body shape changed in Go 1.24. Write `for b.Loop()`:

```go
func BenchmarkCountRunes(b *testing.B) {
	in := loadCorpus()  // expensive setup — runs once, not measured
	b.ReportAllocs()
	for b.Loop() {
		CountRunes(in)
	}
}
```

`b.Loop` is the modern default and the release notes are explicit it is "faster and less error-prone" than `b.N`, with "two significant advantages: The benchmark function will execute exactly once per `-count`, so expensive setup and cleanup steps execute only once. Function call parameters and results are kept alive, preventing the compiler from fully optimizing away the loop body" ([Go 1.24 release notes](https://go.dev/doc/go1.24)). Mechanically: "Loop resets the benchmark timer the first time it is called … any setup performed prior to starting the benchmark loop does not count … when it returns false, it stops the timer so cleanup code is not measured" ([pkg.go.dev/testing](https://pkg.go.dev/testing#B.Loop)). So with `b.Loop` you usually need **no** `b.ResetTimer()` — the reset is built in.

The legacy form still works but you own its hazards: `for i := 0; i < b.N; i++ { … }` (or `for range b.N`) reruns setup on every `b.N` re-measurement and does **not** keep results alive. Pick one: "Benchmarks should either use Loop or contain a loop to b.N, but not both" ([pkg.go.dev/testing](https://pkg.go.dev/testing#B.Loop)). For new code, use `b.Loop`.

Run with allocation counts: `go test -bench=. -benchmem`. Reading the output:

```
BenchmarkCountRunes-32    36540247    34.11 ns/op    0 B/op    0 allocs/op
```

The `-32` is `GOMAXPROCS`; `36540247` is the iteration count the framework chose to run long enough for a stable timing; `34.11 ns/op` is time per operation; `0 B/op` and `0 allocs/op` (shown because `b.ReportAllocs()`/`-benchmem` is on) are bytes and heap allocations per op. A single run is not a measurement you can act on — noise dominates small deltas. To compare two runs statistically, save output and diff it with `benchstat` (route the perf workflow — pprof, escape analysis, PGO — to **`go-performance`**; this skill owns the benchmark mechanics, that one owns interpreting them).

---

## 3. Benchmark Hygiene: Allocs, the Dead-Code Trap, Parallel

- **`b.ReportAllocs()`** turns on the alloc columns for this benchmark: it "enables malloc statistics for this benchmark … equivalent to setting `-test.benchmem`, but it only affects the benchmark function that calls ReportAllocs" ([pkg.go.dev/testing](https://pkg.go.dev/testing)). Always add it when allocations are part of what you're measuring — `0 allocs/op` is a result worth proving.
- **The dead-code trap.** A benchmark whose result is never used can be deleted entirely by the optimizer, giving a meaningless `0.3 ns/op`. `b.Loop` defends against this: "arguments to and results from function calls and assigned variables within the loop are kept alive, preventing the compiler from fully optimizing away the loop body … the loop condition must be written exactly as `b.Loop()`" ([pkg.go.dev/testing](https://pkg.go.dev/testing#B.Loop)). With the legacy `b.N` form you must defend it yourself — assign the result to a package-level **sink** variable so the call can't be elided.
- **`b.ResetTimer()`** "zeroes the elapsed benchmark time and memory allocation counters" ([pkg.go.dev/testing](https://pkg.go.dev/testing)) — needed only in the `b.N` style after setup; `b.Loop` resets on its first call.
- **`b.RunParallel(func(pb *PB){ for pb.Next() { … } })`** measures contended throughput across `GOMAXPROCS` goroutines; the body "should not use the B.StartTimer, B.StopTimer, or B.ResetTimer functions, because they have global effect" ([pkg.go.dev/testing](https://pkg.go.dev/testing)).

---

## 4. `testing/synctest` (Go 1.25) — Kill the Flaky `time.Sleep` Test

Concurrency tests that wait on real time are slow and flaky: a `time.Sleep(100*time.Millisecond)` to "let the goroutine finish" either wastes 100ms or races under load. `testing/synctest`, stable since Go 1.25, removes both problems. "The `Test` function runs a test function in an isolated 'bubble'. Within the bubble, time is virtualized: `time` package functions operate on a fake clock and the clock moves forward instantaneously if all goroutines in the bubble are blocked" ([Go 1.25 release notes](https://go.dev/doc/go1.25)).

```go
func TestWaitWithTimeout(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()

		start := time.Now()
		err := WaitWithTimeout(ctx, make(chan struct{})) // never signaled
		if err != context.DeadlineExceeded {
			t.Errorf("WaitWithTimeout() = %v, want %v", err, context.DeadlineExceeded)
		}
		if elapsed := time.Since(start); elapsed != 5*time.Second {
			t.Errorf("fake clock advanced %v, want exactly 5s", elapsed)
		}
	})
}
```

That 5-second timeout resolves **instantly** in wall-clock time, and `time.Since(start)` reads *exactly* `5s` on the fake clock — deterministic, no real sleep. Two API pieces: every goroutine started inside the bubble is part of it, and time only advances "when every goroutine in the bubble is durably blocked." A goroutine is "durably blocked" when it "can only be unblocked by another goroutine in the same bubble" ([pkg.go.dev/testing/synctest](https://pkg.go.dev/testing/synctest)) — a channel op, `select`, `sync.Cond.Wait`, `WaitGroup.Wait`, or `time.Sleep`, all on bubble-internal state. When you need to observe state *after* background goroutines have settled, `synctest.Wait` "blocks until every goroutine within the current bubble, other than the current goroutine, is durably blocked" ([pkg.go.dev/testing/synctest](https://pkg.go.dev/testing/synctest)). (`synctest` was a `GOEXPERIMENT` in 1.24 with a different API; that old API is gone in 1.26. See `go-race-and-memory-model` for the memory-model basis and `-race`.)

---

## 5. Coverage Is a Guide, Not a Target

"If executing the test suite causes 80% of the package's source statements to be run, we say that the test coverage is 80%" ([go.dev/blog/cover](https://go.dev/blog/cover)). The commands:

```sh
go test -cover                              # prints "coverage: NN.N% of statements"
go test -coverprofile=cover.out            # also sets -cover; writes the profile
go tool cover -func=cover.out              # per-function breakdown
go tool cover -html=cover.out              # browser: green=covered, red=not, grey=uninstrumented
```

"The `-coverprofile` flag automatically sets `-cover`" ([go.dev/blog/cover](https://go.dev/blog/cover)). Coverage measures which **statements executed**, not whether you asserted the right thing — a test that runs a line but checks nothing still counts it as covered. So use the red in `-html` to *find untested branches* (its real value), not the percentage as a KPI. Chasing 100% manufactures brittle tests for trivial getters and error-string branches while a single number hides the one untested path that matters. For integration binaries, `go build -cover` (Go 1.20+) collects coverage from a running program, not just `go test`.

---

## 6. The Testify Stance: Prefer Stdlib + `cmp`

testify (`assert`, `require`, `mock`, `suite`) is widely used, but the idiomatic-Go default for **new** code is the stdlib `testing` package plus `go-cmp` (see `go-testing-tabledriven` §8). Google's style guide is direct: "Do not create 'assertion libraries' as helpers for testing," because they "tend to either stop the test early … or omit relevant information about what the test got right," and "Instead of creating a domain-specific language for testing, use Go itself" — "Prefer using standard libraries such as `cmp` and `fmt` instead" ([Google Style Guide](https://google.github.io/styleguide/go/decisions#assert)). The stdlib `if got != want { t.Errorf("Fn(%v) = %v, want %v", in, got, want) }` keeps the got/want and the failure semantics visible.

**The stance is discouraged, not banned.** In a codebase already on testify, match it — consistency wins. But don't reach for it by reflex on a greenfield package. If you do use it, know the fatal-behavior split: `assert` "returns a bool" and continues; the `require` package "implements the same assertions as the `assert` package but stops test execution when a test fails" via `t.FailNow` ([pkg.go.dev/testify](https://pkg.go.dev/github.com/stretchr/testify)). That carries the same goroutine rule as `t.Fatal`: `require.*` must not be called from a spawned goroutine (it calls `FailNow`, which only stops the test goroutine — see `go-testing-tabledriven` §6). Using `assert` where the test should stop leaves a failed check followed by a nil-deref panic.

---

## 7. Routing to Related Skills

- `go-testing-tabledriven` — the **other** testing skill: table tests, `t.Run` subtests, `t.Helper`, `t.Cleanup`, `t.Parallel`, golden files, `cmp.Diff`. The structural floor; read it first.
- `go-performance` — pprof, escape analysis (`-gcflags=-m`), PGO, and `benchstat` for comparing benchmark runs. Benchmarks here *feed* that workflow.
- `go-race-and-memory-model` — what a data race is, `go test -race` in CI, and the memory-model basis of `synctest`.
- `go-version-feature-map` — the version floors: fuzzing 1.18, `b.Loop` 1.24, `testing/synctest` stable 1.25. Gate the idiom on your module's `go` directive.
- `go-idiomatic-discipline` — the policy root; "clear AND correct" applies to test code too.

---

## 8. Reference Files

High-frequency advanced-testing anti-patterns in LLM-generated Go, each with wrong/right code and citations:

[references/common-mistakes.md](references/common-mistakes.md)

Source provenance for every claim in this skill:

[references/sources.yaml](references/sources.yaml)

