JavaRush /Courses /Go SELF /Parallel tests — t.Parallel(), an anti-flaky approach

Parallel tests — t.Parallel(), an anti-flaky approach

Go SELF
Level 71, Lesson 1
Available

1. Why parallel tests

If you have ever looked at the output of go test and thought, “Yeah, tests passed... but why did it take a minute if my code is three functions and one sad struct?”, then you are already almost there. Tests often grow faster than the project: you add one package, a second one, a bit of integration coverage—and suddenly the run takes so long that you can make tea, forget that you made tea, and make a second tea.

Parallel tests in Go are a way to utilize multiple CPUs (and the runner’s capabilities in general) so that different independent checks run at the same time. If the tests are truly independent, you gain time almost “for free.” But if the tests are not independent, you get a rare collection of bugs: from “tests affect each other” to “yesterday it was green, today it’s red, and tomorrow it’s philosophical.”

It’s useful to lock in this idea right away: t.Parallel() is not a “test accelerator,” but “a tool that instantly reveals hidden dependencies.” And that is its real value: it forces you to write tests you can actually trust.

How t.Parallel() works

When you first see t.Parallel(), you might imagine magic like “the test runs in a separate goroutine and lives its own life.” In practice the mechanics are a bit trickier, and because of that beginners often put t.Parallel() “somewhere in the middle,” and then get surprised by the behavior.

The idea is: a test that calls t.Parallel() tells the runner, “I can run in parallel with other tests like this.” After that, the runner may pause it at the current point and resume later, when it can run multiple parallel tests at once. For subtests (t.Run()) there is another nuance: a parallel subtest will actually start running only when the parent test reaches the end (that is, when the parent “releases” control).

If you want to keep this in your head as a mini diagram, you can imagine a “timeline” like this:

flowchart TD
    A["TestParent starts"] --> B["t.Run('case1')"]
    B --> C["case1 calls t.Parallel()\nand goes into waiting"]

    C --> D["t.Run('case2')"]
    D --> E["case2 calls t.Parallel()\nand goes into waiting"]

    E --> F["TestParent finishes\n(parent releases control)"]

    F --> G["case1 runs"]
    F --> H["case2 runs"]

    G -. in parallel .- H

This leads to a practical rule: if you decide to make a test parallel, write t.Parallel() as close to the start of the test/subtest body as possible. Not because “that’s how it’s done,” but because it makes it easier to understand which part of the test belongs to the parallel phase and which part is setup.

3. Isolation: a parallel test should be “vacuum-sealed”

When we say “test isolation,” it sounds like something from a lab: white coats, gloves, an autoclave. In reality the meaning is everyday: parallel tests must not share mutable resources in a way where execution order changes the result.

A mutable resource is not only a global variable. It is also a shared file, shared directory, shared port, shared environment variable, shared singleton cache, shared database, shared “fake server on 8080,” shared random generator with a single seed, shared map, shared slice, shared time.Now() without control—the list is endless, like the number of ways to accidentally ruin your evening.

A good target is: each parallel test creates its own inputs and its own temporary resources inside itself. And if it needs something “from the outside,” it receives it as a dependency (parameter/constructor), not by reading global state.

Isolating files and directories: t.TempDir()

Parallelism most often breaks tests not on arithmetic, but on “the environment.” For example, two tests write to the same file tmp.txt, or both create the directory ./testdata/out, or both use the same port.

The right model: each test creates its own temporary resources. In Go this is usually done via t.TempDir(), and then inside that directory you can create files without worrying about name collisions.

Example writing a file inside an individual temp directory:

package task

import (
	"os"
	"path/filepath"
	"testing"
)

func TestWriteTempFile_ParallelSafe(t *testing.T) {
	t.Parallel()

	dir := t.TempDir()
	path := filepath.Join(dir, "note.txt")
	_ = os.WriteFile(path, []byte("ok"), 0600)
}

The point is not that this test “deeply verifies” something. The point is discipline: t.TempDir() guarantees that each test gets its own sandbox directory, and parallel execution doesn’t turn the file system into a battlefield.

Environment variables and global settings

Environment variables (os.Setenv()) are global process state. If one test changes APP_ENV and another test relies on the previous value, you will get either flaky behavior or a very weird bug like “fails only on Windows / only on CI / only on Tuesdays.”

If you really need to change env in a test, do it in a way that makes the changes local and rolled back. In modern Go versions there are convenient approaches like t.Setenv() (if you use it), but even without it the principle is the same: “set it—guaranteed restore it,” and it’s better not to do this in parallel tests at all.

A helpful way to think about it: a parallel test should work even if someone is running next to it. And env is a “shared kitchen” where you suddenly rearranged all the mugs and left.

Where to put t.Parallel() in subtests

A common beginner mistake: put t.Parallel() after data setup, because “setup is shared anyway, let it be before.” Sometimes that’s right, but sometimes it’s not.

The problem is that data setup may touch a shared resource. For example, you create a shared object and then mutate it in parallel subtests. Or you fill a shared slice and then in subtests do append and get surprised by aliasing (yes, these are the kinds of bugs that like to travel in packs).

The best practice for table-driven tests is usually: in the parent, you describe cases (pure data), and in the subtest you create everything that will be mutated, including structs, files, etc. That’s why t.Parallel() goes at the very start of the subtest, right after entering it, and then everything is created locally.

If you need “expensive setup” (for example, a large set of input data), you can do it once and then use it as read-only. But then you must honestly guarantee it is read-only. The moment you say “well, we’ll just flip one flag here,” you’re back in the flaky world.

Why “protect everything with a mutex” is not a universal answer

Sometimes after learning about races you get a reflex: “if the problem is parallelism, let’s add a Mutex around everything.” That may eliminate a data race, but it won’t necessarily remove flaky behavior and it won’t necessarily make the test good.

There’s a difference between “data is protected from simultaneous access” and “tests are independent.” If two tests share the same resource, then even with perfect locking they can still affect the order of operations and the final state. You won’t have a race, but you will have “test A left state behind, and test B observed it.”

So the idea is: Mutex is a tool for correctness of concurrent code, but test isolation is a tool for correctness of verification. Sometimes they overlap, but they do not replace each other.

Mini-table: what is usually safe in parallel tests

Sometimes it’s easier to quickly check with your eyes. We won’t abuse lists, so we’ll make a compact reminder table.

Situation In a parallel test this is usually… Why
Pure functions (string -> string, calculations) Great No I/O and no shared state
Creating your own structs inside the test Great Local data
t.TempDir() and files inside it Great Directory-level isolation
Global variables/singletons Bad Tests affect each other
os.Setenv() without rollback Bad Env is global to the process
Ports/server on a fixed :8080 Bad Resource conflicts
time.Sleep() as “wait until” Suspicious Timings are not guaranteed

4. Pattern: t.Run() + t.Parallel() in table-driven tests

Now the most important part: how to nicely combine table-driven tests, subtests, and t.Parallel() so that it works by the rules, not by “luck.”

Mini-project: the domain package task and a “pure” function

To avoid being abstract, we’ll rely on the familiar “tasks” domain (todo/task tracker) that we used many times in the course. Today we don’t care about the architecture of the whole app; we only need at least one pure function that is pleasant and safe to test in parallel.

Imagine we have a function that normalizes a task title: remove extra spaces, make the string look neat. This is a classic example of “pure” logic: no I/O, no time, no global variables—so you can run it in parallel across a hundred tests if you want.

package task

import "strings"

func NormalizeTitle(s string) string {
	s = strings.TrimSpace(s)
	s = strings.Join(strings.Fields(s), " ")
	return s
}

There is no “shared resource” here. The function takes a string and returns a string. This kind of logic is an ideal candidate for t.Parallel().

Parallel cases for NormalizeTitle

Let’s imagine a test for NormalizeTitle. We want several cases, and we want them to execute in parallel: each case is independent, so it is safe.

package task

import "testing"

func TestNormalizeTitle_Parallel(t *testing.T) {
	cases := []struct {
		name, in, want string
	}{
		{"trim", "  hello  ", "hello"},
		{"spaces", "a   b", "a b"},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			got := NormalizeTitle(tc.in)
			if got != tc.want {
				t.Fatalf("got=%q, want=%q", got, tc.want)
			}
		})
	}
}

Note that the loop has no tc := tc line, which you will often see in older code. Before Go 1.22 it was mandatory: there was one loop variable for the whole loop, the parallel subtests started after the loop had finished, and they all read the last case.

Starting with Go 1.22, each iteration of for _, tc := range creates a new tc variable, and the closure in t.Run() captures “its own” case. The semantics is enabled by the go directive in go.mod: with go 1.22 or higher (ours is go 1.25) the copy is not needed; with an older version the trap comes back.

The trap still exists in Go 1.25 if you declare the variable before the loop and assign to it with =: for _, tc = range cases. Then there is one variable for the whole loop, and all parallel subtests will read the last case. The fix is not a copy but declaring the variable in the loop header with :=.

Tools take this change into account. In Go 1.20, vet learned to detect loop variable capture after t.Parallel(), and since Go 1.22 it no longer reports such places in modules with go 1.22+: capturing a variable from the loop header has become safe.

Another example from task: title validation

Suppose in our domain we have a simple check: the task title must not be empty after normalization. This is again “pure logic,” which is nice to parallelize.

package task

import "errors"

var ErrEmptyTitle = errors.New("empty title")

func ValidateTitle(s string) error {
	if NormalizeTitle(s) == "" {
		return ErrEmptyTitle
	}
	return nil
}

And parallel subtests:

package task

import "testing"

func TestValidateTitle_ParallelCases(t *testing.T) {
	cases := []struct {
		name string
		in   string
		want error
	}{
		{"ok", "buy milk", nil},
		{"empty", "   ", ErrEmptyTitle},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			err := ValidateTitle(tc.in)
			if err != tc.want {
				t.Fatalf("err=%v, want=%v", err, tc.want)
			}
		})
	}
}

Notice that we compare errors as values (for a sentinel error this is fine in such a simple case). The important part is different: each case is independent, there is no shared state, and parallelism is safe.

5. Flaky tests and an anti-flaky approach

A flaky test is a test that sometimes passes and sometimes fails without any code changes. The worst part is not even the failure; it’s the loss of trust: when tests “make noise,” the team stops treating a red run as a signal. And then the typical thing happens: “CI is acting up again, hit rerun”—and quality goes off a cliff with a very serious face.

Parallelism is a great amplifier of flaky behavior, because it makes execution order less predictable. What in a sequential run “just happened to always make it in time,” in a parallel run starts exposing resource contention, synchronization problems, dependence on time, external services, and environment.

Time-dependent tests are especially tricky. Historically, even changes in the precision of time.Now() broke tests that compared timestamps “head-on” and expected an exact match—there is a good real-world example from the Go team: a test saved time.Now(), loaded it back, and compared with ==, and after a precision change it started failing.
And that’s an important moral: time and parallelism almost always come as a pair to surprise you.

An example of “don’t do this”: parallelism + shared state

To understand the anti-flaky approach, it helps to see a “bad” test once. It can be race-free (that is, without an explicit data race) and still be flaky, because tests affect each other via a shared resource.

The simplest shared resource is a global variable.

package task

import "testing"

var globalCounter int

func TestBad_GlobalState(t *testing.T) {
	t.Parallel()

	globalCounter++
	if globalCounter == 0 {
		t.Fatalf("impossible, but flaky things happen")
	}
}

This example looks silly (and it is), but the mechanism is real. If your tests contain anything like var shared = ... and parallel tests touch it, you will almost certainly get instability. Even if you protect globalCounter with a mutex, tests will still depend on each other semantically: execution order affects the final value. That’s no longer a unit test, it’s a “social network of tests,” where everyone knows everyone and discusses things.

Anti-flaky approach: don’t “fix it with Sleep”

It’s tempting to give a “simple rule” like “never use time.Sleep().” In real life, Sleep is sometimes acceptable, but in tests it most often means you are guessing timing, not verifying a contract. And guessing timing is a perfect recipe for a flaky test.

An anti-flaky approach sounds like this: a test should rely on deterministic completion conditions, not on hope that “everything will definitely finish within 50ms.”

If a test checks concurrent code, the best completion condition is a signal: closing a done channel, waiting on a WaitGroup, canceling a context, receiving a result from a channel. In earlier lectures you have already seen the “timeout via channel/context” pattern so a test won’t hang forever. Today we add this thought: a timeout is a safety net against hanging, but the main synchronization mechanism is a completion signal, not sleeping.

6. Common mistakes with t.Parallel() and subtests

Mistake №1: t.Parallel() is in the middle of the test, and you don’t understand what exactly runs in parallel.
Such a test is hard to read: there’s setup at the top, then something important, then t.Parallel(), and then something else. In practice it’s better to put t.Parallel() as early as possible, right after entering the test/subtest. Then the parallel phase is obvious, and you won’t accidentally put something into “setup” that should be local.

Mistake №2: the case variable is declared outside the loop.
The symptoms are classic: in the report, subtests have different names, but in reality they all use the same case inside (often the last one). In Go 1.22+ this happens if you declared the variable before the loop and assign with = (for _, tc = range cases), or if go.mod specifies a version below go 1.22. The fix is to declare it in the loop header: for _, tc := range cases. In Go 1.22+ the line tc := tc is redundant.

Mistake №3: parallel tests share resources—files, directories, env, ports.
A test may be correct “on its own,” but fail under parallel execution. The solution is almost always the same: each test creates its own temporary resources (for example, via t.TempDir()), does not use fixed paths, and does not change global process settings.

Mistake №4: flaky tests are “fixed” with time.Sleep(), not synchronization.
Adding Sleep(50ms) and having it “fixed” is an illusion. On a different CPU, on a loaded CI, with another runner version, or with parallel execution, it will break again. An anti-flaky approach is to wait for completion events (done channel, WaitGroup, context), and use a timeout only as a safety net against hanging.

Mistake №5: tests compare “time head-on” and start failing due to precision/rounding.
Time is not a string and not an int; it has nuances of precision and representation. There are real cases where precision changes in time.Now() broke tests that expected exact equality after saving/loading.
The remedy is usually to compare time with a tolerance, normalize precision (round/truncate), or avoid testing “equals” and instead test “falls into the expected range.”

1
Task
Go SELF, level 71, lesson 1
Locked
Cleaning headers
Cleaning headers
1
Task
Go SELF, level 71, lesson 1
Locked
Palindrome bug
Palindrome bug
1
Task
Go SELF, level 71, lesson 1
Locked
Notes in a sandbox
Notes in a sandbox
1
Task
Go SELF, level 71, lesson 1
Locked
Asynchronous doubler
Asynchronous doubler
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION