Skip to main content

Go's testing/synctest — Hands-On

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 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:

FunctionPurpose
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:

go1.26
interactive
package main
import (
"fmt"
"testing"
"testing/synctest"
"time"
)
func TestFakeClock(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now()
fmt.Println("Start:", start.Format(time.DateTime))
time.Sleep(24 * time.Hour)
fmt.Printf("After 24 h sleep: %v elapsed\n", time.Since(start))
})
}
func main() {
testing.Main(
func(_, _ string) (bool, error) { return true, nil },
[]testing.InternalTest{{Name: "TestFakeClock", F: TestFakeClock}},
nil, nil,
)
}

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:

go1.26
interactive
package main
import (
"fmt"
"sync"
"testing"
"testing/synctest"
"time"
)
func TestTimeOrdering(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
events := []string{}
var wg sync.WaitGroup
wg.Add(3)
go func() {
defer wg.Done()
time.Sleep(3 * time.Second)
events = append(events, "C (3 s)")
}()
go func() {
defer wg.Done()
time.Sleep(1 * time.Second)
events = append(events, "A (1 s)")
}()
go func() {
defer wg.Done()
time.Sleep(2 * time.Second)
events = append(events, "B (2 s)")
}()
wg.Wait()
fmt.Println(events) // always [A (1 s) B (2 s) C (3 s)]
})

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:

go1.26
interactive
package main
import (
"fmt"
"testing"
"testing/synctest"
)
func TestWaitSemantics(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
log := []string{}
ch := make(chan struct{})
go func() {
<-ch // blocks until ch is closed
log = append(log, "unblocked")
}()
// After Wait returns, the goroutine above is confirmed blocked on <-ch.
synctest.Wait()
fmt.Println("Before close:", log) // []
close(ch)
synctest.Wait()
fmt.Println("After close:", log) // [unblocked]
})
}
func main() {
testing.Main(
func(_, _ string) (bool, error) { return true, nil },
[]testing.InternalTest{{Name: "TestWaitSemantics", F: TestWaitSemantics}},
nil, nil,
)
}

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 blockingNOT durably blocking
Send/receive on a channel created inside the bubblesync.Mutex / sync.RWMutex locking
select where every case uses a bubble channelNetwork I/O (reads, writes, accepts)
time.SleepSystem 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 main
import (
"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++

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 main
import (
"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()

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:

go1.26
interactive
package main
import (
"context"
"fmt"
"testing"
"testing/synctest"
"time"
)
// Pipeline starts a producer that emits a counter every interval
// and a consumer that collects the values.
func Pipeline(ctx context.Context, interval time.Duration) (produced, consumed *[]int) {
p := &[]int{}
c := &[]int{}
ch := make(chan int, 1)
go func() {
n := 0
defer close(ch)
for {
select {
case <-ctx.Done():
return
case <-time.After(interval):
n++
*p = append(*p, n)
ch <- n
}
}
}()
go func() {
for v := range ch {
*c = append(*c, v)
}

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

ConceptDetails
APIsynctest.Test(t, f) + synctest.Wait() — two functions, full package
Fake clockStarts at 2000-01-01 00:00:00 UTC; advances only when all goroutines block
Durably blockingChannel ops (bubble channels), time.Sleep, sync.WaitGroup.Wait, sync.Cond.Wait
Not blockingMutexes, network I/O, syscalls, channels created outside the bubble
NetworkingUse net.Pipe for in-memory connections that work with Wait
IsolationBubble channels/timers panic if used from outside; WaitGroup binds on first Add/Go
HistoryExperimental in Go 1.24 (GOEXPERIMENT=synctest); stable since Go 1.25
Cleanupsynctest.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

0/280
Go's testing/synctest — Hands-On