Go 1.25 testing/synctest: No More Flaky Concurrent Tests

Three green tests locally, two red on CI. The outcome depends on how busy the build server is that day. This isn't a logic bug — it's a time.Sleep that assumes 100 ms is always enough for a goroutine to finish.

That test doesn't verify anything. It gambles.

testing/synctest, stable since Go 1.25 (August 2025), solves this at the root. No more guesswork, no more arbitrary waits: the clock advances on demand, inside an isolated bubble.

The test that lies

Debounce is a classic example of time-dependent concurrent code: trigger an action only if no call has happened in the last N milliseconds. Here's a simple implementation.

func Debounce(fn func(), delay time.Duration) func() {
    var timer *time.Timer
    var mu sync.Mutex
    return func() {
        mu.Lock()
        defer mu.Unlock()
        if timer != nil {
            timer.Stop()
        }
        timer = time.AfterFunc(delay, fn)
    }
}

And the naive test that goes with it:

// ✗ Fragile
func TestDebounce(t *testing.T) {
    called := 0
    debounced := Debounce(func() { called++ }, 50*time.Millisecond)

    debounced()
    debounced()
    debounced()

    time.Sleep(200 * time.Millisecond) // fingers crossed

    if called != 1 {
        t.Fatalf("want 1 call, got %d", called)
    }
}

This passes locally because your machine is fast and 200 ms feels like plenty. On a loaded CI runner, the time.AfterFunc goroutine may not have had a chance to run by the time called is read. Flaky. No error message explains why.

go test -race won't catch this either. There's no concurrent memory access at the same instant — the problem is purely temporal. Two different tools, two different failure modes.

What testing/synctest does

testing/synctest runs your test inside a "bubble". Inside the bubble, the time package uses a fake clock controlled by the test. That clock doesn't tick on its own: it only advances when all goroutines in the bubble are durably blocked — waiting on a channel, a mutex, a timer — but not on a syscall or network I/O.

The Go 1.25 API has exactly two functions:

  • synctest.Test(t, f) — runs f in a new bubble and waits for every goroutine in that bubble to exit before returning to the test framework.
  • synctest.Wait() — blocks until all other goroutines in the bubble are durably blocked. The explicit synchronization point: "background work is done for now."

Go 1.24 had an experimental preview behind GOEXPERIMENT=synctest with a slightly different API (synctest.Run() instead of synctest.Test()). Go 1.25 graduated the package to the standard library and finalized the API. No flags, no build tags — direct import.

Before and after

The same debounce test, rewritten with testing/synctest:

// ✓ Deterministic
import "testing/synctest"

func TestDebounce(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        called := 0
        debounced := Debounce(func() { called++ }, 50*time.Millisecond)

        debounced()
        debounced()
        debounced()

        // Advances the virtual clock by 100 ms instantly.
        // The AfterFunc goroutine (fires at 50 ms) wakes up along the way.
        time.Sleep(100 * time.Millisecond)
        synctest.Wait()

        if called != 1 {
            t.Fatalf("want 1 call, got %d", called)
        }
    })
}

What happens inside the bubble:

  1. The three debounced() calls each schedule a time.AfterFunc at 50 ms, canceling the previous one.
  2. time.Sleep(100ms) blocks the test goroutine. All goroutines in the bubble are now durably blocked, so the runtime advances the virtual clock to 50 ms. The AfterFunc goroutine wakes up and runs called++.
  3. The clock continues to 100 ms. The test goroutine wakes up. synctest.Wait() confirms the AfterFunc goroutine has finished.
  4. The assertion on called is reliable. Always. No gambling.

Actual wall-clock time for the test: microseconds, not 200 ms.

What synctest doesn't cover

testing/synctest isn't a silver bullet. Three categories slip through.

Network I/O. A goroutine blocked on a network read isn't "durably blocked" in synctest's terms — an external process can unblock it at any time. If your code makes real HTTP calls, synctest.Wait() can't reason about its state. Fix: replace the network layer with an interface backed by a deterministic test implementation (net.Pipe(), httptest.Server, or a hand-rolled mock).

Syscalls and cgo. Same reason: the Go runtime doesn't control what the OS or C code does in the background. These calls are not considered durably blocking.

Package-level sync.WaitGroup (var wg sync.WaitGroup). These can't be associated with a bubble. Declare them inside the test function instead.

Ideal use cases: timers, tickers, debounce, rate limiters, TTL caches, retry with backoff — anything that chains time.After, time.Sleep, or time.AfterFunc with goroutines communicating over channels.

Conclusion

go test -race remains the right tool for real data races. testing/synctest targets a different class of failure: tests whose reliability depends on wall-clock timing. The two tools are orthogonal — use both.

If you have time.Sleep in your Go tests to "give the goroutine time to finish," that's the signal. testing/synctest will do the job faster and correctly. Go 1.25 has been out since August 2025 — no flags, no configuration, just import it.

📚 Related reading. If goroutines that never exit are your problem, the natural follow-up is Goroutine leaks in Go: detect, understand, fix.

Comments (0)