t.Run lets you nest tests inside a parent test so each case has its own name, its own pass/fail line, and its own scope. It pairs naturally with the table-driven pattern and unlocks a few features that flat tests can't reach: per-case filtering on the command line, per-case parallelism with t.Parallel, and deterministic cleanup with t.Cleanup.
A subtest is a function passed to t.Run with a name. The name becomes part of the test's identity in the output, in -run filters, and in any failure logs.
Every subtest gets its own === RUN and --- PASS line, indented under the parent. If one case fails, the others still run, and the failure message points at the exact subtest name. The spaces in "empty cart" become underscores in empty_cart. The test runner sanitizes names so the path stays usable on the command line.
Each subtest's full name is the parent test's name plus its own name, joined with a forward slash. Subtests inside subtests extend the path further. This is what shows up in go test -v output and what -run matches against.
The hierarchy is the test name. A failure in the deepest level prints the full path, so you can see exactly which case broke without reading line numbers. This matters more than it sounds: in a table with 30 entries, "failed at index 17" is useless, but "failed at TestCartTotal/three_items/with_tax" tells you the bug at a glance.
The -run flag takes a regular expression that's matched against test names. For subtests, the pattern matches against the slash-separated path, and slashes in the pattern are matched literally.
The runner walks the test tree, and for each level it checks the corresponding segment of the -run pattern. A parent only runs if its segment matches, and a child only runs if both its segment and all ancestor segments match. This is why you can scope -run to a single failing case while iterating on a fix, then drop the filter once it's green.
Two practical notes. Spaces in subtest names become underscores on the command line, so -run TestCartTotal/empty_cart matches a subtest named "empty cart". And the pattern is a regex, so dots, parens, and pipes need escaping if you want them literal.
Table-driven tests are a loop over a slice of cases. Adding t.Run to that loop is a small change with a big payoff: every case gets its own line in the output, its own filter target, and its own scope. This is the canonical Go test pattern in practice.
Here's a discount calculation table converted to use subtests. The structure is the same as a bare table; the loop body is wrapped in t.Run.
Six cases, six lines, six targets for -run. If "full_discount" fails, you can iterate on it with go test -v -run TestCouponApply/full_discount until it passes, then drop the filter. The flat-table version (loop body without t.Run) gives you one line for the whole table, and a failure has to encode the case index or label in its message.
The name field in the case struct exists specifically so t.Run has a label. Use short, descriptive names. The runner replaces spaces with underscores, so "ten percent off" reads cleanly both in Go source and in CLI filters.
t.Parallel() marks a test as eligible to run alongside other parallel tests. Inside a subtest, it works the same way: the subtest pauses, the parent test runs the rest of its body sequentially, and the parent's subtests with t.Parallel then run concurrently with each other once the parent function returns.
The total wall time is roughly 50ms, not 200ms, because the four subtests run concurrently. With -parallel N (default GOMAXPROCS) you can cap how many parallel tests run at once. This is the easiest way to speed up a slow table without changing the code under test.
Cost: t.Parallel shares state between subtests by definition. If your code uses a shared map, a singleton, or any package-level variable, parallel subtests will race. Run with -race to catch this early.
The parent test waits for all parallel subtests to finish before reporting its own pass/fail. So TestOrderStatusTransitions doesn't return until every subtest is done, which is the behavior you want for grouped reporting.
Before Go 1.22, the most common bug with parallel subtests was sharing the loop variable. The tc variable in a for _, tc := range cases loop was a single variable that the loop reassigned on each iteration. When subtests captured it in a closure and ran in parallel, every subtest saw the value of tc at whatever point the loop happened to be at when the goroutine ran, which was usually the last case.
In Go 1.22+, each iteration of a for loop gets its own copy of the loop variables. The bug is gone for code compiled with Go 1.22 or later. If your go.mod declares go 1.22 or higher (which it should for any new project), parallel subtests over a loop are safe by default.
For code that still has to compile under Go 1.21 or earlier, the fix is to shadow the variable inside the loop body so each iteration's closure captures its own copy:
The tc := tc line looks redundant but is the classic Go workaround. It declares a new variable named tc inside the loop body, initialized from the outer loop variable. The closure captures this inner tc, which never changes after that iteration. The Go 1.22 change essentially does this shadow for you, every time, automatically.
t.Cleanup(func) registers a function to run when the current test (or subtest) finishes. Cleanups run in last-in, first-out order, after the test function returns but before the runner reports the result. This is the idiomatic way to release resources opened during a test: temp files, database connections, HTTP servers, mocked dependencies.
The os.Remove call runs whether the test passes, fails, or calls t.Fatal. Compare this to a plain defer, which also runs on early return but lives next to the resource open. Both work; t.Cleanup is preferred when the resource is set up inside a helper function or when you want all cleanups to run as a stack from the test's perspective rather than each helper's.
A subtest's cleanups run when the subtest ends, not when the parent ends. So a temp file opened inside t.Run("case A", ...) is removed before t.Run("case B", ...) starts. This is exactly the isolation you want for table-driven tests: each case sets up its own state, exercises the code, and tears down before the next case runs.
Cleanups are LIFO because each one usually undoes a setup, and setups have to be undone in reverse order. If you open a database, then a transaction, then a session, you want to close the session, then roll back the transaction, then close the database. Registering each cleanup right after the matching setup gives you that order automatically.
Cost: Cleanups inside a parallel subtest run when that subtest finishes, which may overlap with other parallel subtests. If two cleanups touch the same global resource without locking, they'll race. Either keep cleanups subtest-local or guard shared state with a mutex.
Subtests can nest. A t.Run inside a t.Run is fine, and the nested subtest's name extends the path with another slash. This is useful for grouping cases by feature within a larger test, like all "valid transitions" vs all "invalid transitions" for an order state machine.
Now -run TestOrderStatus/valid_transitions runs the two valid cases and skips the invalid ones. The nesting depth has no hard limit, but two or three levels is usually enough. Beyond that, the names get unwieldy and the structure stops helping.
Inside a subtest, t.Fatal and t.FailNow stop only that subtest, not the parent or its siblings. This is one of the practical reasons to wrap table cases in t.Run: a fatal in one case lets the others run, so you see every failure at once instead of fixing them one at a time.
t.Error and t.Errorf log a failure and let the subtest keep running, which is fine when later assertions are still useful. t.Fatal and t.Fatalf log and stop, which is the right call when later code would panic or produce a misleading second error (a nil pointer dereference, a divide by zero, an index out of range).
A common mistake is calling t.Fatal outside a subtest when you intended it to scope to the case. At the top level of a parent test, t.Fatal stops every subtest that hasn't started yet. Inside a t.Run body, it stops only that subtest. Place Fatal calls inside t.Run when you want per-case fail-fast behavior.
10 quizzes