Parallel Tests
You already know t.Run and table-driven tests from
01-standard-library/01-testing, and mutexes from Part 4. This lesson
puts them together: running subtests concurrently with t.Parallel(),
and why that demands concurrency-safe code underneath.
What t.Parallel() actually does
Calling t.Parallel() inside a subtest doesn't start it running
concurrently right away. It does something more specific: it pauses the
current subtest, signals to the test runner that this subtest is ready to
run in parallel, and immediately returns control to the parent — which
moves on to launch the next subtest. Only once every subtest in the group
has reached its t.Parallel() call (or the parent's loop finishes) does
go test actually run them all together.
func TestWorkers(t *testing.T) {
for i := 0; i < 8; i++ {
i := i
t.Run(fmt.Sprintf("worker-%d", i), func(t *testing.T) {
t.Parallel()
doWork(i)
})
}
}
That's why t.Parallel() is normally the first line of a parallel
subtest: everything after it runs concurrently with its siblings, but
everything before it runs sequentially during the "registration" pass.
When it's worth using
Parallel subtests pay off when cases are independent — no case depends on
another having already run — and when each case does something that
would otherwise leave the CPU idle: file I/O, a fake network call, a
time.Sleep, anything with real wall-clock cost. Running eight such
cases serially adds up; running them together the suite takes roughly as
long as the slowest one. For quick, pure computations, the bookkeeping
t.Parallel() adds isn't worth it — you're not actually blocked on
anything.
Shared-state hazards
The catch is exactly what you'd expect from Part 4: concurrent code sharing mutable state needs to coordinate. Two hazards show up constantly in parallel tests specifically:
A captured loop variable. Before Go 1.22, writing t.Run(name, func(t *testing.T) { t.Parallel(); use(i) }) inside a for i := range loop
captured the same i across every closure, so parallel subtests would
race on it (or all silently use the final value). If your module still
targets pre-1.22 semantics, shadow it explicitly with i := i inside the
loop body before the t.Run call, as in the example above. It's a hazard
worth knowing about even where the compiler already saves you.
A shared object under test. More often, several parallel subtests call methods on one instance you constructed before the group — a cache, a counter, a registry. If that type isn't safe for concurrent use, you don't get a wrong answer; on a Go map you get a hard crash, "fatal error: concurrent map writes", which kills the whole test binary rather than just failing one test.
The parent waits — and that's your assertion point
An outer t.Run that only launches parallel children does not return
until every one of them has finished. That guarantee is what lets you
write the natural assertion: launch a group of parallel subtests inside
one t.Run("group", ...), and once that call returns, check the shared
object's final state.
t.Run("group", func(t *testing.T) {
for i := 0; i < workers; i++ {
t.Run(fmt.Sprintf("worker-%d", i), func(t *testing.T) {
t.Parallel()
registry.Record("hit")
})
}
})
// Safe here: every parallel worker above has completed.
if got := registry.Count("hit"); got != want {
t.Errorf("Count(hit) = %d, want %d", got, want)
}
Designing for it: go test -race
The real fix for shared-state hazards isn't in the test — it's in the type being tested. Any type parallel subtests will touch concurrently needs a mutex guarding every access to its mutable fields, including reads: a read racing a write on a plain map is just as fatal as two writes racing each other, because the Go runtime's race detector — and sometimes the map implementation itself — doesn't distinguish "just reading" as safe.
Locally, run your suite with go test -race whenever you add
t.Parallel(). The race detector instruments memory accesses and flags
unsynchronized concurrent access even when it doesn't happen to crash or
produce a wrong number on a given run — which matters, because a data
race that doesn't panic today can still corrupt state or panic tomorrow.
Get in the habit now.
Practice
1. A Registry That Survives Parallel Tests
Intermediatet.Parallel()runs subtests concurrently — which makes tests a race detector of last resort: code that "worked" single-threaded suddenly loses updates or panics with "concurrent map writes".Make
Registrycorrect under that pressure:NewRegistry() *Registry— the map must be initialized (nil maps panic on write).Record(name string)— add one to name's count. Lock first.Count(name string) int— a name's current count. Reads need the lock too: a concurrent read+write on a map is just as fatal.Total() int— sum of all counts.
The struct already carries
mu sync.Mutex. The hidden test hammers Record from 8 parallel subtests × 100 calls and then expects exactly 800.package main import ( "fmt" "sync" ) // Registry counts named events and is safe for concurrent use — every // method takes the mutex before touching the map. type Registry struct { mu sync.Mutex counts map[string]int } // NewRegistry returns a ready-to-use Registry. func NewRegistry() *Registry { // TODO: initialize counts — writing to a nil map panics return &Registry{} } // Record adds one to name's count. func (r *Registry) Record(name string) { // TODO: lock, increment, unlock — defer r.mu.Unlock() is the idiom } // Count returns name's current count. func (r *Registry) Count(name string) int { // TODO: reads race with writes too — lock here as well return 0 } // Total returns the sum of all counts. func (r *Registry) Total() int { // TODO: reads race with writes too — lock here as well return 0 } func main() { r := NewRegistry() r.Record("visit") r.Record("visit") fmt.Println("visits:", r.Count("visit"), "total:", r.Total()) }Sign in to write and run your solution in the browser.
Sign in for hints and the solution.