Go testing tabledriven
Skill ctoth/golang-skills-plugin/plugins/golang/skills/go-testing-tabledriven
Guides Go test structure — a slice of named struct cases looped through t.Run so every case is an isolated, individually-runnable subtest; t.Helper in assertion helpers so failures point at the caller; t.Cleanup (LIFO) over defer for teardown; t.Parallel for concurrency (and the Go 1.22 loop-var change that removes the old `tc := tc` copy); t.Errorf to continue vs t.Fatalf to stop this test's goroutine; got-before-want messages that identify the input; cmp.Diff over reflect.DeepEqual; golden files under testdata/ with a -update flag; t.TempDir and TestMain. Auto-invokes when writing or editing _test.go files, table-driven tests, t.Run subtests, t.Helper, t.Cleanup, t.Parallel, golden files, or on "write tests for this" / "add a test case" requests. Routes fuzzing, benchmarks, and synctest to go-testing-advanced.From its SKILL.md
npx -y skills add ctoth/golang-skills-plugin --skill go-testing-tabledrivenAssembled 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
14.3 KB, ~3.4k tokens by cl100k_base, as published. Nobody here has run it
Go Testing — Table-Driven
"Table driven testing is not a tool, package or anything else, it's just a way and perspective to write cleaner tests." — Go Wiki — TableDrivenTests
"The test code is written once and amortized over all table entries, so it makes sense to write a careful test with good error messages." — Go Wiki — TableDrivenTests
A Go test grows by adding a row, not a function. The shape that makes that work — a slice of named cases looped through t.Run — is the structural floor this skill owns. It also owns the helper/cleanup/parallel mechanics and the assertion conventions (got/want, cmp.Diff, golden files) that keep failures legible. Fuzzing, benchmarks, testing/synctest, and coverage belong to go-testing-advanced.
1. The Table-Driven Shape
Define a []struct{ name string; in …; want … }, then loop, running each case as a subtest with t.Run(tc.name, …). "Given a table of test cases, the actual test simply iterates through all table entries and for each entry performs the necessary tests" (TableDrivenTests).
func TestCount(t *testing.T) {
tests := []struct {
name string
in string
want int
}{
{name: "empty", in: "", want: 0},
{name: "two words", in: "hello world", want: 2},
{name: "tabs and newlines", in: "a\tb\nc", want: 3},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := Count(tc.in); got != tc.want {
t.Errorf("Count(%q) = %d, want %d", tc.in, got, tc.want)
}
})
}
}
The payoff is diagnostic: "When the test fails it is immediately obvious which test failed and why, even without having to read the test code" (TableDrivenTests).
2. Always Use t.Run Subtests
t.Run is not optional decoration. "Run runs f as a subtest of t called name. It runs f in a separate goroutine and blocks until f returns or calls t.Parallel" (pkg.go.dev/testing). Three things you lose without it:
- Isolation. With a bare loop and
t.Errorf, one failing case keeps the loop going but every failure blends together. With subtests each case is a named, separately-reported unit. - A single-case runner. Subtests "can be singled out on the command line using the
-run… flag" (Subtests):go test -run TestCount/two_wordsruns exactly one row. The slash separates parent from subtest name; spaces in names become underscores. - Per-case
t.Parallelandt.Cleanup(§4, §5), which operate on the subtest's own*testing.T.
A t.Errorf inside the loop "is not an assertion. The test continues even after an error is logged" (TableDrivenTests) — good, because you want every case's verdict, not just the first failure.
3. t.Helper — Make Failures Point at the Caller
Factor repeated assertions into a helper and call t.Helper() first. "Helper marks the calling function as a test helper function. When printing file and line information, that function will be skipped" (pkg.go.dev/testing) — so the reported line is the subtest that called the helper, not the helper's interior.
func wantFrequency(t *testing.T, in string, want map[string]int) {
t.Helper() // failures below report the CALLER's line, not this one
got := Frequency(in)
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("Frequency(%q) mismatch (-want +got):\n%s", in, diff)
}
}
Without t.Helper(), every failure across every case points at the one t.Errorf line inside the helper, and you cannot tell which case failed from the output.
4. t.Parallel — and Why Go 1.22 Removed tc := tc
t.Parallel() "signals that this test is to be run in parallel with (and only with) other parallel tests" (pkg.go.dev/testing). Call it at the top of the subtest (and optionally in the parent) to run independent cases concurrently:
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := Count(tc.in); got != tc.want {
t.Errorf("Count(%q) = %d, want %d", tc.in, got, tc.want)
}
})
}
The version-aware modernization: pre-1.22, a parallel subtest closure captured the single shared loop variable, which the loop overwrote before the deferred parallel body ran — so everyone needed the tc := tc shadow copy. Go 1.22 fixed the root cause: "Previously, the variables declared by a 'for' loop were created once and updated by each iteration. In Go 1.22, each iteration of the loop creates new variables, to avoid accidental sharing bugs" (Go 1.22 release notes). The new semantics apply to packages in a module whose go.mod declares go 1.22 or later. On a 1.22+ module, do not write tc := tc — it is dead code. (See go-version-feature-map; the copyloopvar modernizer in go fix removes leftover copies — see go-tooling-and-static-analysis.)
A subtle ordering fact: a parent test "blocks until its test function returns and all of its subtests have completed," and parallel subtests are suspended until the parent function returns (Subtests). That is why parallel subtests only start running after the loop has finished launching them.
5. t.Cleanup over defer; t.TempDir for Files
t.Cleanup(fn) registers teardown tied to the test's lifetime: "Cleanup registers a function to be called when the test (or subtest) and all its subtests complete. Cleanup functions will be called in last added, first called order" (pkg.go.dev/testing) — LIFO, exactly like defer, but it survives across helper boundaries. Prefer it inside helpers: a defer in a helper runs when the helper returns, which is too early; t.Cleanup runs when the test ends.
func newServer(t *testing.T) *Server {
t.Helper()
s := startServer()
t.Cleanup(s.Close) // runs when the test finishes, not when newServer returns
return s
}
For filesystem tests use t.TempDir(): it "returns a temporary directory for the test to use. The directory is automatically removed when the test and all its subtests complete" (pkg.go.dev/testing) — no manual os.RemoveAll, no leaked dirs, and a fresh unique dir per call.
6. t.Errorf (continue) vs t.Fatalf (stop) — and the Goroutine Rule
Pick by whether the test can meaningfully continue. t.Errorf "is equivalent to Logf followed by Fail" — it records the failure and keeps going. t.Fatalf "is equivalent to Logf followed by FailNow," and "FailNow marks the function as having failed and stops its execution by calling runtime.Goexit" (pkg.go.dev/testing). Rule of thumb:
t.Fatalfwhen a precondition failed and the rest of the test would panic or test nothing — a setup error, a failedos.ReadFile, a nil result you are about to dereference.t.Errorfwhen you want to report this assertion and still run the others — the common case inside a table loop, where eacht.Errorfflags one bad field without aborting the case.
The goroutine trap: "FailNow must be called from the goroutine running the test or benchmark function, not from other goroutines created during the test. Calling FailNow does not stop those other goroutines" (pkg.go.dev/testing). So never call t.Fatal/t.Fatalf (or anything that calls FailNow, including require-style helpers) from inside a go func(){…} you spawned — send the error back to the test goroutine and fail there, or use t.Errorf which is safe to call from any goroutine.
7. Message Conventions: got-before-want, Identify the Input
A failure message has a fixed grammar. Actual value first, expected second: "Test outputs should include the actual value that the function returned before printing the value that was expected" (Google Style Guide — decisions). Name the function and the input: "failure messages should include the name of the function that failed" and "should include the function inputs if they are short" (Google Style Guide).
// WRONG — want before got, no input, no function: "expected 3 but got 2" tells you nothing
t.Errorf("expected %d but got %d", tc.want, got)
// RIGHT — function, input, got, then want
t.Errorf("Count(%q) = %d, want %d", tc.in, got, tc.want)
In a table-driven test the subtest name already labels the case, but echoing the literal input in the message still saves a lookup.
8. cmp.Diff over reflect.DeepEqual
For anything bigger than a scalar — structs, slices, maps — compare with cmp.Diff from github.com/google/go-cmp/cmp, not reflect.DeepEqual. The style guide is explicit: "reflect.DeepEqual should not be used for checking equality, as it is sensitive to changes in unexported fields and other implementation details … Prefer using cmp for new code" (Google Style Guide). cmp.Diff "returns a human-readable report of the differences between two values" (pkg.go.dev/cmp), so a failure shows the exact field that differs instead of a flat false.
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("Frequency(%q) mismatch (-want +got):\n%s", in, diff)
}
Argument order is cmp.Diff(want, got) with the message labeled (-want +got): the first arg is the - side, the second the + side. Two cautions: cmp "is intended to only be used in tests" and "may panic if it cannot compare the values"; and "unexported fields are not compared by default; they result in panics unless suppressed by using an Ignore option" — reach for cmpopts.IgnoreUnexported/cmpopts.EquateApprox from github.com/google/go-cmp/cmp/cmpopts (pkg.go.dev/cmp). If you cannot add the dependency, reflect.DeepEqual for the boolean check is the stdlib fallback, but you lose the diff.
9. Golden Files for Large Expected Output
When the expected value is a big blob (rendered text, JSON, generated code), store it in a file under testdata/ rather than a giant string literal. The go tool ignores any directory named testdata, so it never interferes with the build. Compare the result against the file, and gate regeneration behind a -update flag so refreshing the expectation is one command:
var update = flag.Bool("update", false, "regenerate golden files")
func TestReportGolden(t *testing.T) {
got := Report(input)
golden := filepath.Join("testdata", "report.golden")
if *update {
if err := os.WriteFile(golden, []byte(got), 0o644); err != nil {
t.Fatalf("writing golden file: %v", err)
}
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("reading golden (run `go test -update` to create it): %v", err)
}
if diff := cmp.Diff(string(want), got); diff != "" {
t.Errorf("Report() mismatch (-want +got):\n%s", diff)
}
}
go test -update writes the golden file; a normal go test compares against it. Review golden diffs in code review exactly as you would review the code that produced them — a golden file is only as trustworthy as the run that generated it.
10. Test the Public API; TestMain for Suite Setup
Put tests in an external test package — package foo_test in the same directory — so the test compiles against foo's exported surface only. This proves the public API is usable as shipped and stops tests from depending on internals that may change. Drop to internal package foo tests only for the genuinely unexported logic that the public API cannot reach.
For one-time setup/teardown around the whole package (a shared database, a global fixture), define TestMain: it runs m.Run() and passes the result to os.Exit. "TestMain is a low-level primitive and should not be necessary for casual testing needs, where ordinary test functions suffice" (pkg.go.dev/testing) — reach for it only when per-test t.Cleanup/helpers genuinely cannot express the setup. Gate slow suites with if testing.Short() and run go test -short for the fast path.
11. Routing to Related Skills
go-idiomatic-discipline— the policy root; "clear AND correct" applies to test code too.go-testing-advanced— fuzzing, benchmarks (b.Loop),testing/synctest, coverage, and the stdlib-vs-testify stance. This skill owns structure; that one owns those techniques.go-error-handling— testing error paths: assert witherrors.Is(err, ErrSentinel)/errors.As, never by string-matching the message.go-version-feature-map— the Go 1.22 loop-var change that removestc := tc, and which idiom yourgodirective allows.go-tooling-and-static-analysis—go test -racein CI, and thego fixmodernizer that strips leftovertc := tccopies.
12. Reference Files
High-frequency table-driven 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
15.1 KB alongside SKILL.md
references/
- common-mistakes.md10.8 KB
- sources.yaml4.3 KB