📋 Analogy
A table test is a checklist on a clipboard. Instead of writing a separate inspection for every item, you list the items in rows — name, input, expected — and walk the same inspection down the list. Add a row to cover a new case; the procedure never changes. t.Run stamps each row with its own pass/fail so you know exactly which one failed.
The pattern
A table test is a slice of anonymous-struct cases, looped with t.Run so each becomes a named subtest:
// reverse_test.go
package strutil
import "testing"
func TestReverse(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{"empty", "", ""},
{"single", "a", "a"},
{"word", "abc", "cba"},
{"palindrome", "level", "level"},
{"unicode", "héllo", "olléh"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Reverse(tc.input)
if got != tc.want {
t.Errorf("Reverse(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
Running it shows each case by name, and -run can target a single row:
$ go test -v -run TestReverse
=== RUN TestReverse
=== RUN TestReverse/empty
=== RUN TestReverse/word
=== RUN TestReverse/unicode
--- PASS: TestReverse (0.00s)
--- PASS: TestReverse/empty (0.00s)
--- PASS: TestReverse/word (0.00s)
--- PASS: TestReverse/unicode (0.00s)
PASS
# run just one subtest
$ go test -run TestReverse/unicode
Here is the function under test running over the same inputs in main, printing got vs want — exactly what the table checks, but in a form you can run and edit:
package main
import "fmt"
// Reverse returns s with its runes in reverse order (rune-safe).
func Reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
func main() {
cases := []struct{ input, want string }{
{"", ""},
{"a", "a"},
{"abc", "cba"},
{"level", "level"},
{"héllo", "olléh"},
}
for _, c := range cases {
got := Reverse(c.input)
status := "ok"
if got != c.want {
status = "MISMATCH"
}
fmt.Printf("Reverse(%q) = %q want %q [%s]\n", c.input, got, c.want, status)
}
}
Parallel subtests
t.Parallel() lets independent cases run concurrently. Each subtest signals it’s parallel, then pauses until the parent returns and they all run together:
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel() // these subtests run concurrently
if got := Reverse(tc.input); got != tc.want {
t.Errorf("Reverse(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
graph TD
T["cases []struct{name,input,want}"] --> L["for _, tc := range cases"]
L --> R["t.Run(tc.name, func(t))"]
R --> S1["subtest: empty"]
R --> S2["subtest: word"]
R --> S3["subtest: unicode"]
S1 --> A["assert got == want"]
S2 --> A
S3 --> A⚠️ The loop-variable trap (and why it's gone)
With parallel subtests on Go 1.21 and earlier, every closure captured the same tc, so by the time the parallel subtests ran, all of them saw the last case. The fix was a tc := tc copy at the top of the loop. As of Go 1.22 the for range loop variable is scoped per iteration, so this footgun is fixed and the copy is no longer needed — but you’ll still see tc := tc in older code, and it does no harm.
Slice vs map tables
A slice of cases preserves order and lets cases share a name field — the default choice. A map keyed by name enforces unique names and reads a little cleaner, at the cost of random iteration order:
// Map-keyed: the key is the case name; iteration order is random.
cases := map[string]struct {
input, want string
}{
"empty": {"", ""},
"word": {"abc", "cba"},
"unicode": {"héllo", "olléh"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
if got := Reverse(tc.input); got != tc.want {
t.Errorf("Reverse(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
Reach for the slice form when order matters or you want deterministic output; the map form when each case is independent and you value the uniqueness guarantee.
See also
- testing basics — the
ttoolbox,t.Run,t.Cleanup,t.TempDir. - fuzzing — generate inputs automatically once your table covers the known cases.
- benchmarks — the same row-per-case idea, measured with
b.N.
Next: throwing random inputs at your code to find the cases you didn’t list — fuzzing.
Related topics
The testing package and go test — writing TestXxx functions, Errorf vs Fatalf, t.Helper, and the got/want convention that runs with go test ./...
testingFuzzingNative Go fuzzing — FuzzXxx with f.Add seeds and f.Fuzz, how a fuzzer hunts for panics and broken properties, and go test -fuzz.
testingBenchmarksMeasuring speed with BenchmarkXxx and b.N — reading ns/op, B/op and allocs/op, and the classic += vs strings.Builder comparison.
Check your understanding
Score: 0 / 51. Why wrap each case in t.Run(tc.name, ...) instead of asserting in the loop directly?
t.Run creates an isolated subtest with its own name (e.g. TestSplit/empty_string). A failure or Fatalf in one subtest doesn't stop the others, the output tells you exactly which case broke, and -run TestSplit/empty can run just that one.
2. When you call t.Parallel() inside a subtest, what should you watch out for with the loop variable?
Parallel subtests pause until the parent test function returns, then run together. In Go 1.22+ the for range loop variable is per-iteration, so capturing tc is safe. On Go 1.21 and earlier you needed tc := tc to avoid every subtest seeing the last case.
3. What's the main reason table tests scale better than one function per case?
The check is written a single time; each new scenario is just another row in the cases slice. That keeps the assertions consistent and makes the set of inputs easy to read and extend at a glance.
4. How do you run just one row of a table test from the command line?
Because t.Run names each case as TestName/sub, -run takes a slash-separated regexp: go test -run TestReverse/unicode. The testing package replaces spaces in subtest names with underscores, so quote/escape accordingly.
5. When might you key the cases by a map instead of a slice — and what's the trade-off?
map[string]struct{...} makes the name the key (enforcing uniqueness and dropping a 'name' field), but Go randomizes map iteration, so cases run in no fixed order. A slice preserves order — pick it when one case should run before another, or for deterministic output.
Comments
Sign in with GitHub to join the discussion.