How does testing/synctest (Go 1.25) make concurrent, time-dependent code testable?
Question 182HardGo 1.22 to 1.25
synctest.Test(t, f) runs f in a new goroutine inside an isolated bubble. Goroutines started from it belong to the bubble, and the bubble has a fake clock that starts at 2000-01-01 00:00 UTC. Time moves forward only when every goroutine in the bubble is durably blocked, for example on a channel created in the bubble, time.Sleep, sync.Cond, sync.WaitGroup, or a select over bubble channels. The clock then jumps straight to the next timer. synctest.Wait() blocks until all other goroutines in the bubble are durably blocked.
func TestContextTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
time.Sleep(5*time.Second - time.Nanosecond) // returns instantly
synctest.Wait()
if err := ctx.Err(); err != nil {
t.Fatalf("expired too early: %v", err)
}
time.Sleep(time.Nanosecond)
synctest.Wait()
if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
t.Fatal("not expired after deadline")
}
})
}
Key points:
- The test runs in microseconds and is deterministic. There are no real sleeps and no flaky timing margins.
- If all bubble goroutines are blocked and no timer can wake them,
Testfails with a deadlock report.Testalso waits for every bubble goroutine to exit, so leaks are caught. - Blocking on a mutex, on real I/O such as a network connection, or on a channel created outside the bubble is not durable. Use in-memory fakes such as
net.Pipefor network code. - Go 1.24 shipped it as an experiment (
GOEXPERIMENT=synctest, withsynctest.Run). Go 1.25 made it generally available withsynctest.Test, andRunis deprecated.
More on Goroutines & the Scheduler
- Q180What does the Go memory model guarantee about starting and ending a goroutine?
- Q181A CPU profile shows lots of time in runtime.findRunnable, stealWork, futex and usleep. What is going on and how do you fix it?