Go testing advanced
Skill ctoth/golang-skills-plugin/plugins/golang/skills/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 < 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.From its SKILL.md
npx -y skills add ctoth/golang-skills-plugin --skill go-testing-advancedAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
15.7 KB, ~3.9k tokens by cl100k_base, as published. Nobody here has run it
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
"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
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). 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).
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) — 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). 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) — 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) 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). 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). The body shape changed in Go 1.24. Write for b.Loop():
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). 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). 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). 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). Always add it when allocations are part of what you're measuring —0 allocs/opis 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.Loopdefends 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 asb.Loop()" (pkg.go.dev/testing). With the legacyb.Nform 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) — needed only in theb.Nstyle after setup;b.Loopresets on its first call.b.RunParallel(func(pb *PB){ for pb.Next() { … } })measures contended throughput acrossGOMAXPROCSgoroutines; the body "should not use the B.StartTimer, B.StopTimer, or B.ResetTimer functions, because they have global effect" (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).
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) — 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). (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). The commands:
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). 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). 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). 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.Runsubtests,t.Helper,t.Cleanup,t.Parallel, golden files,cmp.Diff. The structural floor; read it first.go-performance— pprof, escape analysis (-gcflags=-m), PGO, andbenchstatfor comparing benchmark runs. Benchmarks here feed that workflow.go-race-and-memory-model— what a data race is,go test -racein CI, and the memory-model basis ofsynctest.go-version-feature-map— the version floors: fuzzing 1.18,b.Loop1.24,testing/syncteststable 1.25. Gate the idiom on your module'sgodirective.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:
${CLAUDE_SKILL_DIR}/references/common-mistakes.md
Source provenance for every claim in this skill:
${CLAUDE_SKILL_DIR}/references/sources.yaml
What ships with it: 2 files
17.5 KB alongside SKILL.md
references/
- common-mistakes.md11.6 KB
- sources.yaml5.9 KB