· 6 min read ·

Go 1.25's Test Bubbles Fix the Right Problem in Async Testing

Source: go

For years, testing time-dependent Go code carried a tax that had nothing to do with the logic being tested. To verify that a retry loop backed off correctly, or that a context deadline fired at the right moment, you either peppered your tests with time.Sleep calls and hoped CI wasn’t slow that day, or you modified production code to accept a Clock interface so tests could swap in a fake one. Both approaches work. Neither is good. The first is flaky and slow. The second deforms production code to accommodate test infrastructure, and the Clock interface ends up threaded through constructors, function signatures, and struct fields that have no business knowing about time abstraction.

Go 1.25 graduates testing/synctest to stable, and it addresses this by moving the fake clock into the test runtime itself rather than into your application code.

What a bubble actually is

The central concept in testing/synctest is the bubble. When you call synctest.Test, your test function runs inside an isolated execution environment with its own synthetic clock, starting at midnight January 1, 2000 UTC. Real wall-clock time does not advance inside the bubble. The clock only advances when every goroutine spawned within the bubble is durably blocked.

func TestContextDeadline(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        deadline := time.Now().Add(1 * time.Second)
        ctx, cancel := context.WithDeadline(t.Context(), deadline)
        defer cancel()

        time.Sleep(time.Until(deadline))
        synctest.Wait()

        if ctx.Err() != context.DeadlineExceeded {
            t.Fatal("context not canceled after deadline")
        }
    })
}

This test completes in microseconds. The time.Sleep inside the bubble uses the fake clock, so calling it with a one-second duration does not wait one real second. It advances synthetic time by one second, which fires the context deadline, which the assertion then checks. No sleep in real time, no flakiness on a loaded CI machine.

The synctest.Wait() call is the other half of the picture. It blocks until all goroutines inside the bubble are durably blocked, meaning blocked only on things the runtime can fully account for: channel operations on channels created inside the bubble, time.Sleep, sync.WaitGroup.Wait, and sync.Cond.Wait. Once everything is durably blocked, Wait returns and your assertions run in a quiescent state. There are no more goroutines mid-flight that could still be writing the values you are about to read.

The insight behind durable blocking

The distinction between “blocked” and “durably blocked” is doing significant work here. A goroutine blocked on a mutex is not durably blocked in the synctest model, because acquiring a mutex depends on another goroutine releasing it, which could happen at any point. The runtime cannot determine that the system as a whole is quiescent when mutex-blocked goroutines exist. But a goroutine sleeping for 500ms inside the bubble is in a state the runtime can schedule: nothing external will unblock it, and the fake clock fully controls when it resumes.

This is what makes the whole thing composable. When you call synctest.Wait(), you get a guarantee: the state you are about to observe reflects all work that logically should have happened before this point. That guarantee is hard to get right with real time, because “all work that should have happened” depends on timing assumptions that vary between machines.

The package also randomizes the execution order of events scheduled for the same synthetic time. This was added between the experimental 1.24 release and the stable 1.25 release specifically to surface tests that were accidentally depending on a particular ordering. If your test passes only when goroutine A fires before goroutine B at the same tick, the randomization will find that assumption.

What this replaces, and what it does not

The Clock interface pattern was widespread enough that most large Go codebases have their own version of it. The canonical form looks something like this:

type Clock interface {
    Now() time.Time
    Sleep(d time.Duration)
    After(d time.Duration) <-chan time.Time
}

type RealClock struct{}
func (RealClock) Now() time.Time                         { return time.Now() }
func (RealClock) Sleep(d time.Duration)                  { time.Sleep(d) }
func (RealClock) After(d time.Duration) <-chan time.Time  { return time.After(d) }

You inject RealClock in production and a controlled fake in tests. This works, but it means every component that does anything time-related needs to accept a Clock parameter. Constructors grow an extra argument. Functions that should have no concept of test infrastructure start carrying a Clock field. The production behavior is unchanged, but the production code is shaped around a testing requirement.

testing/synctest eliminates this for the cases it covers. Code that calls time.Sleep directly, uses context.WithDeadline, or relies on time.After can now be tested with fake time without any changes to its signature or its struct fields. The test wraps the execution in a bubble and the runtime handles the rest.

That said, synctest has well-defined boundaries. It does not detect goroutine leaks. A goroutine that escapes the bubble and continues running after the test exits is invisible to synctest. For that, goleak from Uber is still the right tool. The two are complementary: synctest controls time and waits for in-bubble concurrency to quiesce; goleak catches goroutines that were never supposed to outlive the test.

Real network I/O is also outside synctest’s scope. A goroutine blocked on reading from a real net.Conn is not durably blocked in the synctest model, because the unblock comes from outside the bubble. The stdlib’s net.Pipe() gives you a synchronous, in-memory net.Conn that works inside bubbles, and that covers many use cases. But if your code under test opens a real listener or dials a real address, synctest cannot help with timing around that.

The path from experimental to stable

The package was available under GOEXPERIMENT=synctest in Go 1.24. Between 1.24 and 1.25, the API changed in ways that reflect what the team learned during the experimental period. The entry point was renamed from Run to Test and now accepts a *testing.T directly rather than returning a new one. The *testing.T passed to the callback is scoped to the bubble, so t.Context(), the context-per-test feature added in Go 1.21, integrates cleanly with the fake clock. A test that sets a deadline via t.Context() gets that deadline evaluated against synthetic time inside the bubble.

Cross-bubble unblocking was explicitly removed in 1.25. In 1.24, a goroutine outside the bubble could unblock one inside it; the 1.25 release made bubbles fully isolated. Deadlock detection also improved: when the test function returns with goroutines still blocked inside the bubble, the panic now shows only the bubble’s goroutine stacks, not every goroutine in the process. This makes the diagnostic output useful rather than overwhelming.

Damien Neil presented the package at GopherCon Europe 2025, and the graduation to stable means the API is now frozen. It is safe to take a dependency on testing/synctest in shared test infrastructure without worrying about breaking changes in the next release.

In practice

The most immediate win is for code that uses context.WithTimeout or context.WithDeadline in ways that matter to correctness. Retry logic with exponential backoff, connection pools that expire idle connections, background workers that poll on a schedule: all of these previously required either a sleep-based test (slow and flaky) or a Clock interface (production-code overhead). With testing/synctest, you write the code naturally and wrap the test in a bubble.

The pattern also helps with the harder case: asserting that something does not happen within a window. Suppose you want to verify that a message is not delivered before a delay expires. With real time, you sleep the full delay and then check. Inside a bubble, you advance synthetic time to just before the delay, call synctest.Wait(), assert the message has not arrived, advance past the delay, wait again, and assert it has arrived. The test is deterministic and fast.

The official blog post and the package documentation both have more examples. The release notes for Go 1.25 cover the changes from the experimental version. If you have been living with Clock interfaces or sleeping in tests, this is worth adopting.

Was this interesting?