Testing concurrent Go code has always been a balancing act between speed and reliability. The testing/synctest package (experimental in Go 1.24, stable since Go 1.25) eliminates that trade-off. It wraps your test in an isolated bubble with a fake clock, deterministic goroutine scheduling, and instant time advancement. Two functions, zero flakiness.
Every snippet runs on Go 1.26 via Codapi sandboxes directly in your browser, no local toolchain required. Because
synctestrequires a real*testing.T, each snippet uses a thintesting.Mainwrapper to run the test function.
The timing trap
Polling and hoping
The classic way to check whether a goroutine did something is to wait a little and then peek:
Two problems: the test is slow (100 ms of real wall-clock time for two assertions) and fragile (an overloaded CI machine can make 50 ms feel like nothing). Multiply that by hundreds of tests and you're stuck choosing between a fast suite that sometimes lies and a slow suite that sometimes tells the truth.
Enter synctest
Two functions, that's it
The entire testing/synctest API is:
| Function | Purpose |
|---|---|
synctest.Test(t, f) | Run f in a new bubble; wait for all goroutines to exit before returning |
synctest.Wait() | Block until every other goroutine in the bubble is durably blocked |
A bubble is an isolated environment. Every goroutine started inside belongs to it. The runtime tracks when all of them are stuck (on a channel, a WaitGroup, or time.Sleep) and only then decides what to do next: return from Wait, advance the fake clock, or panic on deadlock.
Fake clock: time runs instantly
Inside the bubble, time.Now() starts at midnight UTC 2000-01-01 and advances only when every goroutine is blocked. A 24-hour sleep completes in microseconds:
No mock clocks, no dependency injection, no interface wrappers; time.Sleep and time.Now just work.
Multiple goroutines sleeping different amounts are unblocked in order of their wakeup times:
In a real program those goroutines might race. Inside the bubble the order is deterministic: the shortest sleep wakes first, then the next, and so on.
synctest.Wait: peeking at goroutine state
Wait blocks the calling goroutine until every other goroutine in the bubble is durably blocked. Once it returns, you know nothing else will happen until you take the next action. That turns "assert something hasn't happened" from a guess into a fact:
No race condition: the log slice is safe to read after Wait returns because every other goroutine is confirmed blocked. The race detector understands Wait calls; remove one and it'll rightfully complain.
What counts as "durably blocked"
Not every blocking call qualifies. The runtime only considers a goroutine durably blocked when nothing outside the bubble can unblock it:
| Durably blocking | NOT durably blocking |
|---|---|
| Send/receive on a channel created inside the bubble | sync.Mutex / sync.RWMutex locking |
select where every case uses a bubble channel | Network I/O (reads, writes, accepts) |
time.Sleep | System calls |
sync.WaitGroup.Wait (if Add/Go was called inside the bubble) | Channels created outside the bubble |
sync.Cond.Wait |
Mutexes are excluded deliberately; they're typically held briefly and may involve global state. Network I/O is excluded because a socket could be unblocked by a write from a completely different process.
The implication: network code needs a fake transport (like net.Pipe) to participate in the blocking model. More on that below.
Practical patterns
Testing a periodic health checker
Real systems are full of background loops. Here's a health checker that pings a service every 30 seconds, exactly the kind of code that's miserable to test with real time:
90 seconds of simulated time, zero seconds of real time. And because Wait confirms the loop has settled after each sleep, the results slice is always in the expected state, no flaky assertions.
Testing a debouncer
Debouncers collapse rapid calls into one delayed action. Without synctest, testing the timing is basically guesswork. With it, you get millisecond precision:
The action fires exactly once, exactly 500 ms after the last call. Try changing the delay or the trigger timing and re-run; the results always match.
Testing a producer–consumer pipeline
synctest shines when you need to verify ordering across multiple goroutines:
Three seconds of simulated pipeline, deterministic results. The producer emits exactly three values and the consumer has received all of them by the time Wait returns.
Networking: use net.Pipe
I/O operations are not durably blocking; the runtime can't know whether a network read will be resolved by something outside the bubble. To test network code with synctest, replace real connections with net.Pipe:
func TestEchoServer(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
client, server := net.Pipe() // in-memory, fully synchronous
defer client.Close()
defer server.Close()
// Start echo handler
go func() {
buf := make([]byte, 256)
for {
n, err := server.Read(buf)
if err != nil {
return
}
server.Write(buf[:n])
}
}()
// Send and receive
go func() {
client.Write([]byte("ping"))
}()
resp := make([]byte, 4)
client.Read(resp)
if string(resp) != "ping" {
t.Fatalf("got %q, want %q", resp, "ping")
}
})
}net.Pipe creates a synchronous, in-memory connection pair. Reads and writes on piped connections behave like channel operations, so synctest.Wait correctly identifies when goroutines are idle.
This is the pattern for testing HTTP clients, WebSocket handlers, gRPC streams, or any code that touches the network.
Isolation rules
Channels, timers, and tickers created inside a bubble belong to it. Using a bubbled channel from outside panics at runtime. This keeps the isolation water-tight:
- A
sync.WaitGroupbecomes linked to a bubble on the firstAddorGocall. sync.Cond.Waitis durably blocking; waking it from outside the bubble is a fatal error.- Cleanup functions and finalizers registered with
runtime.AddCleanuporruntime.SetFinalizerrun outside any bubble. synctest.Testwaits for all bubble goroutines to exit before returning. If they deadlock, the test panics, no silent hangs.
A practical consequence: if your code under test uses package-level channels or WaitGroups initialized at startup, you may need to restructure so those primitives are created inside the test.
Quick reference
| Concept | Details |
|---|---|
| API | synctest.Test(t, f) + synctest.Wait(): two functions, full package |
| Fake clock | Starts at 2000-01-01 00:00:00 UTC; advances only when all goroutines block |
| Durably blocking | Channel ops (bubble channels), time.Sleep, sync.WaitGroup.Wait, sync.Cond.Wait |
| Not blocking | Mutexes, network I/O, syscalls, channels created outside the bubble |
| Networking | Use net.Pipe for in-memory connections that work with Wait |
| Isolation | Bubble channels/timers panic if used from outside; WaitGroup binds on first Add/Go |
| History | Experimental in Go 1.24 (GOEXPERIMENT=synctest); stable since Go 1.25 |
| Cleanup | synctest.Test waits for all bubble goroutines; deadlocks cause a panic |
For full documentation, see the testing/synctest package docs and the official Go blog post.
Comments