Notes from Alexander Baygeldin’s GopherCon 2026 talk, “(Sync)testing Concurrent Code with Confidence.”
Testing concurrent code used to mean picking one of three bad options:
hacky (poke at internals), flaky (time.Sleep(100 * time.Millisecond)
and hope), or slow (sleep long enough that it’s reliable). testing/synctest
removes the tradeoff by giving the test a fake clock.
The fake clock
Inside synctest.Test, time doesn’t pass on its own. It jumps forward
only when every goroutine in the bubble is blocked. A one-hour timeout
resolves instantly and deterministically.
func TestCacheExpiry(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
c := NewCache(time.Hour)
c.Set("k", "v")
time.Sleep(time.Hour + time.Second) // instant
if _, ok := c.Get("k"); ok {
t.Error("entry should have expired")
}
})
}
No real sleep, no flake, no arbitrary duration constant chosen because it passed on your laptop.
Waiting for goroutines, not for time
synctest.Wait blocks until every other goroutine in the bubble is
durably blocked. It replaces the “sleep and hope the worker got there”
pattern outright.
func TestWorkerConsumes(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ch := make(chan int)
go worker(ch)
ch <- 1
synctest.Wait() // worker has processed it, or is blocked again
// assert on post-processing state
})
}
The constraints are the point
The bubble only knows about blocking it can see: channels, mutexes,
time.Sleep, sync.WaitGroup. Real I/O — a network read, a syscall — is
opaque to it, so a goroutine parked there is not “durably blocked” and
the clock will not advance.
That sounds like a limitation. Baygeldin’s argument is that it’s a design signal: code that can’t be tested under synctest usually has its I/O tangled up with its coordination logic. Separate them — push the network call behind an interface, keep the timing and orchestration pure — and both the tests and the design get better.
Go 1.27 adds synctest.Sleep() as a helper. Small addition, but it
signals the package is settling in rather than being reworked.