Tests for concurrent Go code often become timing tests by accident. A goroutine starts, the test sleeps for 20 milliseconds, then checks whether something happened. That can pass thousands of times and still fail on a loaded CI runner because the sleep never proved the goroutine reached the state you cared about.
Go’s testing/synctest package gives these tests a better model. It runs code inside an isolated bubble where time is virtualized, and synctest.Wait can synchronize the test with goroutines in that bubble. The result is especially useful for code built around timers, context deadlines, retries, and background goroutines.
testing/synctest is part of the standard library starting with Go 1.25. If a project still supports an older Go release, that version requirement is the first trade-off to consider.
Why sleeping in a concurrent test doesn’t prove readiness
Consider a worker that marks itself ready after some asynchronous setup:
func startWorker(ready *atomic.Bool) {
go func() {
// Imagine some in-memory setup here.
ready.Store(true)
}()
}A tempting test is:
func TestWorker(t *testing.T) {
var ready atomic.Bool
startWorker(&ready)
time.Sleep(10 * time.Millisecond)
if !ready.Load() {
t.Fatal("worker did not become ready")
}
}The sleep is only a scheduling guess. Ten milliseconds is usually enough, but “usually” isn’t a useful correctness condition. Making the delay longer reduces the chance of failure while making the test suite slower. It still doesn’t establish that the worker has run.
With testing/synctest, the test can wait for the bubble to become quiescent instead:
func TestWorker(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ready := false
go func() {
ready = true
}()
synctest.Wait()
if !ready {
t.Fatal("worker did not become ready")
}
})
}Wait returns when the other goroutines in the bubble are durably blocked. It also provides synchronization recognized by Go’s race detector, so the plain bool access above is synchronized by the call to Wait. Removing that synchronization and reading the value concurrently would still be a data race.
This distinction matters: virtual time and synchronization solve different problems. A timer can advance the fake clock, but elapsed time by itself isn’t a synchronization primitive for arbitrary memory accesses.
Use testing/synctest to test time without waiting for wall-clock time
The package becomes particularly useful when production code uses the ordinary time package. You don’t need to thread a custom clock interface through the application just to make a timer test fast.
Suppose a function reports when a delay has elapsed:
func afterDelay(d time.Duration) <-chan struct{} {
done := make(chan struct{})
go func() {
time.Sleep(d)
close(done)
}()
return done
}A test can check both sides of the deadline precisely:
func TestAfterDelay(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
const delay = 5 * time.Second
done := afterDelay(delay)
time.Sleep(delay - time.Nanosecond)
synctest.Wait()
select {
case <-done:
t.Fatal("completed before the delay")
default:
}
time.Sleep(time.Nanosecond)
synctest.Wait()
select {
case <-done:
// Expected.
default:
t.Fatal("did not complete after the delay")
}
})
}Inside the bubble, time.Sleep uses the bubble’s fake clock. Time advances when every goroutine in the bubble is durably blocked, so the five-second logical delay doesn’t require a five-second wall-clock wait.
Notice the synctest.Wait calls after sleeping. Reaching the timer deadline makes the sleeping goroutine runnable; it doesn’t mean that goroutine has already executed close(done) before the test goroutine makes its assertion. Wait lets that asynchronous work settle before the test inspects the result.
The same pattern works well for time.Timer, time.Ticker, context.WithTimeout, and code that schedules work with time-based delays.
Test context deadlines at the boundary you actually care about
Context timeout tests are often written with generous real-time margins: set a 50 millisecond timeout, wait 100 milliseconds, then expect context.DeadlineExceeded. That verifies eventual expiration but says little about the actual boundary, and every such test adds real delay.
With testing/synctest, you can test immediately before and at the deadline:
func TestContextDeadline(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
const timeout = 30 * time.Second
ctx, cancel := context.WithTimeout(t.Context(), timeout)
defer cancel()
time.Sleep(timeout - time.Nanosecond)
synctest.Wait()
if err := ctx.Err(); err != nil {
t.Fatalf("before deadline: ctx.Err() = %v, want nil", err)
}
time.Sleep(time.Nanosecond)
synctest.Wait()
if err := ctx.Err(); err != context.DeadlineExceeded {
t.Fatalf("at deadline: ctx.Err() = %v, want %v", err, context.DeadlineExceeded)
}
})
}This is a stronger test than “wait longer than the timeout.” It checks the behavior on both sides of the boundary while still using the same context and time APIs as production code.
Using the *testing.T passed into the synctest.Test callback is intentional. Its context is associated with the bubble, and cleanup registered on that T runs inside the bubble as well.
Understand what synctest.Wait is waiting for
The phrase “wait for goroutines” can be misleading. synctest.Wait doesn’t wait for every goroutine to exit. It waits until every other goroutine in the current bubble is durably blocked.
A goroutine blocked receiving from a channel created inside the bubble can be durably blocked because only activity associated with the bubble can make progress relevant to that wait. time.Sleep is also understood by the bubble’s virtual-time machinery.
External I/O is different. A network read might become ready because the kernel receives a packet, and an external process might complete at an unpredictable time. The Go runtime can’t treat those operations as purely controlled by goroutines inside the bubble.
That leads to a practical rule: use testing/synctest for concurrency whose progress you can keep inside the test. If the code normally talks to a network service, filesystem watcher, subprocess, or another external system, put an in-memory fake at that boundary rather than expecting the bubble to make external I/O deterministic.
This isn’t just a limitation. It forces the test to distinguish two concerns that are easy to mix together: whether your concurrent state machine behaves correctly, and whether an external integration behaves correctly. The former is a good fit for synctest; the latter usually belongs in a separate integration test.
Keep channels and goroutines inside the bubble
A synctest test should be self-contained. Create the channels, timers, contexts, and goroutines it uses from inside the synctest.Test callback whenever possible.
For example:
func TestBackgroundLoopStops(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
stop := make(chan struct{})
stopped := make(chan struct{})
go func() {
defer close(stopped)
for {
select {
case <-stop:
return
case <-time.After(time.Minute):
// Periodic work.
}
}
}()
close(stop)
synctest.Wait()
select {
case <-stopped:
default:
t.Fatal("background loop did not stop")
}
})
}Keeping the test’s synchronization objects inside the bubble makes ownership clear and avoids interactions with goroutines outside it. The package documentation specifically recommends avoiding goroutines that weren’t started from the test, external processes, and real network activity.
There’s another useful guardrail: synctest.Test waits for goroutines in its bubble to exit before returning, and a deadlocked bubble fails the test. A background goroutine that has no shutdown path can therefore turn into a visible test failure instead of quietly leaking into later tests.
Common mistakes when adopting testing/synctest
The first mistake is treating Wait as a replacement for every synchronization mechanism. Production code still needs correct channels, mutexes, atomics, or other synchronization. Wait is a testing primitive for reaching a stable point in the bubble; it doesn’t repair races in the code under test.
Another mistake is assuming that a fake-clock sleep means all timer-triggered work has completed. When a deadline makes a goroutine runnable, call synctest.Wait before inspecting state that goroutine is expected to update.
Real network connections are also a poor fit. Even loopback traffic passes through the operating system, so the runtime can’t reliably decide that a goroutine waiting on that connection is durably blocked. Prefer an in-memory transport or a small fake tailored to the protocol boundary you’re testing.
Finally, check the project’s minimum Go version before converting a large test suite. The stable testing/synctest API with synctest.Test arrived in Go 1.25. Go 1.24 exposed an experimental version behind GOEXPERIMENT=synctest with a different API, including synctest.Run. Copying an older example into a Go 1.25-or-newer codebase can therefore produce needless confusion.
A good first target is a flaky timeout test
You don’t need to rewrite every concurrent test at once. Start with a test that currently uses time.Sleep to wait for a goroutine, timer, retry, or context timeout. Put the behavior inside synctest.Test, replace timing margins with exact logical durations, and use synctest.Wait at the points where asynchronous work needs to settle before an assertion.
Then run that test with the race detector as well as the normal test command. testing/synctest makes scheduling and time easier to control, but the race detector remains useful for catching memory accesses that aren’t actually synchronized.
The goal isn’t merely faster tests. It’s to replace “this should have happened by now” with a test that can state exactly when the behavior should happen and exactly when the concurrent work has reached a stable point.