Testing Concurrent Code Using the Experimental 'testing/synctest' Package
The testing/synctest
package helps solve two common testing problems:
- Flaky tests involving time and concurrency
- Slow tests that wait for time to pass
How It Works
The package provides a controlled environment called a “bubble” where:
- Time starts at 2000-01-01 00:00:00 UTC
- Time only advances when needed
- The runtime controls all timing
- Tests run instantly instead of waiting for real time
Basic Usage
import (
"testing"
"testing/synctest"
"time"
)
func TestTimeOperation(t *testing.T) {
synctest.Run(func() {
start := time.Now()
time.Sleep(time.Hour) // This returns instantly!
end := time.Now()
// Will be exactly 1 hour
duration := end.Sub(start)
})
}
When Goroutines Are Idle
Time advances in a bubble when all goroutines are idle. A goroutine is idle when:
- Waiting on
time.Sleep
- Sending/receiving on a channel created in the bubble
- Using
select
with only bubble-created channels - Calling
sync.Cond.Wait
A goroutine is NOT idle when:
- Making system calls
- Making CGO calls
- Doing I/O operations
- Using mutexes
Common Use Cases
Testing Timeouts
func TestTimeout(t *testing.T) {
synctest.Run(func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
// This runs instantly but behaves as if a second passed
<-ctx.Done()
})
}
Testing Tickers
func TestTicker(t *testing.T) {
synctest.Run(func() {
ticker := time.NewTicker(time.Millisecond)
defer ticker.Stop()
count := 0
for i := 0; i < 1000; i++ {
<-ticker.C
count++
}
// This completes instantly despite simulating 1 second of ticks
})
}
Synchronization Points
func TestSync(t *testing.T) {
synctest.Run(func() {
ch := make(chan bool)
go func() {
time.Sleep(time.Second)
ch <- true
}()
synctest.Wait() // Waits for all goroutines to be idle
<-ch // Guaranteed to have data
})
}
Key Restrictions
-
Channel Rules
- Channels created in a bubble must stay in that bubble
- Using bubble channels from outside causes panic
-
Timer Rules
- Time only advances when all goroutines are idle
- Timers and tickers must stay in their bubble
-
Deadlock Detection
- The bubble panics if all goroutines are idle with no pending timers
Setup Requirements
This is still experimental, so you’ll need to enable it, even when Go1.24 is released.
# For Go 1.24 and later
export GOEXPERIMENT=synctest
go test ./...