Parallel Tests — Junior¶
This page introduces t.Parallel from the ground up. By the end of it you should be able to: explain what t.Parallel() does, write a parallel test, write parallel table-driven subtests safely, recognise the pre-Go 1.22 loop-variable capture bug, control the parallelism with the -parallel flag, and identify the most common categories of tests that should not be parallel.
1. The default: tests run serially¶
When you write your first test, you do not need to know anything about parallelism. The go test tool compiles every _test.go in the package and runs each TestXxx(t *testing.T) function one after another, in the order they appear in the source. Sub-tests created via t.Run("name", func(t *testing.T) { ... }) also run serially relative to one another by default.
package mathutil
import "testing"
func Square(n int) int { return n * n }
func TestSquare(t *testing.T) {
if got := Square(3); got != 9 {
t.Fatalf("Square(3) = %d, want 9", got)
}
}
func TestCube(t *testing.T) {
cases := []struct {
in, want int
}{
{2, 8},
{3, 27},
}
for _, tc := range cases {
if got := tc.in * tc.in * tc.in; got != tc.want {
t.Errorf("Cube(%d) = %d, want %d", tc.in, got, tc.want)
}
}
}
Running go test -v prints:
=== RUN TestSquare
--- PASS: TestSquare (0.00s)
=== RUN TestCube
--- PASS: TestCube (0.00s)
PASS
ok example/mathutil 0.003s
Two tests, one after the other. Nothing parallel about this.
2. What t.Parallel() does¶
t.Parallel() is a method on *testing.T. Calling it tells the framework: "I am safe to run concurrently with other tests that also call t.Parallel()." Internally, the framework pauses the goroutine for the current test, finishes running any serial tests in the same level, then resumes all parallel tests together (up to a configured maximum).
package mathutil
import "testing"
func TestSquareParallel(t *testing.T) {
t.Parallel()
if got := Square(5); got != 25 {
t.Fatalf("Square(5) = %d, want 25", got)
}
}
func TestCubeParallel(t *testing.T) {
t.Parallel()
if got := 4 * 4 * 4; got != 64 {
t.Fatalf("got %d, want 64", got)
}
}
With go test -v:
=== RUN TestSquareParallel
=== PAUSE TestSquareParallel
=== RUN TestCubeParallel
=== PAUSE TestCubeParallel
=== CONT TestSquareParallel
=== CONT TestCubeParallel
--- PASS: TestCubeParallel (0.00s)
--- PASS: TestSquareParallel (0.00s)
PASS
The === PAUSE line marks where t.Parallel() was called; === CONT is where the framework resumed the test. Both tests then ran concurrently. The order they print is non-deterministic — never rely on it.
3. What "concurrent" means here¶
Go's test framework starts a goroutine per parallel test. The number of parallel tests allowed to run at the same time is capped by the -parallel flag (default: GOMAXPROCS, typically the number of CPU cores). The Go runtime then multiplexes those goroutines onto the available OS threads.
On a 4-core machine with -parallel 4, four tests run truly in parallel; on -parallel 1, parallel-marked tests still get the === PAUSE / === CONT dance but execute one at a time.
go test -v -parallel 1 ./mathutil # one at a time
go test -v -parallel 4 ./mathutil # up to 4 at once
go test -v -parallel 16 ./mathutil # up to 16, may exceed cores
4. Why bother?¶
A serial 60-second suite often becomes a 6-second parallel suite when most tests are short and CPU-light. The speed-up is the most visible reason, but the real point is that parallel tests amplify race conditions and let -race catch them in CI rather than in production. A test suite that runs parallel-by-default is a passive correctness check on the codebase's thread safety.
5. Subtests with t.Run¶
t.Run creates a subtest. It is most useful for table-driven tests, where a single TestParse function holds many cases:
package mathutil
import (
"strconv"
"testing"
)
func TestParseSerial(t *testing.T) {
cases := []struct {
name string
in string
want int
}{
{"one", "1", 1},
{"two", "2", 2},
{"three", "3", 3},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := strconv.Atoi(tc.in)
if err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Errorf("Atoi(%q) = %d, want %d", tc.in, got, tc.want)
}
})
}
}
Verbose output shows nested subtests:
=== RUN TestParseSerial
=== RUN TestParseSerial/one
=== RUN TestParseSerial/two
=== RUN TestParseSerial/three
--- PASS: TestParseSerial (0.00s)
--- PASS: TestParseSerial/one (0.00s)
--- PASS: TestParseSerial/two (0.00s)
--- PASS: TestParseSerial/three (0.00s)
All three subtests ran serially inside TestParseSerial. The whole thing runs serially relative to other top-level tests.
6. Parallel subtests¶
To run the subtests in parallel, put t.Parallel() inside the subtest closure:
func TestParseParallel(t *testing.T) {
cases := []struct {
name string
in string
want int
}{
{"one", "1", 1},
{"two", "2", 2},
{"three", "3", 3},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := strconv.Atoi(tc.in)
if err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Errorf("Atoi(%q) = %d, want %d", tc.in, got, tc.want)
}
})
}
}
Each subtest's goroutine pauses at t.Parallel(), the loop finishes registering all three, then they all run concurrently.
You can also call t.Parallel() on the outer TestParseParallel. That makes the whole table-driven test run in parallel with other top-level parallel tests, while its subtests are serial (unless they also call t.Parallel()):
func TestParseParallel(t *testing.T) {
t.Parallel() // outer test parallel
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// subtests serial inside this test
})
}
}
Both levels can be parallel: outer + inner. The combinatorics let you tune the concurrency.
7. The pre-Go 1.22 loop-variable trap¶
This is the single most famous gotcha in Go testing. On Go 1.21 and earlier, for _, tc := range cases { ... } reused one storage slot for tc across all iterations. A goroutine created inside the loop that referred to tc saw whatever value tc had when the goroutine ran, not when the goroutine was created.
For a parallel subtest, this is a disaster: each subtest's goroutine pauses, the loop finishes (advancing tc to its last value), and then all paused goroutines resume — every one reads the final tc.
The fix on Go ≤1.21 was to "shadow" the variable inside the loop body:
for _, tc := range cases {
tc := tc // re-declare, gives each iteration its own tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// tc now safely refers to this iteration's value
})
}
On Go 1.22+, the language was changed: each iteration of a for loop creates a fresh tc, and the manual shadow is unnecessary (but harmless). You may still see the shadow in older code and on projects that pin to Go 1.21 or earlier. go vet's loopclosure check warns about the pre-1.22 form when running on older Go.
Concrete demonstration of the bug (run on Go 1.21):
// On Go 1.21:
for _, tc := range []string{"a", "b", "c"} {
t.Run(tc, func(t *testing.T) {
t.Parallel()
t.Logf("tc=%s", tc)
})
}
// All three subtests log "tc=c" because the loop completed before they resumed.
On Go 1.22+:
This single language change deleted an entire category of test bug.
8. The -parallel flag¶
-parallel N limits how many t.Parallel-marked tests run concurrently within one test binary. The default is runtime.GOMAXPROCS(0) of the test binary at startup. Common values:
go test -parallel 1 ./... # disable parallelism: useful for debugging
go test -parallel 4 ./... # 4 at a time
go test -parallel 16 ./... # 16 at a time
Going above GOMAXPROCS makes sense only for I/O-bound tests (DB queries, HTTP calls, file reads) — extra goroutines can do useful work while peers wait on the kernel. For CPU-bound tests, going above the core count rarely helps.
Independent of -parallel, the go test driver itself runs multiple packages in parallel, controlled by -p. So go test -p 4 -parallel 8 ./... runs up to 4 test binaries concurrently, each running up to 8 parallel tests, for a peak of 32 concurrent *testing.T instances.
9. The race detector: -race¶
Race conditions are bugs where two goroutines access the same memory without proper synchronisation, and at least one of the accesses is a write. They are notoriously hard to reproduce because they depend on the scheduler.
The race detector instruments memory accesses at compile time and reports violations at runtime. Combine it with -parallel to surface bugs:
It costs about 5–10x in runtime and ~10x in memory, so it's typical to run a regular suite and a -race suite as separate CI jobs.
Example of what -race catches:
var counter int
func TestRace(t *testing.T) {
for i := 0; i < 4; i++ {
i := i
t.Run(fmt.Sprint(i), func(t *testing.T) {
t.Parallel()
counter++ // race
})
}
}
Output with -race:
==================
WARNING: DATA RACE
Read at 0x... by goroutine 12:
pkg.TestRace.func1()
file.go:9
Previous write at 0x... by goroutine 13:
pkg.TestRace.func1()
file.go:9
==================
The detector points at the exact line. Fix it by removing the parallelism, scoping counter to the test function, or using atomic.AddInt64.
10. What is NOT safe to do in a parallel test?¶
The four classics:
- Mutate process-global state: package-level
var x int, singletons, registries. Two parallel tests overwriting each other's value is a race. - Change environment variables:
os.Setenvaffects the whole process. The framework forbidst.Setenvaftert.Parallelfor this reason. - Change working directory:
os.Chdiris process-global. Two parallel tests changing it race. On Go 1.24+,t.Chdirpanics ift.Parallelwas called. - Touch the same file or port: Two parallel tests writing to
/tmp/output.txtcollide. Uset.TempDir()for unique paths; use port:0to let the OS pick a free port.
When in doubt: ask "what outside my test function does my test touch?" Anything outside the function body is shared with siblings.
11. Safe isolation primitives¶
The two go-to primitives for isolating parallel tests are t.TempDir and t.Setenv (the latter only when not in a parallel test).
func TestWriteFile(t *testing.T) {
t.Parallel()
dir := t.TempDir() // unique per test, cleaned up automatically
path := filepath.Join(dir, "out.txt")
if err := os.WriteFile(path, []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(data) != "hi" {
t.Errorf("got %q, want %q", data, "hi")
}
}
t.TempDir():
- Creates a fresh directory under
os.TempDir()(typically/tmp/...). - Registers a cleanup that removes the directory after the test.
- Returns a different path every time, so 1000 parallel tests don't collide.
For environment variables, t.Setenv exists specifically to forbid parallelism — it mutates a process-global table, so it cannot be safe with t.Parallel. If you need a "per-test env", thread it through a Config struct instead.
12. Cleanup: t.Cleanup¶
When a parallel test ends, the work it did may need teardown. Use t.Cleanup:
func TestWithServer(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(handler)
t.Cleanup(func() { srv.Close() })
resp, err := http.Get(srv.URL + "/")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
// ...
}
t.Cleanup:
- Runs after the test (and all subtests) finish, in LIFO order.
- Runs on the test's own goroutine.
- Survives
t.FailNowandt.Skip, unlikedeferafter such calls.
Prefer t.Cleanup to defer in tests. The two look similar but t.Cleanup is the test-aware version that knows about subtests and parallelism.
13. A first "real" example¶
Putting it together. Suppose you are testing a JSON encoder that takes a struct and returns bytes.
package encoder
import (
"encoding/json"
"testing"
)
type Person struct {
Name string `json:"name"`
Age int `json:"age"`
}
func Encode(p Person) ([]byte, error) {
return json.Marshal(p)
}
func TestEncode(t *testing.T) {
cases := []struct {
name string
in Person
want string
}{
{"empty", Person{}, `{"name":"","age":0}`},
{"alice", Person{Name: "Alice", Age: 30}, `{"name":"Alice","age":30}`},
{"bob", Person{Name: "Bob", Age: 0}, `{"name":"Bob","age":0}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := Encode(tc.in)
if err != nil {
t.Fatal(err)
}
if string(got) != tc.want {
t.Errorf("Encode(%+v) = %s, want %s", tc.in, got, tc.want)
}
})
}
}
What this demonstrates:
- A table-driven test with subtests.
t.Parallel()inside each subtest, so they run concurrently.- No shared state: every iteration reads its own
tc(safe on Go 1.22+ withouttc := tc). - No environment variables, no working-directory changes, no files. Pure CPU work — perfect for parallelism.
Run with go test -v -parallel 4 ./encoder and confirm the === PAUSE / === CONT interleaving in the output.
14. When NOT to call t.Parallel¶
Quick reference:
| Test does this | Parallel? |
|---|---|
| Calls a pure function with no globals | yes |
| Reads from a const string table | yes |
Calls t.Setenv | no (forbidden) |
Calls os.Chdir | no |
Uses t.TempDir | yes (already isolated) |
Modifies a package-level var | no |
| Listens on a fixed port | no |
Listens on :0 (OS-picked port) | yes |
Calls signal.Notify | no |
Uses flag.CommandLine | no |
Uses httptest.NewServer | yes (each test gets a fresh server) |
When in doubt, default to parallel and let -race tell you when you got it wrong.
15. Common surprises¶
t.Parallelinside a test that has already calledt.Setenvpanics.- A parallel subtest's parent's
t.Runreturns before the subtest finishes. To synchronise, wrap in anothert.Run("group", ...). - The order tests print results in
-vmode is non-deterministic when parallel. Don't write integration tests that check stdout line order. t.Paralleldoes not maket.Errorf,t.Fatalf,t.Helper,t.TempDir,t.Cleanupbehave differently. The API is identical; only the schedule changes.
16. A guided exercise: convert a serial suite to parallel¶
Suppose you have this serial test file:
package calc
import "testing"
func Sum(a, b int) int { return a + b }
func Sub(a, b int) int { return a - b }
func Mul(a, b int) int { return a * b }
func TestSum(t *testing.T) {
if got := Sum(2, 3); got != 5 {
t.Errorf("Sum(2,3) = %d, want 5", got)
}
}
func TestSub(t *testing.T) {
if got := Sub(7, 4); got != 3 {
t.Errorf("Sub(7,4) = %d, want 3", got)
}
}
func TestMul(t *testing.T) {
if got := Mul(6, 9); got != 54 {
t.Errorf("Mul(6,9) = %d, want 54", got)
}
}
Step 1: Add t.Parallel() as the first call in each test. The functions are pure (no shared state, no I/O), so it's safe.
func TestSum(t *testing.T) {
t.Parallel()
if got := Sum(2, 3); got != 5 {
t.Errorf("Sum(2,3) = %d, want 5", got)
}
}
// repeat for TestSub and TestMul
Step 2: Run go test -v and confirm the === PAUSE / === CONT lines appear.
Step 3: Run go test -race -count=10 to confirm no races.
Step 4: Add -parallel 2 and confirm only 2 tests run at a time (less interesting for 3 tests, but the principle holds).
The whole exercise takes 3 minutes for a small file. For a 50-test file it takes 15. The speedup on the latter is significant.
17. Reading -v output¶
The -v flag prints each test event. A parallel test produces this sequence:
=== RUN TestX
=== PAUSE TestX
[other serial tests run in between]
=== CONT TestX
[the test's t.Log output]
--- PASS: TestX (0.01s)
=== RUN— test goroutine started.=== PAUSE—t.Parallel()was called; goroutine is parked.=== CONT— goroutine was resumed.--- PASS/--- FAIL— test finished.
When multiple tests are parallel, === CONT lines for different tests can interleave with each other. This is normal.
18. Reading -v output for subtests¶
A parallel subtest's events nest under its parent:
=== RUN TestParse
=== RUN TestParse/one
=== PAUSE TestParse/one
=== RUN TestParse/two
=== PAUSE TestParse/two
=== RUN TestParse/three
=== PAUSE TestParse/three
=== CONT TestParse/one
=== CONT TestParse/two
=== CONT TestParse/three
--- PASS: TestParse (0.00s)
--- PASS: TestParse/one (0.00s)
--- PASS: TestParse/two (0.00s)
--- PASS: TestParse/three (0.00s)
All three subtests register, then pause, then resume concurrently. The parent TestParse is marked PASS after all subtests finish.
19. Avoiding time.Sleep¶
A frequent mistake in tests of asynchronous code is to wait with time.Sleep:
func TestAsync(t *testing.T) {
t.Parallel()
StartBackgroundWorker()
time.Sleep(100 * time.Millisecond) // hope this is enough
if !workerDone() {
t.Fatal("worker didn't finish")
}
}
Problems:
- Under heavy CI load, 100 ms might not be enough; the test flakes.
- If the work finishes in 1 ms, the test wastes 99 ms.
- Two parallel tests both sleeping double the wall-clock for no gain.
The right way is to synchronise explicitly:
func TestAsync(t *testing.T) {
t.Parallel()
done := make(chan struct{})
StartBackgroundWorker(func() { close(done) })
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("worker didn't finish")
}
}
done closes the moment the worker finishes, so the test takes the minimum time. The 2-second deadline is the absolute upper bound, used only on failure.
20. httptest for HTTP-aware tests¶
The standard library's net/http/httptest package is purpose-built for parallel HTTP tests. Each call to httptest.NewServer creates a fresh listener on a unique port, so tests don't collide:
func TestEcho(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "echo: %s", r.URL.Path)
}))
t.Cleanup(srv.Close)
resp, err := http.Get(srv.URL + "/hello")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
if string(body) != "echo: /hello" {
t.Errorf("got %q", body)
}
}
Each parallel run of TestEcho gets a different srv.URL. The t.Cleanup ensures the listener is closed even if the test fails.
21. httptest.NewRecorder for handler-only tests¶
When you want to test a single HTTP handler without spinning up a server, use httptest.NewRecorder:
func TestHandler(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("got %d, want 200", rec.Code)
}
}
httptest.NewRecorder is fully in-memory; no listener, no port, fully parallel-safe.
22. Two real-world examples from the standard library¶
The Go standard library uses t.Parallel extensively. Two patterns worth studying:
encoding/json (well over 1000 parallel subtests). Most tests are table-driven, with each subtest in t.Run calling t.Parallel. The tests use only stack-allocated values; no shared state. The suite runs in seconds despite testing thousands of edge cases.
net/http (large parallel HTTP test suite). Each test creates its own httptest.Server, calls t.Parallel, and uses t.Cleanup to close the server. The structure is highly consistent across hundreds of tests.
Reading these test files is one of the best ways to internalise idiomatic parallel-test style. Run:
23. Example functions and parallelism¶
Example functions (func ExampleFoo() { ... }) are not parallel. They run serially and check stdout against the // Output: comment. They are documentation that doubles as tests, optimised for readability not speed.
Don't try to make Examples parallel; they're inherently about sequential output.
24. Skip and SkipNow¶
t.Skip marks the test as skipped and stops its execution. In a parallel test, it works the same way: the test pauses on t.Parallel, resumes when scheduled, calls t.Skip, and is reported as SKIP.
func TestExpensive(t *testing.T) {
if testing.Short() {
t.Skip("skipping in -short mode")
}
t.Parallel()
// ...
}
The order matters: call t.Skip before t.Parallel to avoid scheduling a test that will immediately skip.
25. t.Helper in parallel-test helpers¶
When a helper function calls t.Errorf or t.Fatal, by default the error line points inside the helper, not at the call site in the test. t.Helper() flips this:
func assertEqual(t *testing.T, got, want any) {
t.Helper()
if got != want {
t.Errorf("got %v, want %v", got, want)
}
}
func TestThing(t *testing.T) {
t.Parallel()
assertEqual(t, Square(3), 9) // error points HERE, not inside assertEqual
}
t.Helper works identically in parallel and serial tests. Always call it as the first line of a helper.
26. Test names in parallel output¶
When t.Run is given a name with spaces or slashes, Go normalises it. Slashes become subtest separators; spaces are URL-encoded:
When parallel tests are scheduled, their names appear in the output verbatim — useful for tracing which subtest is which. Keep names short and unique.
27. The -run flag with parallel tests¶
-run filters which tests execute, using a regex:
go test -run TestThing -parallel 4
go test -run TestThing/one -parallel 4 # one subtest
go test -run TestThing/.*one -parallel 4 # subtests matching .*one
Parallel semantics apply only to the selected tests; filtered-out tests don't pause and don't take a slot in the parallel queue.
28. The -v flag with parallel tests¶
-v always prints every test event. Without -v, only failures are shown:
go test -parallel 4 ./pkg # quiet, just OK/FAIL
go test -v -parallel 4 ./pkg # full event log
go test -v -parallel 4 -run X ./pkg # one test, full event log
For debugging a parallel flake, always use -v; you need to see the order of events.
29. Running a single parallel test repeatedly¶
To reproduce a flaky parallel test, run it many times:
-count=100 runs each matched test 100 times in the same binary. If the test flakes 1% of the time, 100 runs hits it once on average.
30. Common confusions¶
Confusion 1: "I added t.Parallel but the tests still run one at a time." Likely cause: -parallel 1 is set somewhere (CI config, GOFLAGS). Check echo $GOFLAGS and the CI config.
Confusion 2: "My parallel test passes but -race reports a race." The race is real. Find the unsynchronised shared state and fix it. Don't dismiss the report.
Confusion 3: "My test prints === PAUSE but never === CONT." The framework hasn't released the parallel queue yet — usually because a serial test is still running, or because -parallel is set very low.
Confusion 4: "Two tests using t.TempDir got the same path." Impossible by design. If you see this, you're misreading the output (the path includes the test name, which may be the same across runs but the random suffix is unique).
Confusion 5: "My test passes locally but fails in CI." Usually a parallelism difference: local has more cores, CI has fewer. Run locally with -parallel 2 or GOMAXPROCS=2 go test ... to reproduce.
31. Mental model: the parallel queue¶
Imagine the test framework has a queue. Tests run serially in source order. When a test calls t.Parallel, it parks itself on the queue and yields. After all serial siblings at the same level complete, the framework starts draining the queue — releasing up to -parallel tests at a time. When one finishes, the next is released.
Queue: [TestA, TestC]
Running: TestB (serial), waiting...
TestB finishes.
Queue drains; up to -parallel tests resume.
TestA and TestC run concurrently.
Both finish; queue empty.
m.Run returns.
This model accounts for every visible behavior: the pause, the wait, the burst of resumption.
32. Summary¶
t.Parallel()opts into concurrent execution with sibling parallel tests.- The default
-parallelisGOMAXPROCS; control with the flag. - Subtests need
t.Parallel()inside the closure to be concurrent. - On Go ≤1.21, shadow the loop variable; on Go 1.22+, the language handles it.
- Use
t.TempDirto isolate file-system state; avoidos.Setenvandos.Chdirin parallel tests. - Run
-raceto catch the bugst.Parallelexposes. - Use
t.Cleanupinstead ofdeferfor teardown. - Use
httptestfor HTTP tests; ports never collide. - Replace
time.Sleepwith channels ort.Contextfor synchronisation. - The
-v,-run,-count, and-parallelflags are your daily tools.
33. Where to go next¶
- The middle page covers grouped parallel subtests, fixture design, and resource pools.
- The senior page extends to architectural decisions about shared state and migration strategies.
- The specification page is the reference for every godoc claim made on this page.
- The interview page has practice questions at every level.
- The tasks page has hands-on exercises with clear pass/fail criteria.
Read in any order; revisit find-bug.md after you've written a few real parallel tests — the bugs there will start looking familiar.
34. Practice plan for the first week¶
- Day 1: write three parallel tests using
t.Parallel. Run with-v -race. - Day 2: write a table-driven parallel test. Confirm the
=== PAUSE/=== CONTinterleave. - Day 3: write a test using
t.TempDirandt.Cleanup. Confirm cleanup runs after the test. - Day 4: write a test that needs
t.Setenvand explain why it can't be parallel. - Day 5: write a
TestMainwith one setup and one teardown line. Verify it runs before/afterm.Run. - Day 6: take a 10-test file and convert all to parallel where safe. Benchmark before/after.
- Day 7: read
encoding/json/encode_test.goandnet/http/server_test.gofor idiomatic patterns.
After this week, parallel testing should feel as natural as writing the test function itself.
35. A deeper look at === PAUSE and === CONT¶
When you read -v output for the first time, the === PAUSE and === CONT lines look like noise. Once you understand them, they become a precise log of the test framework's scheduling decisions.
=== RUN TestX — the test function has been invoked; its goroutine is alive and executing.
=== PAUSE TestX — t.Parallel() returned. The test's goroutine is sleeping on an internal channel. The framework has noted that this test is ready to be batched with other parallel tests later.
=== CONT TestX — the framework has decided to wake this test up. Its goroutine resumes from just after the t.Parallel() call.
--- PASS: TestX (0.01s) — the test's function returned successfully. The elapsed time is from === RUN to here, including the time spent paused. This is important: a test that paused for 5 seconds before finishing in 1 ms will report 5.001s elapsed.
Knowing this saves debugging time. If a parallel test reports unexpectedly long elapsed time, check whether it was paused for a long stretch — possibly because earlier serial tests were slow.
36. The -cpu flag¶
The -cpu flag runs each test multiple times with different GOMAXPROCS settings:
This runs every test once with GOMAXPROCS=1, once with =2, once with =4. Total: 3x the work per test. Useful for catching tests that pass on multi-core machines but fail on single-core (or vice versa).
Combined with -parallel, you can quickly explore the behavior of your suite under different concurrency settings. Just remember that -cpu multiplies the number of test runs.
37. runtime.GOMAXPROCS¶
runtime.GOMAXPROCS(0) returns the current setting. The test binary uses the value set by -cpu or the default. Tests can read it but should not modify it during a run:
func TestCPUCount(t *testing.T) {
t.Parallel()
n := runtime.GOMAXPROCS(0)
t.Logf("running with GOMAXPROCS=%d", n)
}
Modifying GOMAXPROCS from within a parallel test is a race (it affects the whole runtime). Don't do it.
38. Cleaning up files in t.TempDir¶
t.TempDir registers a cleanup that removes the directory recursively. You can rely on this:
func TestWrite(t *testing.T) {
t.Parallel()
dir := t.TempDir()
for i := 0; i < 100; i++ {
path := filepath.Join(dir, fmt.Sprintf("file-%d.txt", i))
os.WriteFile(path, []byte("data"), 0o644)
}
// No explicit cleanup needed; t.TempDir handles it.
}
The directory and all its contents are removed after the test finishes. This works even on Windows, where file locking can complicate manual cleanup.
39. When t.TempDir is not enough¶
t.TempDir creates a directory under os.TempDir(). If your test needs to test paths in a different location (a specific mountpoint, a Windows UNC path, etc.), you'll need to create and clean up manually:
func TestSpecificPath(t *testing.T) {
t.Parallel()
base := "/mnt/test-data/" + t.Name() + "-" + randomSuffix()
if err := os.MkdirAll(base, 0o755); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(base) })
// ... use base
}
The randomSuffix() (e.g., 8 hex digits from crypto/rand) ensures parallel tests don't collide.
40. Reading a parallel test's output: practical exercise¶
Run this code:
package demo
import "testing"
func TestA(t *testing.T) {
t.Parallel()
t.Log("A: start")
t.Log("A: end")
}
func TestB(t *testing.T) {
t.Log("B: serial start")
t.Log("B: serial end")
}
func TestC(t *testing.T) {
t.Parallel()
t.Log("C: start")
t.Log("C: end")
}
With go test -v -parallel 4, the output is (one possible ordering):
=== RUN TestA
=== PAUSE TestA
=== RUN TestB
demo_test.go:14: B: serial start
demo_test.go:15: B: serial end
--- PASS: TestB (0.00s)
=== RUN TestC
=== PAUSE TestC
=== CONT TestA
=== CONT TestC
demo_test.go:7: A: start
demo_test.go:8: A: end
demo_test.go:19: C: start
demo_test.go:20: C: end
--- PASS: TestA (0.00s)
--- PASS: TestC (0.00s)
PASS
Note:
t.Logcalls are buffered per test, then flushed when the test ends.- The buffer is flushed before
--- PASS, all in one block. - A's log and C's log appear in distinct blocks, even though A and C ran concurrently.
- B's log appears before A's, because B finished first (it ran serially while A was paused).
Practice reading this output until the pattern is obvious.
41. t.Errorf vs t.Fatalf in parallel tests¶
t.Errorf marks the test as failed but continues execution. t.Fatalf marks it failed and stops execution (via t.FailNow). Both work the same way in parallel and serial tests.
In a parallel test, the choice has the same trade-offs as serial:
func TestX(t *testing.T) {
t.Parallel()
got, err := fetch()
if err != nil {
t.Fatalf("fetch: %v", err) // can't continue if fetch failed
}
if got.ID == 0 {
t.Errorf("got zero ID") // could check more fields
}
if got.Name == "" {
t.Errorf("got empty name")
}
}
t.Fatalf for fail-fast on prerequisites; t.Errorf for accumulating findings. Don't mix them up.
42. Patterns I see in junior code (and how to fix them)¶
Pattern A: forgetting t.Parallel entirely.
Fix: add it as the first call in every test function unless the test mutates global state.
Pattern B: t.Parallel at the top, then mutating a package-level slice.
Fix: scope the slice locally or remove parallelism.
Pattern C: hardcoded port numbers.
Fix: ":0" and read back the actual port from ln.Addr().
Pattern D: defer cleanup() instead of t.Cleanup(cleanup).
Fix: use t.Cleanup; it's more robust against t.FailNow.
Pattern E: time.Sleep waiting for an async operation.
Fix: use a channel, sync.WaitGroup, or t.Context().
Pattern F: os.Setenv directly in a test.
Fix: use t.Setenv (and accept the lack of parallelism), or refactor the production code to read config from a struct.
43. The pre-1.22 loop-variable bug, illustrated more¶
To really understand the bug, write the buggy code with explicit goroutine numbering:
// Pre-Go 1.22:
for _, tc := range []string{"a", "b", "c"} {
t.Run(tc, func(t *testing.T) {
t.Parallel()
// Goroutine 1 reads tc here, but tc is shared across iterations.
// When the goroutine resumes, the loop has finished and tc == "c".
t.Log(tc)
})
}
// All three log "c".
The fix:
for _, tc := range []string{"a", "b", "c"} {
tc := tc // each iteration creates its own copy
t.Run(tc, func(t *testing.T) {
t.Parallel()
t.Log(tc) // now correctly logs "a", "b", "c"
})
}
On Go 1.22+, the language change makes the tc := tc redundant. The fresh-variable-per-iteration is now built into the language. go vet's loopclosure analyzer still flags the missing shadow on older Go.
Practice: write the bug, run it on Go 1.21, observe the all-"c" output. Apply the fix. Re-run. Confirm distinct outputs. This is a five-minute exercise that prevents an hour of frustration later.
44. Combining t.Run, t.Parallel, and t.Cleanup¶
The three primitives compose well. A complete parallel subtest example:
func TestUsers(t *testing.T) {
cases := []struct {
name string
id int
}{
{"alice", 1},
{"bob", 2},
{"carol", 3},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
db := openTestDB(t) // helper that calls t.Cleanup
user, err := db.GetUser(tc.id)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if user.Name != tc.name {
t.Errorf("got name %q, want %q", user.Name, tc.name)
}
})
}
}
func openTestDB(t *testing.T) *DB {
t.Helper()
db := newDB()
t.Cleanup(func() { db.Close() })
seed(db)
return db
}
Read top-to-bottom:
casesis a table.- Each row launches a subtest via
t.Run. - Each subtest calls
t.Parallelfirst thing. - Each subtest builds its own DB via a helper.
- The helper registers cleanup so the DB is closed automatically.
- The assertion is just a comparison.
No global state, no manual cleanup, no synchronization issues. This is what good junior-level parallel test code looks like.
45. The race detector report format¶
When -race catches a race, the report includes:
==================
WARNING: DATA RACE
Read at 0x... by goroutine N:
pkg.SomeFunc()
/path/to/file.go:42 +0x...
testing.tRunner()
...
Previous write at 0x... by goroutine M:
pkg.OtherFunc()
/path/to/file.go:17 +0x...
testing.tRunner()
...
Goroutine N (running) created at:
testing.(*T).Run()
...
Goroutine M (finished) created at:
testing.(*T).Run()
...
==================
To diagnose:
- Read the "Read at" location. This is where the access happened.
- Read the "Previous write at" location. This is the other access.
- Both lines point to the same memory address (
0x...). - Identify the shared variable.
- Add a mutex, atomic, or restructure to eliminate the sharing.
Practice on a known buggy test: introduce a var counter int, increment from two parallel subtests, run with -race, read the report.
46. The middle page preview¶
The middle page elaborates on:
- The two-tier parallelism (
-pvs-parallel) in detail. - Grouped subtests and synchronization points.
- Fixture design: pooled, namespaced, immutable shared.
- The race detector at scale.
goleakfor goroutine leak detection.TestMaininteractions with parallel tests.
If you've read this far, you have the foundation. Move to middle when you're writing your first non-trivial parallel test for a real codebase.
47. Frequently asked junior questions¶
Q: Should every test be parallel?
Almost. If the test is pure (no env vars, no working directory changes, no package-level state, no shared files), default to parallel. Document the rare exceptions inline.
Q: How fast will my suite get?
Roughly proportional to the number of cores, capped by the slowest serial test. A 10-second suite on 8 cores often becomes ~2 seconds. A suite where one test takes 5 seconds and others take 100 ms each cannot go below 5 seconds, no matter how parallel.
Q: Will t.Parallel break my existing tests?
It might. If your tests share state, parallelism exposes the race. The race was there before, just hidden. Run -race after adding t.Parallel to surface the bugs.
Q: Can I call t.Parallel conditionally?
You can put it inside an if, but the test framework expects it to be called at most once per test goroutine. Conditional t.Parallel is rare and usually a sign that the test should be split.
Q: Does t.Parallel affect benchmark functions?
No. Benchmarks use b.RunParallel and b.SetParallelism instead. The model is completely different.
48. The shape of a robust parallel test¶
After reading this page, here's the mental template for every new test you write:
func TestX(t *testing.T) {
t.Parallel() // first thing
// Setup using parallel-safe helpers:
fixture := newFixture(t) // helper that registers t.Cleanup
// Assertion:
got, err := callTheFunction(fixture)
if err != nil {
t.Fatalf("setup error: %v", err)
}
if !equal(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
Five sections: t.Parallel, fixtures, call, error check, value check. No defer. No package-level state. No os.Setenv. No time.Sleep. No working-directory changes.
Memorise the shape. Then every time you write a test, ask: "does this fit the shape? If not, why?"
49. The "before you commit" checklist¶
Before pushing a PR with new tests, run:
go test ./...— green.go test -race ./...— green.go test -count=5 ./...— green (catches per-test flakes).go vet ./...— clean.
If any step fails, fix before committing. CI will run the same commands; failing locally and waiting for CI is a slow feedback loop.
49a. Tests that look pure but aren't¶
A surprising number of "pure" functions in Go libraries depend on subtle global state. Watch out for:
crypto/randandmath/randreading from a package-level source.math/randhas a default Source; concurrent reads needmath/rand/v2(Go 1.22+) or a per-test*rand.Rand.time.Now()is global, but reads are safe (no mutation). Tests that compare timestamps from different parallel runs see different values, but each test sees consistent ones.fmt.Sprintfand friends acquire and release a sync pool of buffers — internally synchronised, fine for parallel.os.Argsis global but typically not mutated afterinit.
When you call a function and don't know what it touches, run a quick check: spike up -parallel 64, run -race -count=10, and see if anything fires. Twenty seconds of paranoia saves an hour of debugging later.
49b. Why -race is your friend¶
A common reaction to a race detector report is "but the test passes". The race detector is not reporting a test failure; it's reporting a memory-model violation. Under a different schedule (e.g., production load, a different CPU, a different goroutine count), the violation could manifest as:
- A panic.
- A silent wrong result.
- A deadlock.
- A corrupted data structure.
Treating race-detector reports as informational is how production incidents happen weeks after the bug landed in main. Treat them as failures.
49c. A note on t.Logf versus fmt.Println¶
In a parallel test, fmt.Println writes directly to stdout, interleaving with other tests. t.Logf buffers per-test and flushes at the test's end. Always use t.Logf in tests; the output is cleaner and the framework controls verbosity.
func TestThing(t *testing.T) {
t.Parallel()
fmt.Println("DEBUG") // bad, may interleave
t.Logf("DEBUG") // good, buffered
}
Also: t.Logf only prints when -v is set or the test fails. fmt.Println always prints, polluting CI logs.
50. Summary, take three¶
The three big ideas of this page:
t.Parallel()is a contract: I will not touch process-global state; the framework will run me concurrently with siblings.- Use the right isolation primitive:
t.TempDirfor files,httptest.NewServerfor HTTP,127.0.0.1:0for ports,t.Cleanupfor teardown. -raceis the safety net: without it, parallel tests would be Russian roulette. With it, the bugs surface deterministically in CI.
Internalise these and you have everything you need to write idiomatic parallel tests in Go.
51. Appendix: cheat sheet¶
A printable summary for taping to your monitor:
TEST PATTERN:
func TestX(t *testing.T) {
t.Parallel() // first line, always
fixture := newFixture(t) // helper with t.Cleanup
// ... assertions
}
SUBTEST PATTERN:
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// ... assertions
})
}
CLEANUP:
t.Cleanup(func() { resource.Close() }) // not defer
ISOLATION:
dir := t.TempDir() // file-system
srv := httptest.NewServer(handler) // HTTP
ln, _ := net.Listen("tcp", "127.0.0.1:0") // raw TCP
FORBIDDEN IN PARALLEL:
t.Setenv(...) // env vars
t.Chdir(...) // Go 1.24+ // working dir
package-level mutable var // shared state
DEBUGGING:
go test -v -race -count=100 -parallel 4 -run TestX
52. Appendix: the testing package's surface, in one diagram¶
testing.T
├── Parallel() // mark this test parallel
├── Run(name, fn) // create a subtest
├── Cleanup(fn) // register teardown
├── TempDir() string // unique temp dir
├── Setenv(k, v) // serial-only env var
├── Chdir(dir) (1.24+) // serial-only chdir
├── Context() (1.24+) // cancellable context
├── Helper() // mark as helper
├── Log/Logf // buffered log
├── Error/Errorf/Fatal/Fatalf // failure reporting
├── Skip/Skipf/SkipNow // skip the test
├── Name() string // current test name
└── ...
Most parallel-test code uses fewer than 10 of these methods. The rest are for less common scenarios (deferred setup, conditional skip, etc.).
53. Appendix: -parallel and -cpu decision table¶
| Workload | -parallel | -cpu |
|---|---|---|
| Pure compute, small tests | GOMAXPROCS | (default) |
| Pure compute, large suite | 2*GOMAXPROCS | (default) |
| I/O-bound (DB, HTTP) | 4*GOMAXPROCS | (default) |
| Mixed | GOMAXPROCS | (default) |
| Sanity test single-core | 1 | 1 |
| Comprehensive CPU testing | (default) | 1,2,4 |
When in doubt, run the suite at three different -parallel values and pick the fastest.
The middle page covers the patterns that compose these primitives into real fixtures and test suites.