Testing Async Code with synctest
Testing Async Code with synctest
The last chapter used a Clock interface when you own time. Often the code under test already calls time.Sleep, time.After, or context.WithTimeout. Do not rewrite production APIs just to make a test finish. Prefer testing/synctest: run the test in a bubble with a fake clock so five seconds of deadline logic finishes in a blink. Stable. Fast. No GOEXPERIMENT on Go 1.25+ (this book is on 1.27).
Mental model
synctest.Test(t, f)runsfinside a new bubble. Goroutines started infstay in that bubble.- Inside a bubble,
timeuses a fake clock. It always starts at midnight UTC 2000-01-01. - Time advances only when every goroutine in the bubble is durably blocked (waiting on something only another bubble goroutine can unblock):
time.Sleep, channel send/receive on channels created in the bubble,WaitGroup.Waittied to the bubble, and a few related cases. synctest.Wait()blocks until every other goroutine in the bubble is durably blocked. Use it after you expect background work (or a timer callback) to have run.t.Context()inside the bubble is tied to the bubble. Prefer it overcontext.Background()in these tests.- Real network sockets are not durably blocking. Prefer
net.Pipe,httptest, or fakes — or usehttptest.NewTestServer(Go 1.27) when you need HTTP inside a bubble. - Go 1.27 adds
synctest.Sleep(d):time.Sleep(d)plusWaitin one call. Use it when you only need “advance and quiesce.”
A Clock interface is still the right boring default when your package decides what “now” means. Reach for synctest when the standard library’s timers are already on the path.
Worked examples
Case 1: A desk hold that times out
HoldTicket blocks until the context is done. The test proves it still holds just before the deadline and returns context.DeadlineExceeded after.
Empty directory:
go mod init deskSave as hold.go:
// hold.go
package desk
import (
"context"
)
// HoldTicket blocks until ctx is done, then returns ctx.Err().
func HoldTicket(ctx context.Context) error {
<-ctx.Done()
return ctx.Err()
}Save as hold_test.go:
// hold_test.go
package desk
import (
"context"
"testing"
"testing/synctest"
"time"
)
func TestHoldTicketTimesOut(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
const timeout = 5 * time.Second
ctx, cancel := context.WithTimeout(t.Context(), timeout)
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- HoldTicket(ctx)
}()
// Just before the deadline: still holding.
time.Sleep(timeout - time.Nanosecond)
synctest.Wait()
select {
case err := <-errCh:
t.Fatalf("ticket released early: %v", err)
default:
}
// Cross the deadline (fake clock — runs instantly).
time.Sleep(time.Nanosecond)
synctest.Wait()
if err := <-errCh; err != context.DeadlineExceeded {
t.Fatalf("got %v, want DeadlineExceeded", err)
}
})
}Run:
go testOutput (the duration varies):
PASS
ok desk 0.004s
Five seconds of timeout logic, milliseconds of wall clock. The Wait after each Sleep lets the context package’s timer goroutines finish before you assert.
Case 2: Wait before you assert
Without Wait, a test can race the background goroutine. After cancel, wait for quiescence, then check the flag.
Save as hold_wait_test.go (same module as Case 1):
// hold_wait_test.go
package desk
import (
"testing"
"testing/synctest"
)
func TestHoldTicketReleasesOnCancel(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
released := false
ctx, cancel := context.WithCancel(t.Context())
go func() {
_ = HoldTicket(ctx)
released = true
}()
synctest.Wait()
if released {
t.Fatal("released before cancel")
}
cancel()
synctest.Wait()
if !released {
t.Fatal("not released after cancel")
}
})
}Run:
go testOutput (the duration varies):
PASS
ok desk 0.003s
Wait is not a sleep. It means “every other goroutine in this bubble is stuck waiting on something inside the bubble.”
Case 3: synctest.Sleep (Go 1.27)
When the pattern is always “sleep, then wait for the bubble to settle,” use the helper.
Save as hold_sleep_test.go:
// hold_sleep_test.go
package desk
import (
"testing"
"testing/synctest"
"time"
)
func TestSynctestSleepAdvancesClock(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now() // midnight UTC 2000-01-01 in the bubble
done := false
go func() {
time.Sleep(2 * time.Second)
done = true
}()
synctest.Sleep(2 * time.Second) // Sleep + Wait
if !done {
t.Fatal("background sleep not finished")
}
if got := time.Since(start); got != 2*time.Second {
t.Fatalf("since start = %v, want 2s", got)
}
})
}Run:
go test -run TestSynctestSleepAdvancesClockOutput (the duration varies):
PASS
ok desk 0.002s
Prefer the explicit time.Sleep + synctest.Wait pair when you need an assert between advancing the clock and waiting. Prefer synctest.Sleep when you only need both.
The trap
A wall-clock sleep outside a bubble is slow and flaky. The “test” below can pass on a quiet laptop and fail under load — and it wastes two seconds every run.
Save as trap_test.go (do not keep this pattern):
// trap_test.go
package desk
import (
"context"
"testing"
"time"
)
func TestHoldTicketWallClockTrap(t *testing.T) {
t.Skip("trap demo — slow and flaky; use synctest instead")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- HoldTicket(ctx)
}()
time.Sleep(3 * time.Second) // real wall clock
if err := <-errCh; err != context.DeadlineExceeded {
t.Fatalf("got %v, want DeadlineExceeded", err)
}
}Run (shows the skip):
go test -run TestHoldTicketWallClockTrap -vOutput:
=== RUN TestHoldTicketWallClockTrap
trap_test.go:11: trap demo — slow and flaky; use synctest instead
--- SKIP: TestHoldTicketWallClockTrap (0.00s)
PASS
ok desk 0.002s
Second trap: blocking on a real network socket inside a bubble. That wait is not durably blocked, so Wait and the fake clock can deadlock or hang. Use net.Pipe, in-memory fakes, or httptest.NewTestServer (1.27) when HTTP must live in the bubble.
The boring rule
- Prefer sync APIs when you control the design. Prefer synctest when
time/contexttimers are already in the path. - Always wrap async timer tests in
synctest.Test. Uset.Context()inside the bubble. - After you expect background work or a timer to fire, call
synctest.Wait(orsynctest.Sleep) before asserting. - Keep a
Clockinterface when your package owns “now.” Do not invent a clock just to avoid learning synctest. - Do not use
GOEXPERIMENT=synctest. That was for the 1.24 experiment. On 1.25+ the package is ordinary stdlib. - Do not dial the real network inside a bubble. Fake the wire.
Try this
- Start from Case 1. Before the deadline, assert
ctx.Err() == nilon the parent context (not only thaterrChis empty). - Replace
WithTimeoutwithWithCancelandcontext.AfterFuncthat sets abool. Cancel,Wait, assert the flag — same shape as the package docs. - Change Case 3 to
synctest.Sleep(1 * time.Second)while the background goroutine sleeps two seconds. Predict whetherdoneis true, run the test, then fix the sleep so it passes. - Optional: move Case 1’s two-step sleep into one
synctest.Sleep(timeout)and assert only after the deadline. What coverage did you lose?