Table-Driven Tests & Subtests
Parametrized cases, t.Run, and shared setup patterns.
Search across all documentation pages
Parametrized cases, t.Run, and shared setup patterns.
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.
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:
-run TestParse/badt.Parallel()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:
t.Run labels failures (TestParseInts/bad token)t.Parallel() runs subtests concurrently when safet.Run registers a subtest with its own failure scope and optional parallelism.go test -run 'TestParseInts/list$' matches subtest names by regex.tc into closures for clarity on libraries.| 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 |
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
}name values confuse -run filters. Fix: Use unique, descriptive name strings.t.Parallel().wantErr or compare errors.Is in the table.errors.Is / errors.As columns in the struct.t.Run so all cases execute.| 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 |
Keep it inside the test function unless multiple tests share the same cases.
Large shared tables can live in testdata as JSON.
Use t.Skip("reason") inside that subtest, or comment the row out.
Do not leave skipped cases in main without a linked issue.
Yes - t.Run can call t.Run again.
Deep nesting hurts readability; prefer flat names like network/timeout.
When tests in different functions do not share mutable package state.
Tables with shared DB setup usually parallelize inside subtests after setup.
defer func() {
if recover() == nil {
t.Fatal("expected panic")
}
}()Or extract a helper that expects panic per row.
Use *T or a valid bool field when zero values are ambiguous (e.g. want 0 vs missing).
Store expected bytes under testdata/ and load per row.
See coverage-race-detector-and-golden-files for update workflows.
Yes - struct fields can be type parameters or use generics in helpers.
The loop pattern is unchanged.
t.Log prints with -v always, or on failure automatically.
Use t.Logf inside the subtest for case-specific context.
Startup per subtest is small.
Thousands of network subtests still hurt CI - batch or mock I/O instead.
Extract the slice to a var or generator function in the test file.
Keep benchmark loops separate to avoid measuring setup.
Tag files //go:build integration for slow rows in a separate _test.go.
Default go test stays fast.
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).
Reviewed by Chris St. John·Last updated Jul 16, 2026