Three green tests locally, two red on CI. The outcome depends on how busy the build
server is that day. This isn't a logic bug — it's a time.Sleep that
assumes 100 ms is always enough for a goroutine to finish.
That test doesn't verify anything. It gambles.
testing/synctest, stable since Go 1.25 (August 2025), solves this
at the root. No more guesswork, no more arbitrary waits: the clock advances on demand,
inside an isolated bubble.
The test that lies
Debounce is a classic example of time-dependent concurrent code: trigger an action only if no call has happened in the last N milliseconds. Here's a simple implementation.
func Debounce(fn func(), delay time.Duration) func() {
var timer *time.Timer
var mu sync.Mutex
return func() {
mu.Lock()
defer mu.Unlock()
if timer != nil {
timer.Stop()
}
timer = time.AfterFunc(delay, fn)
}
}
And the naive test that goes with it:
// ✗ Fragile
func TestDebounce(t *testing.T) {
called := 0
debounced := Debounce(func() { called++ }, 50*time.Millisecond)
debounced()
debounced()
debounced()
time.Sleep(200 * time.Millisecond) // fingers crossed
if called != 1 {
t.Fatalf("want 1 call, got %d", called)
}
}
This passes locally because your machine is fast and 200 ms feels like plenty.
On a loaded CI runner, the time.AfterFunc goroutine may not have had
a chance to run by the time called is read. Flaky. No error message
explains why.
go test -race won't catch this either. There's no concurrent memory
access at the same instant — the problem is purely temporal. Two different tools,
two different failure modes.
What testing/synctest does
testing/synctest runs your test inside a "bubble". Inside the bubble,
the time package uses a fake clock controlled by the test. That clock
doesn't tick on its own: it only advances when
all goroutines in the bubble are durably blocked — waiting on a
channel, a mutex, a timer — but not on a syscall or network I/O.
The Go 1.25 API has exactly two functions:
-
synctest.Test(t, f)— runsfin a new bubble and waits for every goroutine in that bubble to exit before returning to the test framework. -
synctest.Wait()— blocks until all other goroutines in the bubble are durably blocked. The explicit synchronization point: "background work is done for now."
Go 1.24 had an experimental preview behind GOEXPERIMENT=synctest
with a slightly different API (synctest.Run() instead of
synctest.Test()). Go 1.25 graduated the package to the standard
library and finalized the API. No flags, no build tags — direct import.
Before and after
The same debounce test, rewritten with testing/synctest:
// ✓ Deterministic
import "testing/synctest"
func TestDebounce(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
called := 0
debounced := Debounce(func() { called++ }, 50*time.Millisecond)
debounced()
debounced()
debounced()
// Advances the virtual clock by 100 ms instantly.
// The AfterFunc goroutine (fires at 50 ms) wakes up along the way.
time.Sleep(100 * time.Millisecond)
synctest.Wait()
if called != 1 {
t.Fatalf("want 1 call, got %d", called)
}
})
}
What happens inside the bubble:
-
The three
debounced()calls each schedule atime.AfterFuncat 50 ms, canceling the previous one. -
time.Sleep(100ms)blocks the test goroutine. All goroutines in the bubble are now durably blocked, so the runtime advances the virtual clock to 50 ms. TheAfterFuncgoroutine wakes up and runscalled++. -
The clock continues to 100 ms. The test goroutine wakes up.
synctest.Wait()confirms theAfterFuncgoroutine has finished. -
The assertion on
calledis reliable. Always. No gambling.
Actual wall-clock time for the test: microseconds, not 200 ms.
What synctest doesn't cover
testing/synctest isn't a silver bullet. Three categories slip through.
Network I/O. A goroutine blocked on a network read isn't
"durably blocked" in synctest's terms — an external process can unblock it at any
time. If your code makes real HTTP calls, synctest.Wait() can't reason
about its state. Fix: replace the network layer with an interface backed by a
deterministic test implementation (net.Pipe(),
httptest.Server, or a hand-rolled mock).
Syscalls and cgo. Same reason: the Go runtime doesn't control what the OS or C code does in the background. These calls are not considered durably blocking.
Package-level sync.WaitGroup
(var wg sync.WaitGroup). These can't be associated with a bubble.
Declare them inside the test function instead.
Ideal use cases: timers, tickers, debounce, rate limiters, TTL caches, retry with
backoff — anything that chains time.After, time.Sleep,
or time.AfterFunc with goroutines communicating over channels.
Conclusion
go test -race remains the right tool for real data races.
testing/synctest targets a different class of failure: tests whose
reliability depends on wall-clock timing. The two tools are orthogonal — use both.
If you have time.Sleep in your Go tests to "give the goroutine time
to finish," that's the signal. testing/synctest will do the job
faster and correctly. Go 1.25 has been out since August 2025 — no flags, no
configuration, just import it.
📚 Related reading. If goroutines that never exit are your problem, the natural follow-up is Goroutine leaks in Go: detect, understand, fix.