Table tests and subtests: cases as data, t.Run, and why Go ships no assert library
Table tests turn cases into a slice of anonymous structs: one loop, t.Run gives each row a name to filter with -run and parallelize. Failures read as got/want with cmp.Diff; t.Helper keeps traces honest; golden files cover big outputs. No assert in the stdlib — on purpose.
The bug report said one merchant’s invoices were off by exactly one cent. The rounding function had nineteen tests — nineteen separate functions named TestRoundUp, TestRoundDown, TestRoundHalfEven2, copy-pasted over two years, each with its own setup and its own slightly different failure message. Nobody could say which cases were actually covered, so nobody noticed the one that wasn’t. Rewritten as a single table, the function’s entire contract fit on one screen: 23 rows, each a name, an input, a want. The missing row was visible within minutes — negative amounts under half-even rounding — and the fix shipped with row 24 as a permanent regression test. The diff that closed the incident deleted 340 lines of test code and added 60. Table tests are not a style preference: they turn coverage from something you grep for into something you can read.
By the end of this lesson you will know exactly why the missing test case was invisible in 340 lines of prose — and how to make gaps impossible to hide.
Cases as data: the anonymous-struct table
The idiom is a slice of anonymous structs — each row one case, each field one dimension of the contract — and a single loop that runs the real logic once:
func TestParseSize(t *testing.T) {
tests := []struct {
name string
in string
want int64
wantErr bool
}{
{name: "plain bytes", in: "512", want: 512},
{name: "kilobytes suffix", in: "4K", want: 4096},
{name: "rejects negative", in: "-1K", wantErr: true},
{name: "rejects empty", in: "", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseSize(tt.in)
if (err != nil) != tt.wantErr {
t.Fatalf("ParseSize(%q) error = %v, wantErr %v", tt.in, err, tt.wantErr)
}
if got != tt.want {
t.Errorf("ParseSize(%q) = %d, want %d", tt.in, got, tt.want)
}
})
}
}The payoffs are structural. Adding a case is one line, so edge cases actually get added — the marginal cost of test number 24 is near zero, where a copy-pasted function costs fifteen lines and a name. Review changes too: a PR that touches behavior shows up as changed rows, and a reviewer can scan the table for the missing case instead of diffing prose. One sharp edge: if rows share mutable state (a package-level fixture, a reused buffer), ordering hides coupling. Some teams use a map[string]testCase precisely because Go randomizes map iteration — inter-case dependencies then fail loudly instead of silently passing in slice order.
Subtests: addressable, filterable, parallel
t.Run makes each row a real subtest: it runs in its own goroutine, fails independently (t.Fatal kills only that row), and gets an addressable name — spaces become underscores, so the failing case above is reachable directly:
// Re-run exactly one row, no editing code:
// go test -run 'TestParseSize/rejects_negative'
// Everything under one test:
// go test -run 'TestParseSize' -vTwo refinements matter in real suites. First, t.Helper(): call it at the top of any shared assertion helper, and the failure is reported at the caller’s line, not inside the helper — without it, thirty failures all point at helpers.go:12 and the report is useless. Second, t.Parallel() inside the subtest closure parallelizes rows: the parent test returns, parked subtests resume together, and -parallel caps concurrency. The historical trap: before Go 1.22, the loop variable tt was shared per loop, so every parallel subtest captured the same variable and all of them tested the final row — suites passed for years while exercising one case N times. Go 1.22 made loop variables per-iteration, killing the bug class, but you will still meet tt := tt shadow lines in older codebases; they are fossils of that trap, not cargo cult.
A Go 1.21 codebase runs a 12-row table with t.Parallel() inside each t.Run closure, capturing the loop variable tt directly. What did the suite actually test?
got/want, cmp.Diff, and golden files — the no-assert stance
Before you reach for a third-party assertion library, ask yourself: what failure message will you read at 2 a.m. when CI is red and you have no context? That question is why Go ships without assert.Equal.
The stdlib has no assert.Equal by design. The Go FAQ’s position: assertion DSLs move control flow into a framework, encourage tests that die on the first check, and produce failure messages in the framework’s vocabulary instead of yours. A test is just a Go program: if got != want plus t.Errorf with both values. The discipline that makes this scale is the message format — f(input) = got, want want — so a CI log line is diagnosable without opening the file. For structs, slices, and maps, hand-rolled comparison drowns; the de-facto standard is go-cmp:
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("ParseConfig() mismatch (-want +got):\n%s", diff)
}cmp.Diff prints a minimal, line-oriented diff of just the fields that differ — on a 40-field struct that is the difference between a useful failure and two walls of %+v. It panics on unexported fields by default (a feature: it forces you to declare comparison semantics via cmpopts.IgnoreUnexported or an Equal method). When the output itself is large — rendered templates, generated SQL, marshaled JSON — the table pattern extends to golden files: store the expected bytes in testdata/case.golden, compare against them, and regenerate via a -update flag the test reads with flag.Bool. The golden diff then shows up in code review as a real file change, which is the point: a human approves the new expected output.
A shared helper func assertStatus(t *testing.T, got, want int) fails in CI, and all 30 failures point at assert.go:12. What is missing?
- 01Describe the table-test idiom end to end: the data structure, the loop, what t.Run buys you, and the pre-Go-1.22 trap with t.Parallel.
- 02Why does Go's stdlib ship no assert library, and what replaces it at scale — including for big structs and large outputs?
The table-test idiom is Go’s answer to coverage you can read: a slice of anonymous structs where each row is a named case, and one loop that feeds rows through the real code inside t.Run. Subtests are addressable — go test -run ‘TestParseSize/rejects_negative’ re-runs one row — they fail independently, and t.Parallel inside the closure parallelizes them, with the historical caveat that before Go 1.22 the shared loop variable made every parallel subtest see the last row; 1.22’s per-iteration variables ended that bug class, and stray tt := tt lines are its fossils. Failures stay in plain Go: got/want comparisons with messages shaped like ParseSize(input) = got, want want, helpers marked with t.Helper so the report points at the failing row rather than helpers.go:12. When values outgrow !=, cmp.Diff renders a minimal field-level diff and deliberately refuses unexported fields until you state comparison semantics; when outputs outgrow the table, golden files in testdata carry the expected bytes and a -update flag regenerates them, turning expected-output changes into reviewable diffs. The missing assert library is the philosophy in miniature: the test remains an ordinary program whose failure messages you own, and the table makes the gap in your contract something a reviewer can see at a glance. Now when you open a test file and see nineteen separate TestXxx functions, you will know exactly what to do — and how to make the missing case impossible to miss.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.