Table-Driven Tests — Specification¶
This file is the spec-style reference for the language and testing package primitives that underpin the table-driven idiom. Where possible it cites the Go release that introduced or changed a relevant behavior.
1. testing.T.Run — the building block of subtests¶
From the testing package godoc (paraphrased):
func (t *T) Run(name string, f func(t *T)) boolRun runs
fas a subtest oftcalledname. It runsfin a separate goroutine and blocks until eitherfreturns or callst.Parallelto become a parallel test.Runreports whetherfsucceeded (or at least did not fail before callingt.Parallel).
Key properties:
- The argument
namebecomes part of the full test name in the formTestX/name. - Slashes in
nameare preserved; spaces and other special characters are rewritten to underscores when reporting (see Section 4). Runalways launches a goroutine forf, but it blocks untilfeither returns or callst.Parallel. So a non-parallel subtest is, behaviorally, sequential.- Failures in one subtest do not abort its siblings — each subtest gets a fresh
*T.
Run was introduced in Go 1.7 (August 2016).
2. -run regex semantics¶
From go help testflag:
-run regexpRun only those tests, examples, and fuzz tests matching the regular expression. For tests, the regular expression is split by unbracketed slash (
/) characters into a sequence of regular expressions, and each part of a test's identifier must match the corresponding element in the sequence, if any.
So -run TestParse/empty_input evaluates as:
- Top-level test name must match the regex
TestParse. - The first subtest level must match
empty_input.
The match is regexp.MatchString — substring, not full anchor. So -run Parse matches TestParse, TestParseV2, and TestUnparse. To anchor, use ^TestParse$.
Each subtest name segment is also matched as a substring. To run a single case named empty_input under TestParse and nothing else, write -run '^TestParse$/^empty_input$'.
3. -run and t.Parallel interaction¶
When -run excludes a test, that test is not started at all. When -run selects a parallel test, that test still goes through the standard "pause until non-parallel siblings finish" sequence. There is no flag to skip the parallel-pause mechanism.
-parallel N (default GOMAXPROCS) caps how many parallel subtests run at the same time. Note that -parallel is a property of t.Parallel, not of t.Run — sequential subtests do not consume a -parallel slot.
4. Name sanitization¶
testing rewrites the displayed name in two passes:
- Characters that are not printable per Go's
strconv.IsPrintare replaced. - Spaces are replaced with underscores.
So a row named "empty input" shows as TestParse/empty_input in output and is matched by -run TestParse/empty_input or -run TestParse/empty input (the matcher applies the same rewrite to the regex).
Duplicate names within the same parent are disambiguated with a #NN suffix: case#01, case#02. This is why you should always set tc.name explicitly — relying on Go to suffix duplicates makes failures hard to find.
5. Go 1.22 loop variable scope (issue 60078)¶
Before Go 1.22, this loop:
was broken for parallel subtests. The loop variable tc was a single variable reused across iterations. By the time the parallel goroutine actually ran, tc held the last value of the slice. The standard fix:
for _, tc := range cases {
tc := tc // shadow with a per-iteration copy
t.Run(tc.name, func(t *testing.T) { ... })
}
Go 1.22 (February 2024) changed for loop scoping so that the loop variables are scoped per iteration. The tc := tc line is now a no-op in modules that declare go 1.22 or later in go.mod. See proposal 60078 and the Go 1.22 release notes.
Backwards compatibility: code that does tc := tc still compiles and runs identically — it is just redundant. Linters (copyloopvar) will flag the shadow as unnecessary in Go 1.22+ modules.
6. testing.T.Cleanup¶
Added in Go 1.14:
func (t *T) Cleanup(f func())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.
This is essential for table-driven tests because per-case teardown (tc.tempDir, tc.server.Close()) belongs inside the t.Run body, scoped to one row.
7. testing.T.Helper¶
Added in Go 1.9. Mark a function so that line numbers in failure reports point at the caller, not at the helper. Vital for table-driven assertion helpers:
func assertEqual(t *testing.T, got, want any) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v want %v", got, want)
}
}
Without t.Helper, every failure report points at the t.Errorf inside the helper, hiding the row that actually failed.
8. testing.T.Setenv and per-case env¶
Added in Go 1.17. Sets an environment variable and registers a cleanup that restores the previous value. Cannot be combined with t.Parallel — it will fail the test if both are called on the same *T.
This matters for tables: if even one row needs t.Setenv, that row cannot be parallel. Either skip parallelism for the whole table, or split the row out.
9. Fuzz seed corpus and tables¶
Added in Go 1.18. The signature is:
func FuzzX(f *testing.F) {
seeds := []struct{ a, b int }{
{1, 2},
{0, 0},
{-1, math.MaxInt32},
}
for _, s := range seeds {
f.Add(s.a, s.b)
}
f.Fuzz(func(t *testing.T, a, b int) { ... })
}
The seed corpus is the same shape as a test table — f.Add calls play the role of rows. Files in testdata/fuzz/FuzzX/ are also seed inputs.
10. testing.B.Run — table-driven benchmarks¶
Mirrors T.Run:
func BenchmarkSplit(b *testing.B) {
cases := []struct {
name string
in string
}{
{"empty", ""},
{"short", "a,b,c"},
{"long", strings.Repeat("a,", 10000)},
}
for _, tc := range cases {
b.Run(tc.name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = strings.Split(tc.in, ",")
}
})
}
}
b.Run sub-benchmarks each get their own b.N calibration. The -bench flag uses the same slash-regex semantics as -run.
11. Standard library idioms¶
Examples of table-driven tests in the standard library worth reading:
src/strconv/atoi_test.go— classic slice-of-struct table forAtoi.src/encoding/json/decode_test.go— table withwantErranderrTypefields.src/net/url/url_test.go— nested tables (per-method tables inside the file).src/time/format_test.go— table-drivenParseandFormattests sharing one table.src/path/filepath/path_test.go— separate tables for Unix and Windows, gated by build tags.
These files predate t.Run (1.7) in many cases, so they show both the older "increment counter, print case index" style and the newer subtest style.
12. Idiomatic field names¶
The Go community has converged on:
name— string label for the row, becomes the subtest name.input,in— single input value.args— when there are multiple inputs.want,wantErr,wantOut— expected values, kept consistent across the codebase.setup,teardown— function fields for per-case lifecycle (less common, often a code smell).
Avoid expected (older Java convention), actual (use got), and caseN (no name — relies on duplicate-disambiguation).
13. Match precedence¶
When multiple flags overlap:
-runselects which tests to run.-count Nreruns each selected test N times (default 1, sometimes 2 for caching).-parallel Ncaps concurrent parallel subtests.-timeoutapplies to the wholego testinvocation, not to individual subtests.
Per-subtest timeouts must be implemented with context.WithTimeout inside the subtest body.
14. Limits¶
- There is no built-in limit on the number of subtests, but each
t.Runallocates a*Tand a goroutine — at ~10⁶ rows, the per-subtest overhead (~5 µs each) starts to dominate. - Subtest names are de-duplicated within the same parent only. Two
TestA/fooandTestB/fooare independent. t.Runreturnsbool— the result of the subtest. You almost never need it, but it lets you skip subsequent subtests if a setup-row failed.
15. testing.T methods relevant to table-driven tests¶
The full list of *testing.T methods you commonly use inside a t.Run body, with the Go version that introduced each non-original method:
| Method | Since | Notes |
|---|---|---|
t.Errorf | 1.0 | Mark fail, continue subtest. |
t.Fatalf | 1.0 | Mark fail, stop this subtest only. |
t.Logf | 1.0 | Stream-style logging (visible only in -v or on failure). |
t.Skipf / t.Skip / t.SkipNow | 1.1 | Mark subtest skipped. |
t.Parallel | 1.0 | Pause until siblings finish, then resume in parallel. |
t.Run | 1.7 | Create a subtest. |
t.Helper | 1.9 | Mark caller as helper; failures point at caller's caller. |
t.Cleanup | 1.14 | Register teardown function (LIFO). |
t.TempDir | 1.15 | Auto-deleted per-test directory. |
t.Setenv | 1.17 | Sets env var; restores on cleanup. Incompatible with t.Parallel. |
t.Deadline | 1.15 | Returns -timeout deadline if set. |
t.Failed | 1.0 | True if subtest has failed (useful in Cleanup). |
t.Name | 1.8 | Returns full subtest path. |
t.Chdir | 1.24 | Changes working directory and restores on cleanup. |
16. Go release timeline for table-driven features¶
A condensed history of features that shaped the modern table-driven idiom:
- Go 1.0 (March 2012) —
testingpackage withfunc TestX(t *testing.T),t.Error,t.Fatal. No subtests. - Go 1.7 (August 2016) —
T.RunandB.Runadded;-runlearns slash semantics for hierarchy. - Go 1.8 (February 2017) —
T.Nameadded so helpers can introspect the current subtest path. - Go 1.9 (August 2017) —
T.Helperadded; failures point at caller. - Go 1.14 (February 2020) —
T.Cleanupadded; replaces mostdeferpatterns inside tests. - Go 1.15 (August 2020) —
T.TempDiradded; per-test auto-deleted scratch dir. - Go 1.16 (February 2021) —
//go:embedadded; tables can now ship test data inside the binary. - Go 1.17 (August 2021) —
T.Setenvadded; per-test env vars. - Go 1.18 (March 2022) —
testing.Fandf.Fuzzadded; seed corpus is shape-compatible with table tests. - Go 1.21 (August 2023) —
slicesandcmpstandard packages stabilize; common in test helpers. - Go 1.22 (February 2024) —
forloop variable per-iteration scoping.tc := tcshadow becomes redundant in modules withgo 1.22+. - Go 1.23 (August 2024) — Range-over-func iterators usable in test data generation.
- Go 1.24 (February 2025) —
T.Chdiradded; per-test directory changes are scoped.
17. The -shuffle flag¶
go test -shuffle on randomizes the order of top-level tests (and only top-level tests — subtests within a Test run in declaration order). The intent: surface tests that depend on package-level state mutated by earlier tests.
If a table-driven test has subtest rows that depend on each other (sequential mutation), -shuffle will not catch it directly because subtest order within TestX is preserved. But if TestX depends on TestW having set up state, -shuffle will catch that.
To shuffle subtests, you'd have to shuffle the table slice yourself before the loop — but don't. Subtests should be independent; making them rely on order is a code smell.
18. Caching semantics¶
go test caches successful test results based on a hash of the package's source files, environment, and build configuration. Effects on tables:
- Adding a row to a table invalidates the cache for that package.
- Changing test data files (
testdata/*.json) does not invalidate the cache by default —go testdoes not hash file system reads. If your table is data-driven fromtestdata/, use//go:embedso the data is part of the source hash. Otherwise, run with-count=1to bypass the cache.
This is the standard workaround. Many teams alias gotest='go test -count=1'.
19. The race detector and tables¶
go test -race adds runtime instrumentation that detects data races. For parallel tables:
- A 10× wall-clock overhead is normal.
- Race detector slots are limited (8K active goroutines per binary); huge parallel tables can hit the limit. Cap
-parallel. - Any race in setup, teardown, or shared fixtures is reported and fails the test.
A best-practice CI configuration runs the whole suite with -race -count=1 at least nightly, even if the per-commit pipeline runs without -race for speed.
20. Test name normalization details¶
When testing rewrites a subtest name for display and matching, the rule (paraphrased from src/testing/match.go):
func rewrite(s string) string {
b := make([]byte, 0, len(s))
for _, r := range s {
switch {
case isSpace(r):
b = append(b, '_')
case !strconv.IsPrint(r):
s := strconv.QuoteRune(r)
b = append(b, s[1:len(s)-1]...)
default:
b = append(b, string(r)...)
}
}
return string(b)
}
So "hello world" becomes hello_world, and "naïve" survives because ï is printable per IsPrint. Tab characters (\t) and newlines become escaped (\t, \n).
21. go test -v vs go test -json¶
Two output modes you'll use to inspect table results:
-v— human-readable, streams=== RUN,--- PASS,--- FAILmarkers. Subtests appear indented under parents. Good for terminal debugging.-json— machine-readable, one JSON event per line. Used bygotestsum, IDE test runners, and CI dashboards. Each subtest has its own events withTest: "TestX/foo".
Lists failing subtests by full path.
22. Custom *testing.T and embedding¶
You cannot define a custom *testing.T because the type is concrete. But you can:
- Define a helper interface that accepts
testing.TB(parent of*Tand*B). - Wrap test runs in a custom function that takes the slice and returns a
func(*testing.T):
func runTable[T any](cases []T, name func(T) string, body func(*testing.T, T)) func(*testing.T) {
return func(t *testing.T) {
for _, tc := range cases {
t.Run(name(tc), func(t *testing.T) { body(t, tc) })
}
}
}
func TestX(t *testing.T) {
cases := []struct{...}{...}
runTable(cases, func(c struct{...}) string { return c.name }, func(t *testing.T, c struct{...}) {
...
})(t)
}
This works but is rarely worth the indirection. Idiomatic Go keeps the loop inline.