- Book: The Complete Guide to Go Programming
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You've seen the test file. One Go function, forty if got != want
blocks, copy-pasted, each with a slightly different input and a
slightly different message. When case 23 fails, the line number tells
you which t.Errorf fired but not which input caused it. So you add
the input to the message. Now every block has its own bespoke format
string, and the file is four hundred lines long.
Go has three tools that turn that file into something you can read:
table-driven tests, t.Run subtests, and t.Parallel(). Used
together they are the standard way Go teams write tests. Used wrong,
t.Parallel() in particular will hand you flaky failures that pass on
rerun and waste a sprint. Here is how the three fit, and where the
2026 toolchain still lets you shoot yourself.
Start with the table
A table-driven test is a slice of cases and a loop. Each case is a
struct: the input, the expected output, and a name.
func TestDiscount(t *testing.T) {
cases := []struct {
name string
price int
tier string
want int
}{
{"no tier", 100, "", 100},
{"silver 10pct", 100, "silver", 90},
{"gold 20pct", 100, "gold", 80},
{"zero price", 0, "gold", 0},
}
for _, c := range cases {
got := Discount(c.price, c.tier)
if got != c.want {
t.Errorf("%s: Discount(%d,%q)=%d want %d",
c.name, c.price, c.tier, got, c.want)
}
}
}
This is already better than forty hand-written blocks. Adding a case
is one line. The failure message carries the input. But a failure in
case three does not stop case four, which is good, and yet the whole
thing reports as a single TestDiscount result. You can't run just
the gold case. That's what subtests fix.
Wrap each row in t.Run
t.Run(name, func) creates a subtest with its own name and its own
pass/fail status. Drop the table loop body into one:
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := Discount(c.price, c.tier)
if got != c.want {
t.Errorf("Discount(%d,%q)=%d want %d",
c.price, c.tier, got, c.want)
}
})
}
Now the test output names each row. A failure reads
--- FAIL: TestDiscount/gold_20pct. You can run one row from the
command line:
go test -run 'TestDiscount/gold_20pct'
Spaces in the name become underscores in the -run pattern. Keep
names short and filesystem-friendly for that reason. Each subtest also
gets its own t, so t.Fatal inside one row stops that row only, not
the whole table. With a bare loop, t.Fatal would kill every case
after it.
Then t.Parallel(), and the first trap
Tests that hit a network stub, sleep on a timer, or spin up a
container spend most of their wall-clock time waiting. t.Parallel()
tells the test runner this subtest can run alongside its siblings.
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
got := Discount(c.price, c.tier)
if got != c.want {
t.Errorf("Discount(%d,%q)=%d want %d",
c.price, c.tier, got, c.want)
}
})
}
Here is the trap that defined Go testing for a decade. Before Go 1.22,
the loop variable c was a single variable reused across iterations.
t.Parallel() pauses the subtest and returns control to the loop. The
loop runs to the end. By the time the paused subtests resume, c
holds the last case. Every parallel subtest tested the same row, and
the bug was silent: tests passed because they all checked a row that
worked.
The classic fix was a per-iteration copy:
for _, c := range cases {
c := c // shadow, one copy per iteration
t.Run(c.name, func(t *testing.T) {
t.Parallel()
// ...
})
}
What changed in Go 1.22, and what didn't
Go 1.22 changed for loop semantics: the loop variable is now scoped
to each iteration, not shared across the loop. On any module that
declares go 1.22 or later in go.mod, the c := c line is dead
code. Each iteration already has its own c, so parallel subtests
capture the right value. go vet no longer warns about the missing
copy, and the copyloopvar modernizer in Go 1.22+ will even strip the
redundant c := c for you when you run go fix ./....
Two caveats before you delete every copy line:
- The semantics follow the
godirective ingo.mod, not the compiler you happen to be running. A module pinned togo 1.21still has the old shared-variable behavior even on a 1.26 toolchain. Check the directive before you trust per-iteration scoping. - The fix only covers the loop variable. It does nothing for shared state you reach from inside the closure. That's the next trap, and it's the one that survives the 1.22 fix.
The trap the loopvar fix does not solve
Per-iteration loop scoping fixed one kind of shared state. It does not
touch a variable declared outside the loop that every subtest writes
to.
func TestCounter(t *testing.T) {
total := 0 // shared across all subtests
cases := []int{1, 2, 3, 4}
for _, n := range cases {
t.Run(fmt.Sprint(n), func(t *testing.T) {
t.Parallel()
total += n // DATA RACE
})
}
}
total += n runs from four goroutines with no synchronization. The
loop variable is fine; n is correct in each subtest. total is the
problem. Run this with go test -race and the race detector flags it.
Run it without, and you get a number that's wrong some fraction of the
time, which is worse, because it passes often enough to ship.
The rule: once a subtest calls t.Parallel(), treat everything in the
enclosing function scope as read-only from inside the closure. If
subtests must share mutable state, that's a design smell in the test;
collect results through a channel or compute the total after the
parallel block, not during it. And run -race in CI. It is the only
tool that catches this reliably.
Parallel subtests and the parent that finishes too early
This one surprises people who just learned t.Parallel(). A parallel
subtest does not run inline. It signals "I can run in parallel," then
pauses until its parent test function returns. The parent's own body
runs to completion first, then the paused parallel children run as a
group.
That ordering matters when the parent sets up shared resources:
func TestWithServer(t *testing.T) {
srv := startTestServer(t)
defer srv.Close() // runs when TestWithServer returns
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
hitServer(t, srv, c.input) // server already closed
})
}
}
TestWithServer schedules the parallel subtests, then reaches its
return. The defer srv.Close() fires. Then the parallel children
wake up and call hitServer against a closed server. Every subtest
fails or, worse, hangs on a dead connection.
The fix is to give the parallel subtests their own enclosing scope so
the parent doesn't return until they finish:
func TestWithServer(t *testing.T) {
srv := startTestServer(t)
defer srv.Close()
t.Run("group", func(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
hitServer(t, srv, c.input)
})
}
})
// t.Run("group", ...) blocks here until all
// parallel children finish, so the defer is safe
}
The wrapping t.Run("group", ...) call blocks until its parallel
children complete. Only then does TestWithServer return and run the
defer. The server stays up for the whole group.
Cleanup ordering: t.Cleanup beats defer here
That defer srv.Close() works once you've fixed the scope, but
t.Cleanup is the better tool for test teardown, and it has ordering
rules worth knowing.
t.Cleanup(fn) registers fn to run when the test and all its
subtests finish. Cleanups run last-in-first-out, the same order as
defer. The difference: a cleanup registered on a parent test waits
for parallel subtests, while a plain defer in the parent body does
not.
func TestWithServer(t *testing.T) {
srv := startTestServer(t)
t.Cleanup(srv.Close) // waits for parallel subtests
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
hitServer(t, srv, c.input)
})
}
}
With t.Cleanup(srv.Close), the close runs after every parallel child
of TestWithServer has finished, even without the wrapping group.
That's the property defer lacks. When a helper sets up a resource,
have the helper register its own cleanup:
func startTestServer(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(handler())
t.Cleanup(srv.Close)
return srv
}
Now the call site can't forget to close it, and the close is ordered
correctly relative to parallel subtests. Stack two cleanups and the
second registered runs first, so register them in setup order and let
LIFO unwind them.
The whole thing, in order
The trifecta is three independent decisions, applied in sequence:
- Table for the data. A slice of case structs with a
namefield. -
t.Run(c.name, ...)for isolation and a-runhandle per row. -
t.Parallel()for the rows that wait on something slow, with the shared-state and ordering rules respected.
The 2026 toolchain removed one historical footgun, the loop-variable
capture, on any module declaring go 1.22 or later. It did not remove
the others. Shared mutable state across parallel subtests still races.
Parent defer still runs before parallel children. t.Cleanup is
still the teardown primitive that understands parallelism. Run
go test -race in CI and the data races stop being a debugging
mystery and start being a build failure, which is where you want them.
Cancellation, scheduling, and the test runner's parallel model all sit
on the same runtime machinery, and it's the kind of thing that reads
small until a flaky test sends you spelunking through testing's
source. The Complete Guide to Go Programming walks through how the
runtime parks and resumes goroutines, which is exactly what
t.Parallel() is doing under the hood when it pauses your subtest.

Top comments (0)