Testing in Go: what t.Run, t.Parallel and a benchmark really do
Interview questions on tests climb a ladder: what a table-driven test is — how to run one case — why t.Parallel and what breaks with it — why a repeated go test does not run the tests — what is wrong with a benchmark. A run answers each step: the parent's defer closes a resource before the parallel subtests start; in a module with go 1.21 every subtest sees the last case of the table; four sleeping subtests run in pairs because -parallel equals GOMAXPROCS; and a benchmark that discarded the result of a pure inlined function measured an empty loop.
Full technical treatment
TL;DR
A test in Go is a function TestXxx(t *testing.T), and a table-driven test
is data plus one check. The cases sit in a slice of structs, the check is
written once, and each case runs as a subtest via t.Run under its own name.
So one case can be run on its own: go test -run 'TestClamp/above'.
t.Parallel() changes the order of execution, and that is the main source
of bugs. A parallel subtest waits until the parent's body finishes. The
parent's defer fires exactly then — before the subtests: in the run both
subtests saw a resource that was already closed. t.Cleanup is called
after all the subtests, and a group's t.Run does not return until its
parallel subtests have finished. And the loop variable in parallel subtests depends
on the go line in go.mod: in a module with go 1.21 all three subtests saw
the last case of the table.
Beyond that comes what separates real understanding from having read about
it. t.Parallel does not mean "all at once": no more run at a time than
-parallel, and that defaults to GOMAXPROCS. Four subtests sleeping 200 ms
each took 404 ms with GOMAXPROCS=2, not 200. A repeated go test with no
changes does not run the tests but prints (cached) — even if the database
the test talks to has changed since. And a benchmark that discards the result
of a pure inlined function measured an empty loop in the run: 0.32 ns
against 1.65 ns when the result is kept.
- how to write a function and run
go test; - what a goroutine and a data race are — that is the lesson on data races, and
go test -raceis covered there; - what a closure and a loop variable are.
- table-driven tests, subtests and running a single case;
t.Parallel,t.Cleanup, the loop variable and the-parallelflag;- the result cache,
t.Fatalin a goroutine,t.Setenvand the benchmark bug.
What is really being asked
The ladder is almost always this one:
- "What is a table-driven test?" — the warm-up; "a table of cases" is right, but what is interesting is what happens to each case.
- "How do you run one case?" — this checks whether the candidate knows about subtests.
- "Why
t.Parallel, and what can go wrong with it?" — the question where answers diverge the most. - "Why did the second
go testfinish instantly?" — about the cache. - "What is wrong with this benchmark?" — the classic problem: the benchmark measures the wrong thing.
- "How do you test code with external dependencies?" — about interfaces and fakes.
The ladder has one spine: go test is not just calling functions one after
another. It has its own order of execution, its own cache and its own
relationship with the compiler, and almost every mistake comes from assuming
that order is obvious.
Base: a table-driven test is subtests
The function under test and its table:
func TestClamp(t *testing.T) {
tests := []struct {
name string
v, lo, hi int
want int
}{
{"inside", 5, 0, 10, 5},
{"below", -3, 0, 10, 0},
{"above", 42, 0, 10, 10},
{"at_boundary", 10, 0, 10, 10},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := Clamp(tc.v, tc.lo, tc.hi); got != tc.want {
t.Errorf("Clamp(%d, %d, %d) = %d, want %d", tc.v, tc.lo, tc.hi, got, tc.want)
}
})
}
}t.Run turns each case into a subtest named TestClamp/<name>. A subtest has
its own result, its own line in the report — and it can be run on its own:
go test -v ./cases/table/
--- PASS: TestClamp
--- PASS: TestClamp/inside
--- PASS: TestClamp/below
--- PASS: TestClamp/above
--- PASS: TestClamp/at_boundary
go test -v -run 'TestClamp/above' ./cases/table/
--- PASS: TestClamp
--- PASS: TestClamp/above
A junior-level answer: a table-driven test is a slice of cases and one
check in a loop; each case is run via t.Run so that it has a name, a
separate result and can be run on its own with -run. t.Errorf reports a
failure and lets the test carry on; t.Fatalf stops the current test.
Mechanism 1: a parallel subtest waits for the parent to finish
t.Parallel() does not say "run me now in a separate goroutine" but
something else:
Parallel signals that this test is to be run in parallel with (and only with)
other parallel tests.
"Only with" is the key. A subtest that called t.Parallel() is paused and
waits until the parent's body finishes: only then do the parallel subtests run
together. And the parent's body has a defer, which fires at exactly that
moment. The same test with a resource closed in two ways:
2) PARALLEL SUBTESTS: defer VERSUS t.Cleanup
────────────────────────────────────────────
EVENT defer: resource opened
EVENT defer: parent body finished
EVENT defer: resource closed
EVENT defer: subtest a runs, resource closed: true
EVENT defer: subtest b runs, resource closed: true
EVENT Cleanup: resource opened
EVENT Cleanup: parent body finished
EVENT Cleanup: subtest a runs, resource closed: false
EVENT Cleanup: subtest b runs, resource closed: false
EVENT Cleanup: resource closed
With defer both subtests got a closed resource: a database connection,
a temporary directory, a test server. The test may even pass — for example, if
the subtest does not check the closed connection's error.
t.Cleanup is built for this case:
Cleanup registers a function to be called when the test (or subtest) and all
its subtests complete.
Hence the rule: a parent's resource used by parallel subtests is released
through t.Cleanup, not the parent's defer. Built-in helpers such as
t.TempDir() work the same way: the directory is removed in a Cleanup.
This is about the parent's defer specifically. A defer inside the subtest
itself fires when the subtest's body finishes — after its work — and for a
resource the subtest created for itself it is fine. And there is a second way
to wait for parallel subtests — a group:
Run does not return until parallel subtests have completed, providing a way
to clean up after a group of parallel tests.
t.Run("group", func(t *testing.T) {
for _, name := range []string{"a", "b"} {
t.Run(name, func(t *testing.T) {
t.Parallel()
// work with the shared resource
})
}
})
// execution gets here once the group's subtests have finished1) A t.Run GROUP AND defer INSIDE A SUBTEST
───────────────────────────────────────────
EVENT group: resource opened
EVENT group: subtest a is running, resource closed: false
EVENT group: subtest b is running, resource closed: false
EVENT group: the group returned, resource closed
EVENT local: subtest a is running, its own resource closed: false
EVENT local: subtest a closed its own resource
EVENT local: subtest b is running, its own resource closed: false
EVENT local: subtest b closed its own resource
Both subtests of the group saw the resource open, and ordinary code after
t.Run("group", …) closed it. Each subtest's local defer fired after that
subtest's work (a run of bench/gotest/boundaries.go, section 1).
Mechanism 2: the loop variable is a matter of the go line in go.mod
The classic bug of parallel subtests is a closure over the loop variable. Since Go 1.22 the specification says:
Each iteration has its own separate declared variable (or variables)
But which semantics applies is decided not by the compiler version but by the
module's language version — the go line in go.mod. One and the same test,
one and the same go1.24.7 compiler, two modules:
cases := []string{"a", "b", "c"}
for _, tc := range cases {
t.Run(tc, func(t *testing.T) {
t.Parallel()
fmt.Printf("EVENT subtest %s sees tc = %s\n", t.Name(), tc)
})
}3) THE LOOP VARIABLE IN PARALLEL SUBTESTS
─────────────────────────────────────────
module with go 1.21 in go.mod:
EVENT subtest TestCases/a sees tc = c
EVENT subtest TestCases/b sees tc = c
EVENT subtest TestCases/c sees tc = c
module with go 1.24 in go.mod — the same code:
EVENT subtest TestCases/a sees tc = a
EVENT subtest TestCases/b sees tc = b
EVENT subtest TestCases/c sees tc = c
go vet in the module with go 1.21:
loopvar/loopvar_test.go:16:73: loop variable tc captured by func literal
In the old module all three subtests checked the last case of the table —
and the test passed: the bug is not in the result but in two thirds of the
table never being checked at all. It is Mechanism 1 at work: the subtests wait
until the parent's loop finishes, and by then the single variable tc holds
the last value.
The practical takeaway for an interview: "Go 1.22 fixed this" is only half
right. A module whose go.mod declares an older version keeps the old
semantics even on a new compiler. The line tc := tc in old code is not
clutter but protection; go vet in such a module catches its absence.
Mechanism 3: how many subtests run at once
"t.Parallel means all subtests at once" is a natural expectation. But the
number of simultaneous tests is limited by a flag:
By default, -parallel is set to the value of GOMAXPROCS.
Four subtests sleeping 200 ms each, all with t.Parallel():
4) HOW MANY SUBTESTS RUN AT ONCE
────────────────────────────────
four subtests sleeping 200 ms each, all with t.Parallel()
-parallel by default (= GOMAXPROCS) 404 ms
-parallel=1 805 ms
-parallel=4 203 ms
Sleeping does not occupy the processor, but the flag limits the number of
subtests, not the load on cores: on a machine with GOMAXPROCS=2, subtests
waiting on a network or a database run in pairs. If such tests are slow,
-parallel is raised explicitly. And the reverse: on a CI machine with many
cores more parallel tests will run — and those sharing a resource will start
getting in each other's way.
Mechanism 4: a repeated go test does not run the tests
5) THE RESULT CACHE
───────────────────
go test ./cases/table/
ok deepengineering/bench/gotest/cases/table
go test ./cases/table/
ok deepengineering/bench/gotest/cases/table (cached)
go test -count=1 ./cases/table/
ok deepengineering/bench/gotest/cases/table
The second run executed nothing:
When this happens, go test prints '(cached)' in place of the elapsed time in the
summary line.
The cache applies when neither the test binary, nor the flags from a short
"cacheable" list, nor the files within the module and the environment
variables the test read have changed. That is all go test sees: the state of
a database, an external API or files outside the module is not part of the
check. For a test that talks to a real database or network this is a trap: a
"green" result may have been taken an hour ago, before the database changed.
That is why integration tests are run with -count=1 — the idiomatic way to
disable the cache.
Mechanism 5: t.Fatal in a goroutine, t.Setenv and synthetic time
t.Fatal stops a goroutine, not the test. t.Fatalf is t.Errorf plus
t.FailNow, and that one works like this:
FailNow marks the function as having failed and stops its execution by calling
runtime.Goexit (which then runs all deferred calls in the current goroutine).
runtime.Goexit ends the goroutine that called it. If that is a goroutine the
test started, that goroutine stops — and the test carries on:
3) t.Fatal FROM ANOTHER GOROUTINE
─────────────────────────────────
fatalgo_test.go:16: error in the goroutine
EVENT fatalgo: the test keeps working after t.Fatal
--- FAIL: TestFatalFromGoroutine (0.00s)
go vet:
cases/fatalgo/fatalgo_test.go:16:3: call to (*testing.T).Fatal from a non-test goroutine
The test is marked failed, but the code after waiting for the goroutine ran —
what t.Fatal was supposed to prevent happened. The documentation requires
calling FailNow only from the test's own goroutine, and go vet catches
such a call. From goroutines the test started, an error is passed back over a
channel or reported with t.Errorf: the Log and Error families may be
called from several goroutines.
t.Setenv and t.Chdir are incompatible with t.Parallel. Environment
variables and the current directory are shared by the whole process:
Because Setenv affects the whole process, it cannot be used in parallel tests
or tests with parallel ancestors.
This is not advice but a check: the package panics.
2) t.Setenv IN A PARALLEL TEST
──────────────────────────────
--- FAIL: TestSetenvParallel (0.00s)
panic: testing: test using t.Setenv or t.Chdir can not use t.Parallel [recovered]
Time in tests is substituted — testing/synctest. Code inside a
synctest.Run "bubble" lives by a synthetic clock: time moves forward when
every goroutine in the bubble is blocked. A sleep of a whole day:
4) testing/synctest (GOEXPERIMENT=synctest)
───────────────────────────────────────────
EVENT synctest: 24h0m0s passed inside the bubble
EVENT synctest: less than a second passed on the real clock: true
Tests of timeouts, retries and tickers stop waiting for real and stop
depending on how loaded the machine is. In an interview an honest answer about
synctest includes the version: in go1.24.7 it is an experiment.
Deeper: a benchmark that measures an empty loop
A classic interview problem — "what is wrong with this benchmark":
func BenchmarkDiscard(b *testing.B) {
for i := 0; i < b.N; i++ {
work(i)
}
}work is a pure function, a few multiplications and shifts. Nobody needs the
result. The compiler inlines the call and sees that its result is unused — and
there is no reason to compute it:
BenchmarkDiscard 0.32 ns/op
BenchmarkSink 1.65 ns/op
BenchmarkLoop 2.20 ns/op
Sink / Discard: 5.2
Best of 5 runs. What the compiler decided (-gcflags=-m):
bench_test.go:41:2: skip inlining within testing.B.loop for for loop
bench_test.go:27:7: inlining call to work
BenchmarkDiscard measured an empty loop. This is not a general rule that
"any benchmark with a discarded result is empty": the computation was thrown
away because work is pure and was inlined — after inlining the compiler
sees that nobody needs the result. What it decided in a particular case is
shown by -gcflags=-m, not by a guess. Two ways to protect the measurement:
b.Loop()(Go 1.24) — the way for new code: the compiler does not throw away calls to functions in the body of afor b.Loop() { ... }loop.- Keep the result in a package-level variable:
sink = work(i)— 1.65 ns. It is the old technique from beforeb.Loop; worth recognising in existing code.
The compiler never optimizes away calls to functions within the body of a "for
b.Loop() { ... }" loop.
The boundary of the promise is in the same documentation: in functions called by the loop, optimisations are performed as usual. How the compiler keeps the promise is a property of the version:
The -gcflags=-m output shows how it achieves this in go1.24.7: inside
b.Loop the call is not inlined (skip inlining within testing.B.loop).
Hence 2.20 ns against 1.65: b.Loop also measures the function call itself.
Neither variant is "right" in general — they measure slightly different
things, and in an interview the ability to say so is exactly what is valued.
And one last thing — about the numbers themselves. "Best of N" is good for an order-of-magnitude estimate, but not enough to conclude "it got faster": one lucky run says nothing about the spread. Ten runs of the same benchmarks:
5) BENCHMARK SPREAD: TEN RUNS
─────────────────────────────
min median max
BenchmarkDiscard 0.33 0.34 0.42 ns/op, 10 runs
BenchmarkSink 1.61 1.76 1.95 ns/op, 10 runs
"Best of N" throws the spread away. Conclusions about speed
compare distributions of series — that is what benchstat does.
To compare "before and after", a benchmark is run in series (-count=10) and
the series are compared with the benchstat tool from golang.org/x/perf: it
shows the centre of a series, the spread and whether the difference is
significant.
How to answer in an interview
Short answer: a test is a function TestXxx(t *testing.T); a table-driven
test is a slice of cases and one check in a loop; each case is a t.Run
subtest and can be run on its own with -run. t.Parallel runs subtests in
parallel, and shared resources must be released through t.Cleanup. Races are
hunted with go test -race, the cache is bypassed with -count=1.
That is enough for a correct answer. What follows is what you add if the interviewer digs.
If the interviewer digs deeper
On "what can go wrong with t.Parallel" — the order. "A parallel subtest
waits until the parent's body finishes. The parent's defer fires before the
subtests — in my run both subtests got a closed resource. So t.Cleanup, not
the parent's defer — or a group: t.Run does not return until its parallel
subtests have finished."
On the loop variable — go.mod. "Since Go 1.22 each iteration has its own
variable, but only in modules whose go.mod declares 1.22 or later. In a
module with go 1.21 on go1.24.7 all parallel subtests saw the last case."
On "why didn't the tests speed up" — -parallel. "No more run at once than
-parallel, and that defaults to GOMAXPROCS. Four sleeping subtests with
GOMAXPROCS=2 — 404 ms, not 200."
On "what is wrong with the benchmark" — the discarded result. "Nobody needs
the result of the pure inlined function, and the compiler threw the
computation away: 0.32 ns is an empty loop. Check with -gcflags=-m. In new
code — for b.Loop(); the old technique is keeping the result in a package
variable. And compare series with benchstat, not the best run."
On "the second run was instant" — the cache. "That is (cached): the tests
did not run. The cache knows nothing about the state of databases and external
services, so integration tests run with -count=1."
Next they ask
How do you test code that talks to a database or an external service?
Through an interface declared on the side of whoever uses the dependency: the code takes not a concrete client but an interface of the two or three methods it needs. The test passes its own implementation — a stub with canned answers. A mocking library is not required; embedding the interface in a stub struct lets you declare only the methods the test needs (that is covered in the lesson on embedding).
For HTTP the standard library has net/http/httptest: a test server on
loopback or a ResponseRecorder for calling a handler without the network.
How does t.Errorf differ from t.Fatalf?
t.Errorf marks the test failed and lets it carry on — handy when there are
several checks and you want to see all the mismatches at once. t.Fatalf
marks the test failed and stops it: there is no point going on, for example,
if a file could not be opened.
t.Fatalf is t.Errorf plus FailNow, and FailNow stops the current
goroutine through runtime.Goexit. So it must be called from the test's own
goroutine: from a goroutine the test started it stops only that goroutine,
and the test carries on (Mechanism 5). t.Errorf may be called from such
goroutines.
How do you find a data race in tests?
go test -race. But the detector sees only what happened in this run: a test
where one goroutine does everything will not produce a race, however many times
you run it. Useful tests for -race run several goroutines on shared state,
and are run more than once (-count). The detector's boundaries and its cost
are covered in the lesson on data races.
And the testing package itself marks a test failed if the detector found a
race during its execution — even if all the test's checks passed.
Which functions besides TestXxx does go test run?
Three more kinds. BenchmarkXxx(b *testing.B) — benchmarks, run only with the
-bench flag. FuzzXxx(f *testing.F) — fuzzing: the target receives randomly
generated inputs; without the -fuzz flag it runs on the seed corpus
(f.Add and testdata/fuzz) as an ordinary test, and failures it finds are
saved there and become regression tests. ExampleXxx() — an example: its
output is compared with the // Output: comment, and it is also shown in the
package documentation.
Why t.Helper()?
So that a failure points to where the helper was called, not to a line inside
it. A helper such as assertEqual(t, got, want) calls t.Errorf inside;
without t.Helper() the report shows the helper's line — the same one for
every check. With t.Helper() it shows the test line where the helper was
called.
Common misconceptions
t.Parallel runs all subtests at once
No more run at once than -parallel, and that defaults to GOMAXPROCS. Four subtests sleeping 200 ms each took 404 ms with GOMAXPROCS=2 — in two pairs.
A defer in a test closes the resource after the subtests
Not after parallel ones: they wait until the parent's body finishes, and defer fires exactly then. In the run both subtests got a closed resource. t.Cleanup is what is called after all subtests.
Since Go 1.22 the loop variable in closures is no longer a trap
Only in modules whose go.mod declares 1.22 or later. In a module with go 1.21 on the go1.24.7 compiler all three parallel subtests saw the last case of the table — and the test passed.
If go test passed, the code has just been checked
Not if the line says (cached): the tests did not run, a saved result was shown. For tests that depend on the outside world this is especially dangerous; bypass the cache with -count=1.
A benchmark of a function measures that function
Not always: in the run the compiler inlined the pure function, saw that the result was not needed and did not compute it — 0.32 ns against 1.65 ns when the result is kept. New code writes for b.Loop().
t.Fatal from any goroutine stops the test
It stops the goroutine that called it: FailNow calls runtime.Goexit. In the run the test kept working after t.Fatal in its goroutine, and go vet caught the call.
Practice
Two problems. Answer first, then check against the real output: in both, the correct answer is taken from a run of the script rather than assigned.
Practice · predict the output
func TestDB(t *testing.T) {
defer fmt.Println("defer: closed")
t.Cleanup(func() { fmt.Println("Cleanup: closed") })
t.Run("a", func(t *testing.T) {
t.Parallel()
fmt.Println("subtest a")
})
fmt.Println("end of body")
}Practice · estimate
Knowledge check
A test opens a database connection, closes it with defer and starts three subtests with t.Parallel(). What will the subtests see?
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- **A test in Go is a function
TestXxx(t *testing.T), and a table-driven test is data plus one check.** The cases sit in a slice of structs, the check is written once, and each case runs as a subtest viat.Rununder its own name. So one case can be run on its own:go test -run 'TestClamp/above'. t.Parallel()changes the order of execution, and that is the main source of bugs. A parallel subtest waits until the parent's body finishes. The parent'sdeferfires exactly then — before the subtests: in the run both subtests saw a resource that was already closed.t.Cleanupis called after all the subtests, and a group'st.Rundoes not return until its parallel subtests have finished. And the loop variable in parallel subtests depends on thegoline ingo.mod: in a module withgo 1.21all three subtests saw the last case of the table.- Beyond that comes what separates real understanding from having read about it.
t.Paralleldoes not mean "all at once": no more run at a time than-parallel, and that defaults to GOMAXPROCS. Four subtests sleeping 200 ms each took 404 ms with GOMAXPROCS=2, not 200. A repeatedgo testwith no changes does not run the tests but prints(cached)— even if the database the test talks to has changed since. And a benchmark that discards the result of a pure inlined function measured an empty loop in the run: 0.32 ns against 1.65 ns when the result is kept.
In fact
- No more run at once than
-parallel, and that defaults to GOMAXPROCS. Four subtests sleeping 200 ms each took 404 ms with GOMAXPROCS=2 — in two pairs. - Not after parallel ones: they wait until the parent's body finishes, and
deferfires exactly then. In the run both subtests got a closed resource.t.Cleanupis what is called after all subtests. - Only in modules whose
go.moddeclares 1.22 or later. In a module withgo 1.21on the go1.24.7 compiler all three parallel subtests saw the last case of the table — and the test passed. - Not if the line says
(cached): the tests did not run, a saved result was shown. For tests that depend on the outside world this is especially dangerous; bypass the cache with-count=1. - Not always: in the run the compiler inlined the pure function, saw that the result was not needed and did not compute it — 0.32 ns against 1.65 ns when the result is kept. New code writes
for b.Loop(). - It stops the goroutine that called it:
FailNowcallsruntime.Goexit. In the run the test kept working aftert.Fatalin its goroutine, andgo vetcaught the call.
What is covered
- What is really being asked
- Base: a table-driven test is subtests
- Mechanism 1: a parallel subtest waits for the parent to finish
- Mechanism 2: the loop variable is a matter of the `go` line in `go.mod`
- Mechanism 3: how many subtests run at once
- Mechanism 4: a repeated `go test` does not run the tests
- Mechanism 5: t.Fatal in a goroutine, t.Setenv and synthetic time
- Deeper: a benchmark that measures an empty loop
- How to answer in an interview
- Next they ask
- Common misconceptions
- Practice
- Knowledge check
Sources & further reading
6 SOURCES
- The testing package — T.Parallel and CleanupOfficial documentation. What t.Parallel promises: «Parallel signals that this test is to be run in parallel with (and only with) other parallel tests». And when Cleanup is called: «Cleanup registers a function to be called when the test (or subtest) and all its subtests complete».https://pkg.go.dev/testing
- The testing package — subtests, FailNow and SetenvOfficial documentation. The second way to wait for parallel subtests: «Run does not return until parallel subtests have completed, providing a way to clean up after a group of parallel tests». What t.Fatal does: «FailNow marks the function as having failed and stops its execution by calling runtime.Goexit». And the boundary of t.Setenv: «Because Setenv affects the whole process, it cannot be used in parallel tests or tests with parallel ancestors».https://pkg.go.dev/testing#hdr-Subtests_and_Sub_benchmarks
- The testing package — B.Loop, go1.24Official documentation. Why b.Loop protects a measurement: «The compiler never optimizes away calls to functions within the body of a "for b.Loop() { ... }" loop». And the boundary of that protection: «Optimizations are performed as usual in any functions called by the loop».https://pkg.go.dev/testing#B.Loop
- The go command — testing flags and the cacheOfficial documentation. How many parallel tests run at once: «By default, -parallel is set to the value of GOMAXPROCS». What «(cached)» means: «When this happens, go test prints '(cached)' in place of the elapsed time in the summary line». And how to bypass the cache: «The idiomatic way to disable test caching explicitly is to use -count=1».https://pkg.go.dev/cmd/go#hdr-Testing_flags
- The Go Specification — For statementsOfficial documentation. The new loop-variable semantics: «Each iteration has its own separate declared variable (or variables)» — marked Go 1.22. Which semantics applies is decided by the module's language version, that is, the go line in go.mod; the lesson checks this with a run.https://go.dev/ref/spec#For_clause
- The testing/synctest package, go1.24.7Official documentation. The status of the package in the version the run was made on: «This package only exists when using Go compiled with GOEXPERIMENT=synctest. It is experimental, and not subject to the Go 1 compatibility promise». The status in newer versions is per their documentation.https://pkg.go.dev/testing/synctest@go1.24.7