A hands-on tour of Go's testing/synctest package: deterministic tests for concurrent code with fake clocks, bubble isolation, and zero flakiness — with runnable examples you can edit in your browser.
gosynctesttestingconcurrencyinteractivetour
This post was generated with AI assistance and hasn't been fully verified, some details may be inaccurate. Report an error.
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 synctest requires a real *testing.T, each snippet uses a thin testing.Main wrapper 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:
go1.26
interactive
package mainimport ( "fmt" "time")func main() { delivered := make(chan bool, 1) // Simulate: a notification fires only after a trigger trigger := make(chan struct{}) go func() { <-trigger delivered <- true }() // "Has it fired?" — wait and hope select { case <-delivered: fmt.Println("FAIL: fired before trigger") case <-time.After(50 * time.Millisecond): fmt.Println("OK: not fired yet (but we waited 50 ms to be sure)") } // Trigger and check again close(trigger) select { case <-delivered: fmt.Println("OK: fired after trigger") case <-time.After(50 * time.Millisecond): fmt.Println("FAIL: timed out waiting") }}
99
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
packagemain
import(
"fmt"
"time"
)
funcmain(){
delivered:= make(chanbool, 1)
// Simulate: a notification fires only after a trigger
trigger:= make(chanstruct{})
gofunc(){
<-trigger
delivered<-true
}()
// "Has it fired?" — wait and hope
select{
case<-delivered:
fmt.Println("FAIL: fired before trigger")
case<-time.After(50*time.Millisecond):
fmt.Println("OK: not fired yet (but we waited 50 ms to be sure)")
}
// Trigger and check again
close(trigger)
select{
case<-delivered:
fmt.Println("OK: fired after trigger")
case<-time.After(50*time.Millisecond):
fmt.Println("FAIL: timed out waiting")
}
}
package main
import (
"fmt"
"time"
)
func main() {
delivered := make(chan bool, 1)
// Simulate: a notification fires only after a trigger
trigger := make(chan struct{})
go func() {
<-trigger
delivered <- true
}()
// "Has it fired?" — wait and hope
select {
case <-delivered:
fmt.Println("FAIL: fired before trigger")
case <-time.After(50 * time.Millisecond):
fmt.Println("OK: not fired yet (but we waited 50 ms to be sure)")
}
// Trigger and check again
close(trigger)
select {
case <-delivered:
fmt.Println("OK: fired after trigger")
case <-time.After(50 * time.Millisecond):
fmt.Println("FAIL: timed out waiting")
}
}
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:
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:
go1.26
interactive
package mainimport ( "context" "fmt" "sync" "testing" "testing/synctest" "time")// RunHealthCheck calls check every interval until ctx is canceled.func RunHealthCheck(ctx context.Context, interval time.Duration, check func() error, onResult func(ok bool)) { for { select { case <-ctx.Done(): return case <-time.After(interval): if err := check(); err != nil { onResult(false) } else { onResult(true) } } }}func TestHealthCheck(t *testing.T) { synctest.Test(t, func(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) callCount := 0 results := []bool{} check := func() error { callCount++ if callCount == 2 { return fmt.Errorf("service down") } return nil } var wg sync.WaitGroup wg.Go(func() { RunHealthCheck(ctx, 30*time.Second, check, func(ok bool) { results = append(results, ok) }) }) // Nothing happened yet synctest.Wait() fmt.Printf("t=0s results=%v\n", results) // Advance past the first check time.Sleep(30*time.Second + time.Millisecond) synctest.Wait() fmt.Printf("t=30s results=%v\n", results) // Second check — this one fails time.Sleep(30 * time.Second) synctest.Wait() fmt.Printf("t=60s results=%v\n", results) // Third check — healthy again time.Sleep(30 * time.Second) synctest.Wait() fmt.Printf("t=90s results=%v\n", results) cancel() wg.Wait() })}func main() { testing.Main( func(_, _ string) (bool, error) { return true, nil }, []testing.InternalTest{{Name: "TestHealthCheck", F: TestHealthCheck}}, nil, nil, )}
99
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
packagemain
import(
"context"
"fmt"
"sync"
"testing"
"testing/synctest"
"time"
)
// RunHealthCheck calls check every interval until ctx is canceled.
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:
go1.26
interactive
package mainimport ( "fmt" "sync" "testing" "testing/synctest" "time")// Debounce returns a function that delays calling action// until delay has passed since the last invocation.func Debounce(delay time.Duration, action func()) func() { var mu sync.Mutex var timer *time.Timer return func() { mu.Lock() defer mu.Unlock() if timer != nil { timer.Stop() } timer = time.AfterFunc(delay, action) }}func TestDebounce(t *testing.T) { synctest.Test(t, func(t *testing.T) { calls := 0 trigger := Debounce(500*time.Millisecond, func() { calls++ }) // Fire three times in quick succession trigger() time.Sleep(100 * time.Millisecond) trigger() time.Sleep(100 * time.Millisecond) trigger() // last call resets the timer // 400 ms after the last trigger — not enough time.Sleep(400 * time.Millisecond) synctest.Wait() fmt.Println("At 400 ms after last trigger:", calls) // 0 // 100 ms more — debounce fires time.Sleep(100 * time.Millisecond) synctest.Wait() fmt.Println("At 500 ms after last trigger:", calls) // 1 })}func main() { testing.Main( func(_, _ string) (bool, error) { return true, nil }, []testing.InternalTest{{Name: "TestDebounce", F: TestDebounce}}, nil, nil, )}
99
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
packagemain
import(
"fmt"
"sync"
"testing"
"testing/synctest"
"time"
)
// Debounce returns a function that delays calling action
// until delay has passed since the last invocation.
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.WaitGroup becomes linked to a bubble on the first Add or Go call.
sync.Cond.Wait is durably blocking; waking it from outside the bubble is a fatal error.
Cleanup functions and finalizers registered with runtime.AddCleanup or runtime.SetFinalizer run outside any bubble.
synctest.Test waits 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
Comments