When a single test function gets out of hand
You have a function that parses configuration. You write a test. It passes. Then you add a case for missing keys. Then one for invalid types. Then one for edge-case whitespace. The test function grows into a wall of if statements. Running the suite takes longer because one slow case blocks the rest. You want to run just the failing case, but the output is a single blob. You need structure. You need isolation. You need t.Run.
Go's testing package provides subtests via t.Run. A subtest is a named child of a parent test. It gets its own *testing.T instance, its own lifecycle, and its own output path. Subtests let you organize cases, run independent tests in parallel, and target specific failures without touching the code.
The subtest model
Think of a test function as a directory. t.Run creates a file inside that directory. You can run the whole directory, or just one file. Each file has its own workspace. If one file crashes, the others keep working. The parent test waits for all children to finish before reporting success or failure.
The signature is simple: t.Run(name, func(t *testing.T)). The name becomes part of the test path. The function receives a fresh *testing.T pointer. This pointer behaves like the parent's, but it tracks state independently. Calls to t.Error or t.Fatal inside the subtest affect only that subtest. The parent continues running other children unless the subtest calls t.FailNow.
Subtests are the standard way to structure table-driven tests in Go. The community expects tests to follow this pattern. It makes output readable and execution efficient.
Table-driven tests
The most common use of t.Run is the table-driven test. You define a slice of structs containing inputs, expected outputs, and a name. You loop over the slice. For each entry, you call t.Run with the entry's name and a function that runs the check.
Here's the table-driven pattern: define cases in a slice, loop over them, and spawn a subtest for each entry.
func TestDouble(t *testing.T) {
// table of cases defines inputs and expected outputs
cases := []struct {
name string
input int
expected int
}{
{"positive", 5, 10},
{"zero", 0, 0},
{"negative", -3, -6},
}
// iterate over the table; Go 1.22+ captures loop variables correctly
for _, tc := range cases {
// t.Run creates a subtest with a unique name and a fresh *testing.T
t.Run(tc.name, func(t *testing.T) {
result := tc.input * 2
if result != tc.expected {
// t.Errorf logs the failure but allows the subtest to continue
t.Errorf("got %d, want %d", result, tc.expected)
}
})
}
}
The compiler enforces the function signature. If you pass a function with the wrong signature, you get cannot use func literal (value of type func()) as func(t *testing.T) value in argument to t.Run. The type system catches this immediately.
Convention dictates naming the loop variable tc or tt for "test case". The receiver is always t. Subtest names should be descriptive. TestDouble/positive tells you exactly what failed. TestDouble/1 forces you to look at the code to understand the failure.
Subtests are your test suite's table of contents.
Isolation and parallelism
Subtests enable parallel execution. When you call t.Parallel() inside a subtest, the test runner schedules that subtest to run concurrently with other parallel subtests. This speeds up the suite significantly when tests involve I/O or heavy computation.
Parallelism requires isolation. Each parallel subtest must not share mutable state with others. If two parallel subtests write to the same variable, the race detector will flag a data race. The race detector runs when you pass the -race flag to go test. It instruments the binary to track memory access. If you share state without synchronization, the detector halts the test and prints a stack trace.
Here's a subtest that allocates a resource, runs in parallel, and guarantees cleanup.
func TestTempFile(t *testing.T) {
// setup shared state if needed, though subtests should be independent
t.Run("create and read", func(t *testing.T) {
// t.Parallel marks this subtest as safe to run concurrently with others
t.Parallel()
// create a temporary file for the test
f, err := os.CreateTemp("", "test-*.txt")
if err != nil {
// t.Fatal stops this subtest immediately and marks it failed
t.Fatal(err)
}
// t.Cleanup registers a function that runs after the subtest finishes
t.Cleanup(func() {
// remove the file regardless of test outcome
os.Remove(f.Name())
})
// write and read data
f.WriteString("hello")
f.Seek(0, 0)
data, _ := io.ReadAll(f)
if string(data) != "hello" {
t.Errorf("read %q, want %q", data, "hello")
}
})
}
t.Cleanup is essential for parallel tests. It registers a function that runs after the subtest completes, even if the test fails or panics. Cleanup functions run in reverse order of registration. This ensures resources are released deterministically. You can use t.Cleanup to close files, stop servers, or delete temporary directories.
Isolation is the price of parallelism.
Cleanup and resources
Real tests often allocate resources. Subtests make cleanup manageable. Each subtest can register its own cleanup functions. The parent test can also register cleanup that runs after all children finish. This hierarchy matches the resource lifecycle.
If a subtest creates a temporary directory, the cleanup function should delete it. If a subtest starts a mock server, the cleanup function should stop it. The cleanup function receives no arguments. It captures variables from the closure.
t.Run("server lifecycle", func(t *testing.T) {
// start a mock server for this subtest
srv := startMockServer(t)
// cleanup stops the server after the test
t.Cleanup(func() {
srv.Stop()
})
// test logic uses the server
resp, err := http.Get(srv.URL + "/health")
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusOK {
t.Errorf("status %d, want 200", resp.StatusCode)
}
})
Convention: Use t.Cleanup instead of defer for test cleanup. defer runs when the function returns. t.Cleanup runs after the test framework finishes processing the subtest. This timing difference matters when the test framework needs to report results or run parent cleanup. t.Cleanup integrates with the test runner. defer does not.
Cleanup runs. Trust it.
Pitfalls and compiler behavior
Subtests introduce a few pitfalls. The most common is loop variable capture. Before Go 1.22, the loop variable in a for loop was shared across iterations. If you captured the loop variable in a subtest function, all subtests would see the final value. The compiler now rejects this pattern with loop variable captured by func literal in Go 1.22 and later. The fix is automatic in modern Go. If you are on an older version, copy the loop variable: tc := tc; t.Run(...).
Another pitfall is sharing state between subtests. Subtests run sequentially by default. They run in parallel only if you call t.Parallel(). If you add t.Parallel() to a subtest that shares mutable state with the parent or other subtests, you introduce a race. The race detector will catch this, but it's better to design tests to be independent from the start.
Subtests also affect failure reporting. If a subtest fails, the parent test fails. The parent continues running other subtests unless you call t.FailNow() or t.Fatal() inside the subtest. t.Fatal stops the subtest and marks it failed. It does not stop the parent. t.FailNow stops the entire test function, including the parent and all siblings. Use t.FailNow sparingly. It prevents other subtests from running, which can hide additional failures.
The output format is hierarchical. With -v, you see === RUN TestDouble/positive. This path lets you filter tests. go test -run TestDouble/positive runs only that subtest. This is faster than commenting out code or using build tags. The -run flag takes a regular expression. You can match multiple subtests with TestDouble/(positive|zero).
Name your subtests like a path.
Decision matrix
Use t.Run when you have multiple independent cases for the same function. Use a single test function when the setup is complex and shared, or the cases depend on each other. Use t.Parallel inside a subtest when the test does I/O or heavy computation and doesn't share mutable state. Use t.Cleanup when you allocate resources that must be released. Use t.Skip when a test depends on an external condition that isn't met. Use t.Context() when you need access to the test's cancellation context.