Testing Concurrent Go Code Without a Single time.Sleep
Every Go codebase with any concurrency in it eventually grows a test that sleeps and hopes:
d.Trigger()
time.Sleep(150 * time.Millisecond)
if calls.Load() != 1 {
t.Fatal("debounce did not fire")
}
It works, mostly. On your laptop. On a loaded CI runner it occasionally doesn't, so someone bumps the sleep to 300ms "to be safe". Now the suite is a few seconds slower and still fails once a month for no reason anyone can reproduce locally.
Multiply that across a few dozen tests for retries, debounces, rate limits and timeouts, and you've got a test suite that's both slow and untrustworthy. That's a genuinely annoying combination.
testing/synctest, stable since Go 1.25, is the stdlib's answer. Run the concurrent code against a fake clock inside an isolated "bubble", and let the runtime itself decide when time should advance, rather than the test author guessing at a sleep that's hopefully long enough.
The bubble gives your test its own fake clock
The entry point is synctest.Test:
func Test(t *testing.T, f func(t *testing.T))
f runs inside a new bubble: an isolated scheduling domain with its own fake clock that starts at midnight UTC on 1 January 2000. Any goroutine spawned from within f is part of the same bubble.
Inside the bubble, time.Now, time.Sleep, timers and tickers all use the fake clock instead of the real one, with no changes needed to the code under test.
You don't need to inject a clock interface or wrap time.Now behind a seam just to make something testable. That used to be the standard workaround; there are whole small packages on pkg.go.dev whose entire job is providing a fake-clock interface for exactly this reason.
Time only moves when everyone is stuck
The clock only advances when every goroutine in the bubble is "durably blocked", meaning it can only be woken by something else inside the same bubble.
When that happens, the runtime fast-forwards straight to the next scheduled timer event rather than idling. So an hour of fake sleep runs in microseconds of real time.
synctest.Wait() is the other half of the API. It blocks the calling goroutine until every other goroutine in the bubble has reached that durably-blocked state. It's how you assert "background work has definitely settled" without a race.
A debouncer, tested without waiting
Here's a small debouncer, the kind of thing you'd use to coalesce a burst of filesystem-change events into a single reload:
package debounce
import (
"sync"
"time"
)
type Debouncer struct {
mu sync.Mutex
timer *time.Timer
delay time.Duration
fn func()
}
func New(delay time.Duration, fn func()) *Debouncer {
return &Debouncer{delay: delay, fn: fn}
}
func (d *Debouncer) Trigger() {
d.mu.Lock()
defer d.mu.Unlock()
if d.timer != nil {
d.timer.Stop()
}
d.timer = time.AfterFunc(d.delay, d.fn)
}
The behaviour worth testing:
- A
Triggercall resets the delay. - So a steady stream of triggers arriving faster than the delay should never fire the callback.
- It should fire exactly once after things go quiet.
That's miserable to write with real sleeps. You need several distinct waits with different timings, and every one of them is a chance for CI jitter to cause a false failure.
package debounce
import (
"sync/atomic"
"testing"
"testing/synctest"
"time"
)
func TestDebouncer(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
var calls atomic.Int32
d := New(100*time.Millisecond, func() { calls.Add(1) })
d.Trigger()
time.Sleep(50 * time.Millisecond)
d.Trigger() // resets the 100ms delay
time.Sleep(50 * time.Millisecond)
synctest.Wait()
if got := calls.Load(); got != 0 {
t.Fatalf("fired early: got %d calls, want 0", got)
}
time.Sleep(60 * time.Millisecond)
synctest.Wait()
if got := calls.Load(); got != 1 {
t.Fatalf("got %d calls, want 1", got)
}
})
}
The two Trigger calls are 50ms apart, so the first timer never gets a chance to fire. Only the second one, 100ms after the last trigger, does.
The test asserts that with fake-clock precision and no real elapsed time worth mentioning. It'll pass as reliably on a busy CI box as on your desk, because there's no real scheduler jitter involved at any point. You could change the delay to an hour and it would still run instantly.
There's also a synctest.Sleep(d) helper that's just time.Sleep(d) followed by an automatic Wait(), for the common case where you want both in sequence.
What counts as "durably blocked", and what doesn't
This decides what your test can rely on. Durably blocking operations, the ones that count towards the bubble going idle and the clock advancing, are:
- channel send, receive and select, on channels created inside the bubble
time.Sleep, and timers/tickers created inside the bubblesync.WaitGroup.Waitsync.Cond.Wait
Deliberately excluded: sync.Mutex.Lock. A goroutine outside the bubble could in principle be holding that mutex, so the runtime can't treat "blocked on Lock" as something only bubble-internal code can resolve, and doesn't try.
If your test's quiescence depends on a mutex-protected code path settling, Wait() won't wait for it. Use a channel or a WaitGroup for that synchronisation instead.
Real network I/O and blocking syscalls are excluded for the same reason, and a practical one besides. "Goroutines blocked on network I/O prevent a bubble from becoming idle" rather than counting as durably blocked, so a goroutine stuck on a genuine socket read just sits there instead of letting the clock advance.
Testing something built on net/http? Use net.Pipe or an in-memory transport rather than a real httptest.Server socket if you want it to cooperate with the fake clock.
Deadlocks become test failures instead of hangs
Hang on, the durably-blocked rule has a lovely side effect. If every goroutine in the bubble is durably blocked and there's no pending timer left to advance to, that's an actual deadlock. Test panics immediately rather than letting the test hang until CI kills it after ten minutes.
The panic message tells you exactly which goroutines were stuck and where. A test that previously needed a timeout wrapper to fail cleanly now just fails, fast, with a stack trace pointing at the real problem.
The same mechanism catches goroutine leaks. Test waits for every goroutine spawned inside the bubble to exit before it returns, so a background goroutine that never terminates shows up as a deadlock panic rather than silently leaking for the rest of the test binary's life.
Don't let bubbled things cross the boundary
One sharp edge worth knowing before you hit it in a stack trace: a bubbled channel, timer, ticker, WaitGroup or Cond can't safely cross the bubble boundary.
- Operating on a bubbled channel, timer or ticker from a goroutine outside the bubble panics outright.
- Touching a bubbled
WaitGroupor waking a bubbledCond.Waitfrom outside is a fatal error.
In practice: don't hand a channel created inside synctest.Test off to some long-lived background goroutine that was started before the test began.
On Go 1.24 it was Run, behind an experiment
If you're still on Go 1.24, the package existed behind GOEXPERIMENT=synctest with a slightly different shape: synctest.Run(f func()) instead of Test, and no automatic association between a bubble panic and the enclosing *testing.T.
Run is now deprecated in favour of Test, which is the one worth reaching for in anything currently being written.