DEV Community

Cover image for Table Tests, Subtests, and t.Parallel(): The Go Testing Trifecta
Gabriel Anhaia
Gabriel Anhaia

Posted on

Table Tests, Subtests, and t.Parallel(): The Go Testing Trifecta


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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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)
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

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()
        // ...
    })
}
Enter fullscreen mode Exit fullscreen mode

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 go directive in go.mod, not the compiler you happen to be running. A module pinned to go 1.21 still 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
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Table for the data. A slice of case structs with a name field.
  2. t.Run(c.name, ...) for isolation and a -run handle per row.
  3. 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.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)