Table-Driven Tests & Subtests
Parametrized cases, t.Run, and shared setup patterns.
Summary
Table-driven tests store cases in a slice of structs and loop with a single test body.
Subtests wrap each row with t.Run so failures name the case and you can run subsets with -run.
Shared setup belongs in helpers marked t.Helper() or in TestMain when the whole package needs one database.
Recipe
Quick-reference recipe card - copy-paste ready.
func TestParse(t *testing.T) {
tests := []struct {
name string
input string
want int
wantErr bool
}{
{"zero", "0", 0, false},
{"bad", "x", 0, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := Parse(tc.input)
if tc.wantErr {
if err == nil {
t.Fatal("expected error")
}
return
}
if err != nil || got != tc.want {
t.Fatalf("got (%d, %v) want (%d, nil)", got, err, tc.want)
}
})
}
}When to reach for this:
- One function has many input/output pairs to document
- You want reviewers to add cases without cloning setup code
- CI should run or skip specific cases via
-run TestParse/bad - Success and error paths share the same arrange/act pattern
- Independent cases can run in parallel with
t.Parallel()
Working Example
package parse
import (
"strconv"
"strings"
"testing"
)
func ParseInts(csv string) ([]int, error) {
if csv == "" {
return nil, nil
}
parts := strings.Split(csv, ",")
out := make([]int, 0, len(parts))
for _, p := range parts {
n, err := strconv.Atoi(strings.TrimSpace(p))
if err != nil {
return nil, err
}
out = append(out, n)
}
return out, nil
}
func TestParseInts(t *testing.T) {
tests := []struct {
name string
in string
want []int
wantErr bool
}{
{"empty", "", nil, false},
{"single", "42", []int{42}, false},
{"list", "1, 2, 3", []int{1, 2, 3}, false},
{"bad token", "1,x", nil, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := ParseInts(tc.in)
if tc.wantErr {
if err == nil {
t.Fatal("expected error")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != len(tc.want) {
t.Fatalf("len %d want %d", len(got), len(tc.want))
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("got[%d]=%d want %d", i, got[i], tc.want[i])
}
}
})
}
}What this demonstrates:
- Table rows carry both happy and error paths
t.Runlabels failures (TestParseInts/bad token)t.Parallel()runs subtests concurrently when safe- Empty slice vs nil is asserted explicitly
Deep Dive
How It Works
- The table is data; the loop is the single test harness.
t.Runregisters a subtest with its own failure scope and optional parallelism.go test -run 'TestParseInts/list$'matches subtest names by regex.- Loop variable capture is safe on Go 1.22+; still pass
tcinto closures for clarity on libraries.
Setup and Teardown Patterns
| Pattern | Use when | Notes |
|---|---|---|
Helper with t.Helper() | Repeated assertions | Keeps line numbers in the test |
defer in subtest | Per-case cleanup | Runs even when t.Fatal fires |
TestMain | One DB or global listener | Call m.Run() once, then teardown |
t.Cleanup | Register cleanup after setup | Runs in LIFO order per test |
Go Notes
func setupDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
return db
}Gotchas
- Missing subtest names - duplicate
namevalues confuse-runfilters. Fix: Use unique, descriptivenamestrings. - Parallel subtests sharing mutable state - races on a package-level map. Fix: Isolate data per subtest or avoid
t.Parallel(). - Only testing success rows - error branches never regress. Fix: Add
wantError compareerrors.Isin the table. - Huge tables without grouping - reviewers cannot scan 80 anonymous rows. Fix: Split tables by concern or use tiered subtests.
- Comparing errors with == - wrapped errors fail equality. Fix: Use
errors.Is/errors.Ascolumns in the struct. - Loop without t.Run - first failure hides later cases. Fix: Wrap each iteration in
t.Runso all cases execute.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
Separate TestXxx per case | One-off regression with heavy setup | Many similar inputs |
| Generative / fuzz tests | Unknown malformed input space | Documented business rules |
| testify suite structs | Shared suite hooks across methods | Simple pure functions |
| BDD layers (Ginkgo, etc.) | Team standardizes on BDD prose | You want zero test deps |
FAQs
Should the table be a package-level var?
Keep it inside the test function unless multiple tests share the same cases.
Large shared tables can live in testdata as JSON.
How do I skip one case temporarily?
Use t.Skip("reason") inside that subtest, or comment the row out.
Do not leave skipped cases in main without a linked issue.
Can subtests nest deeper than one level?
Yes - t.Run can call t.Run again.
Deep nesting hurts readability; prefer flat names like network/timeout.
When should I call t.Parallel at the top level?
When tests in different functions do not share mutable package state.
Tables with shared DB setup usually parallelize inside subtests after setup.
How do I test panics in a table?
defer func() {
if recover() == nil {
t.Fatal("expected panic")
}
}()Or extract a helper that expects panic per row.
Should want be a pointer in the struct?
Use *T or a valid bool field when zero values are ambiguous (e.g. want 0 vs missing).
How do golden files fit tables?
Store expected bytes under testdata/ and load per row.
See coverage-race-detector-and-golden-files for update workflows.
Does table-driven work with generics?
Yes - struct fields can be type parameters or use generics in helpers.
The loop pattern is unchanged.
How do I log only on failure?
t.Log prints with -v always, or on failure automatically.
Use t.Logf inside the subtest for case-specific context.
What is the cost of many subtests?
Startup per subtest is small.
Thousands of network subtests still hurt CI - batch or mock I/O instead.
Can I share a table across Test and Benchmark?
Extract the slice to a var or generator function in the test file.
Keep benchmark loops separate to avoid measuring setup.
How do build tags interact with tables?
Tag files //go:build integration for slow rows in a separate _test.go.
Default go test stays fast.
Related
- Go Testing Basics - first tests and helpers
- Go Testing Culture: Simple, Fast, Table-Driven - cultural context
- testify, httptest & Test Doubles - assertions and fakes
- Examples as Executable Documentation - godoc examples
- Testing Best Practices - team conventions
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).