Skip to content
gohacks.dev
Testing

Subtests and Parallel Tests in Go Without Surprises

Learn t.Run and t.Parallel in Go by experiment: speedups, when parallel subtests start, the loop variable rule, data races and t.Setenv panics.

13 min read
Summarize in AI
Flat illustration of a test report with one failing test.

Subtests and parallel execution are two of the features that turned Go's tiny testing package into something capable of running large suites. They are easy to start using and have rules that are easy to break without noticing: when does a parallel test actually start, what does a closure capture, what happens to shared state, and which helpers refuse to work in parallel. The bugs they produce are the flaky kind, which pass on your laptop and fail one run in twenty on CI.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article learns the rules by experiment. One test file exercises each behaviour and prints what it saw, so every claim below comes from a run. It builds on your first test and table-driven tests, and the concurrency rules are those of ordinary Go (goroutines, the race detector). It ran on Go 1.25.6.

Subtests with t.Run

t.Run(name, func(t *testing.T) {...}) runs a function as a separate test with its own pass/fail status. The function receives its own *testing.T, so t.Fatal inside it ends only that subtest, t.Cleanup registered inside it runs when it ends, and the output shows it as an indented child of the parent. Subtests can nest. Names matter: they are what you put in -run, as TestX/subname, and what appears in failure output. Spaces become underscores, and two subtests with the same name get a #01 suffix.

The three practical benefits are the ones that matter: you can run one case, a failing case does not stop the others, and the report says exactly which one failed. The earlier article on table-driven tests uses this for every row.

The experiment

Five tests in one file, each probing one behaviour.

main.goGo
package main

func main() {}
sub_test.goGo
package main

import (
	"sort"
	"sync"
	"sync/atomic"
	"testing"
	"time"
)

var names = []string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel"}

// A group of subtests that each take 50 ms. Serial subtests add up; parallel ones overlap.
func elapsed(t *testing.T, parallel bool) time.Duration {
	start := time.Now()
	t.Run("group", func(t *testing.T) {
		for _, name := range names {
			t.Run(name, func(t *testing.T) {
				if parallel {
					t.Parallel()
				}
				time.Sleep(50 * time.Millisecond)
			})
		}
	}) // t.Run returns only after every parallel subtest inside the group has finished
	return time.Since(start)
}

func TestTiming(t *testing.T) {
	serial, par := elapsed(t, false), elapsed(t, true)
	t.Logf("8 subtests of 50ms: serial took at least 400ms: %v, parallel took under 300ms: %v", serial >= 400*time.Millisecond, par < 300*time.Millisecond)
}

// Parallel subtests are paused until the function that started them has returned.
func TestOrder(t *testing.T) {
	var parentDone, ranBefore atomic.Bool
	for _, name := range names[:3] {
		t.Run(name, func(t *testing.T) {
			t.Parallel()
			if !parentDone.Load() {
				ranBefore.Store(true)
			}
		})
	}
	parentDone.Store(true) // runs before any of the parallel subtests above get going
	t.Cleanup(func() { t.Logf("any parallel subtest started before the parent body finished: %v", ranBefore.Load()) })
}

// Since Go 1.22 each loop iteration has its own variable, so closures in subtests see their own name.
func TestLoopVariable(t *testing.T) {
	var mu sync.Mutex
	seen := map[string]bool{}
	t.Run("group", func(t *testing.T) {
		for _, name := range names {
			t.Run(name, func(t *testing.T) {
				t.Parallel()
				mu.Lock()
				seen[name] = true
				mu.Unlock()
			})
		}
	})
	var got []string
	for n := range seen {
		got = append(got, n)
	}
	sort.Strings(got)
	t.Logf("distinct names seen by the subtests: %d of %d", len(got), len(names))
}

// Shared state without a lock, written from parallel subtests: the race detector reports it.
func TestSharedCounter(t *testing.T) {
	total := 0
	t.Run("group", func(t *testing.T) {
		for i := 0; i < 4; i++ {
			t.Run(names[i], func(t *testing.T) {
				t.Parallel()
				for j := 0; j < 1000; j++ {
					total++
				}
			})
		}
	})
	t.Logf("total = %d (would be 4000 if the increments were safe)", total)
}

func TestSetenvInParallel(t *testing.T) {
	t.Parallel()
	t.Setenv("MODE", "test") // not allowed once the test is parallel
}
Shell
strip() { sed -E 's/[[:space:]]+[0-9.]+s$//; s/ \([0-9.]+s\)//'; }
echo '$ go test -v -run "TestTiming|TestOrder|TestLoopVariable"'
go test -count=1 -v -run 'TestTiming|TestOrder|TestLoopVariable' . 2>&1 | grep -E '^(---|    sub_test|ok|FAIL|PASS)' | grep -v 'PASS: Test.*/' | strip
echo '--- the loop variable rule depends on the go version in go.mod'
sed -i.bak 's/^go .*/go 1.21/' go.mod
go test -count=1 -v -run TestLoopVariable . 2>&1 | grep 'distinct names' | strip
mv go.mod.bak go.mod
echo '--- shared counter under the race detector'
go test -count=1 -race -run TestSharedCounter . 2>&1 | grep -E 'WARNING: DATA RACE|^(FAIL|ok)|sub_test.go:[0-9]+: total' | sort -u | strip
echo '--- t.Setenv in a parallel test'
go test -count=1 -run TestSetenvInParallel . 2>&1 | grep -m1 'panic:' | strip
Text
$ go test -v -run "TestTiming|TestOrder|TestLoopVariable"
    sub_test.go:31: 8 subtests of 50ms: serial took at least 400ms: true, parallel took under 300ms: true
--- PASS: TestTiming
    sub_test.go:46: any parallel subtest started before the parent body finished: false
--- PASS: TestOrder
    sub_test.go:68: distinct names seen by the subtests: 8 of 8
--- PASS: TestLoopVariable
PASS
ok  	example.test/lab
--- the loop variable rule depends on the go version in go.mod
    sub_test.go:68: distinct names seen by the subtests: 1 of 8
--- shared counter under the race detector
    sub_test.go:84: total = 3000 (would be 4000 if the increments were safe)
FAIL
FAIL	example.test/lab
WARNING: DATA RACE
--- t.Setenv in a parallel test
panic: testing: test using t.Setenv or t.Chdir can not use t.Parallel [recovered, repanicked]

Parallel subtests are faster

TestTiming builds a group of eight subtests, each sleeping 50 ms, and runs the group twice: once serially, once with t.Parallel() at the top of each subtest. The log line from the run: serial took at least 400 ms, parallel took under 300 ms. The sleep stands in for I/O (a database call, an HTTP request) where parallelism helps; for CPU-bound tests the gain is limited by the number of cores.

The mechanics are worth knowing. t.Parallel() signals that this test may run in parallel with other parallel tests. Calling it pauses the test; it resumes when the sequential portion of its parent finishes. A t.Run call for a group whose subtests are parallel does not return until all of them have finished. That last property is what lets the timing test measure the group: the code after t.Run("group", ...) runs when the whole group is done. Without the wrapping group, parallel subtests of a top-level test are released only after that top-level test function returns, so code after the loop cannot wait for them directly. If you need cleanup after all subtests, use t.Cleanup on the parent, which runs after its subtests finish.

The number of tests that run at once is limited by -parallel N, which defaults to GOMAXPROCS. Separately, -p N limits how many packages' test binaries run at once. You can raise -parallel for I/O-bound suites (-parallel 64) and lower it when tests contend for something scarce.

When do parallel subtests start?

TestOrder starts three parallel subtests in a loop and sets a flag after the loop. Each subtest, when it finally runs, records whether the flag was still unset. The cleanup logged false: no parallel subtest had started before the parent's body finished. This is the rule from the documentation, confirmed.

The consequence catches people. Anything the parent does after starting the subtests, including defer statements and variable changes, is visible to them. A parent that creates a temporary resource, starts parallel subtests and then closes the resource with defer will close it before the subtests run, since defer runs when the parent function returns, which is when the subtests are released. Use t.Cleanup for resources the subtests share, because cleanups registered on the parent wait for the subtests.

The loop variable

Before Go 1.22, for _, name := range names reused one variable for all iterations. A closure that captured name and ran later, which is every parallel subtest, saw whatever value the variable had at the end: the last name. The bug looked like "all the subtests run the last case". The standard workaround was a line name := name at the top of the loop body.

Go 1.22 changed the language: each iteration has its own variable. TestLoopVariable records the name each parallel subtest sees. With go 1.25 in go.mod, it saw 8 distinct names of 8. The script then edits go.mod to go 1.21 and reruns the identical code: 1 of 8. The change is keyed on the go line of the module, not on your toolchain, so a module that still says go 1.21 keeps the old semantics even when built with Go 1.25. If you maintain old modules, either update the line or keep copying the variable. The language change is described in the Go blog post on fixing for loops.

Shared state and the race detector

TestSharedCounter starts four parallel subtests that each increment a plain int a thousand times. Without synchronisation, increments are lost: the test logged a total of 3000, not 4000, and the run with -race printed WARNING: DATA RACE and failed. The number you get varies from run to run; the race detector is the dependable signal, not the lost count.

This is the commonest source of flaky parallel tests. The rules:

  • Read-only shared data is fine. A table of inputs, a parsed template, a fixture loaded once.
  • Anything mutable needs its own copy per subtest, or a lock. Create it inside the subtest function.
  • Shared external resources need isolation. A database table, a file path, a port, an environment variable. Use unique names per test (t.TempDir(), a schema per test, port 0) as in integration tests.
  • Run the suite with -race in CI, always. It turns an intermittent failure into a deterministic report.

Helpers that refuse parallel tests

Some operations change process-wide state, so they cannot be used in parallel tests. t.Setenv sets an environment variable for the duration of the test and restores it; t.Chdir (added in Go 1.24) changes the working directory. Both would affect every other test running at the same time. The last block of the output shows the guard: calling t.Setenv after t.Parallel() panics with testing: test using t.Setenv or t.Chdir can not use t.Parallel. The panic is the helpful behaviour; silently racing on the environment would be worse. If code reads environment variables, give it an explicit parameter or a small config struct for the test to fill in, so tests do not need to touch the real environment at all (testing code that reads the environment).

Cleanup order

t.Cleanup(f) registers a function to run when the test and all its subtests complete, in last-in-first-out order. Use it instead of defer whenever subtests may be parallel, as explained above. It is also how helpers register their own teardown without the caller needing to remember (cleanup and temp dirs).

When not to parallelise

Parallel tests are an optimisation with a cost in debugging. Skip them when:

  • The tests are already fast. A suite that takes 200 ms gains nothing from t.Parallel, and every call is a chance to introduce a race.
  • The tests share a resource that cannot be partitioned cheaply, such as a single database with global state.
  • The code under test uses package-level mutable state. Parallelism will expose it, which is sometimes a reason to fix the code.
  • CPU-bound tests already saturate the cores.

Add t.Parallel() where tests wait on I/O or sleep, where they are independent by construction, and where the suite is slow enough to notice.

Per-subtest fixtures

The usual reason a table of parallel subtests goes wrong is a shared fixture. The cure is to make creating the fixture cheap and call it inside the subtest:

Go
func newStore(t *testing.T) *Store {
	t.Helper()
	s := NewStore(t.TempDir())          // a directory of its own
	t.Cleanup(func() { s.Close() })    // closed when this subtest finishes
	return s
}

for _, tc := range tests {
	t.Run(tc.name, func(t *testing.T) {
		t.Parallel()
		s := newStore(t)               // built here, not in the parent
		// ... exercise s with tc.input
	})
}

t.TempDir() returns a directory unique to the test that is removed automatically, so two parallel subtests never share files. The same pattern gives each subtest its own in-memory database, its own httptest server and its own fake clock. When building the fixture is expensive (a container, a schema), build it once in the parent and give each subtest a partition of it (a unique schema, a unique key prefix), as in integration tests against a real database.

Benchmarks have sub-benchmarks too

b.Run works like t.Run for benchmarks and is how you compare variants in one function: b.Run("small", ...), b.Run("large", ...). Benchmarks do not use t.Parallel; instead b.RunParallel runs the body from many goroutines to measure contended code, as the handler benchmark does. The naming rules and the -bench regular expression follow the same slash convention.

Diagnosing a flaky parallel test

When a parallel test fails one run in twenty, work through it in this order.

  1. Run it under the race detector, many times: go test -race -count=50 -run TestName ./pkg. Most flakes are races, and the detector names the two lines.
  2. Shuffle: -shuffle=on -count=20 exposes order dependence between tests.
  3. Reduce parallelism to 1 (-parallel 1) and see whether the failure disappears. If it does, two tests share something.
  4. Look for shared resources: package-level variables written by tests, fixed file names, fixed ports, the environment, the current directory, a global logger, the time.Now function replaced for one test.
  5. Check for goroutines that outlive the test and write to something the next test uses. defer goleak.VerifyNone(t) (from the goleak package) finds them (finding goroutine leaks).
  6. Print what the failing run saw, with t.Logf, including the subtest name and any generated identifiers, so the next failure carries its own evidence.

Most flakes turn out to be one of three things: a loop variable captured wrongly (old module versions), a shared mutable fixture, or a time-based assumption. All three are visible in this article's experiments, which is the reason to run them on your own machine and watch the numbers change.

Subtests and skipping

A subtest can skip itself with t.Skip, which marks it skipped without failing the parent, and a parent can skip all its subtests by calling t.Skip before starting them. Use skips for conditions that really are environmental (a missing tool, a short run with -short), always with a message that says why, and never as a way of silencing a failing test: a skipped test is invisible in a green run, and the suite quietly stops covering what it used to.

Reading failure output from subtests

When a parallel subtest fails, the output groups lines by subtest, but log lines from different subtests can interleave in -v output because they run at the same time. Each line is prefixed with the file and line, and the --- FAIL summary at the end names every failing subtest in a tree. If you need clean logs for one case, rerun just that case with -run 'TestX/name'; without its siblings running beside it, the output is sequential and the failure is easier to read. For tests that print a lot, prefer t.Logf over fmt.Println: t.Logf output is attributed to the right test and hidden unless the test fails or -v is set.

Selecting and shuffling

Use -run 'TestX/name' to run one subtest (each side of the slash is an anchored-ish regular expression matched against that level's name; see running a single test). Use -shuffle=on to randomise test order; combined with -count=10 it finds tests that depend on running after another. Use -failfast to stop at the first failure when iterating. In CI, run the full suite with -race -count=1, and consider a nightly job with -shuffle=on -count=5.

A checklist for parallel subtests

  1. Call t.Parallel() first thing in the subtest function.
  2. Build every mutable input inside the subtest.
  3. Do not capture variables that the parent modifies after the loop.
  4. Use t.Cleanup, not defer, for shared resources in the parent.
  5. Avoid t.Setenv and t.Chdir; pass configuration explicitly.
  6. Run with -race.
  7. Keep module go lines current so the loop variable rule is the modern one.

The behaviours are specified in the testing package documentation for T.Parallel and T.Run, and the flags in the go test command reference.

Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.