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)
}
}
Enter fullscreen mode Exit fullscreen mode
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)
}
}
Enter fullscreen mode Exit fullscreen mode
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)
}
})
}
Enter fullscreen mode Exit fullscreen mode
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.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.