Introduction
Testing concurrent code in Go can be challenging, especially when dealing with timing issues. One common problem is ensuring goroutines, Go’s lightweight threads, have finished their tasks. Traditional techniques often incorporate time.Sleep, but this can slow down tests or make them unreliable.
Fortunately, Go 1.25 introduced the testing/synctest package into the standard library (after first being available experimentally in Go 1.24). It helps tackle these issues by providing a deterministic way to test concurrent code without the usual timing trade-offs.
The Problem with Traditional Tests
Goroutines are key to Go’s concurrency model, allowing functions to run independently. However, when testing goroutines, you might face synchronization issues. Here’s an example function:
func RefreshCacheAfterDelay(cache *Cache) {
go func() {
time.Sleep(30 * time.Second)
cache.Refresh()
}()
}
To test such functions, you might use time.Sleep to wait until the goroutine completes:
func TestRefreshCacheAfterDelay(t *testing.T) {
cache := NewCache()
RefreshCacheAfterDelay(cache)
time.Sleep(31 * time.Second)
assert.True(t, cache.Refreshed())
}
This method works, but every execution waits an unnecessary 31 seconds, making the test inefficient. You may try to shorten the wait like this:
func TestRefreshCacheAfterDelay(t *testing.T) {
cache := NewCache()
RefreshCacheAfterDelay(cache)
time.Sleep(100 * time.Millisecond)
assert.True(t, cache.Refreshed())
}
This speeds up the test, but it introduces another problem. If the goroutine hasn’t completed within the 100ms window, the test fails. It might pass consistently on your machine but fail on a slower CI runner or under heavier system load.
The problem isn’t that 100ms is too short. The problem is that there is no “correct” duration. Sleeping longer makes the test slower, while sleeping less makes it flaky.
Another common scenario involves timeout logic:
func WaitForResult(result <-chan string, timeout time.Duration) string {
select {
case value := <-result:
return value
case <-time.After(timeout):
return "timeout"
}
}
Testing functions like this often depends on timing and goroutine scheduling, making them difficult to test consistently across different environments.
Enter synctest
To solve these timing issues, Go introduced the testing/synctest package. With synctest, your test runs inside a controlled environment called a bubble, where timers and context deadlines use a fake clock instead of the real wall clock.
Here’s how it changes the StartReportWorker test:
func TestStartReportWorker(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
done := make(chan struct{})
StartReportWorker(done)
time.Sleep(10 * time.Second)
synctest.Wait()
<-done
})
}
Although the worker sleeps for 10 seconds, the test finishes almost instantly because only the fake clock advances.
The same applies even if the worker waits much longer:
func StartDailyReportWorker(done chan<- struct{}) {
go func() {
time.Sleep(24 * time.Hour)
close(done)
}()
}
func TestStartDailyReportWorker(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
done := make(chan struct{})
StartDailyReportWorker(done)
time.Sleep(24 * time.Hour)
synctest.Wait()
<-done
})
}
Although the worker sleeps for 24 hours, the test still finishes almost instantly. That’s because the fake clock advances automatically instead of waiting for real time to pass.
Understanding the synctest Bubble
The word bubble appears frequently in the synctest documentation, but the concept is actually quite simple.
A bubble is an isolated environment created by synctest.Test(). Any goroutines started inside the bubble are tracked by synctest, and timers such as time.Sleep, time.After, and context deadlines all use a fake clock instead of the real wall clock.
synctest.Test(t, func(t *testing.T) {
// Everything here runs inside the bubble.
})
Code running outside the bubble behaves normally. Only code created within the bubble uses the fake clock and participates in synctest’s synchronization.
What Does synctest.Wait() Actually Do?
A common misconception is that synctest.Wait() works like time.Sleep(), but they serve completely different purposes.
time.Sleep() advances the fake clock.
synctest.Wait() waits until every goroutine inside the bubble reaches a blocked state, ensuring all work triggered by the current point in time has completed.
For example:
go func() {
time.Sleep(24 * time.Hour)
close(done)
}()
time.Sleep(24 * time.Hour)
synctest.Wait()
In this example, the call to time.Sleep() advances the fake clock by 24 hours, allowing the goroutine to wake up and close the channel. synctest.Wait() then waits until all goroutines have finished the work triggered by that time advancement before the test continues.
You’ll often see these two functions used together: one advances time, while the other synchronizes goroutines.
Testing Timeouts
Here’s an example of how synctest simplifies testing timeout logic:
func RunJob(ctx context.Context) error {
select {
case <-time.After(16 * time.Minute):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
Testing it becomes straightforward:
func TestRunJobTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
done := make(chan error, 1)
go func() {
done <- RunJob(ctx)
}()
time.Sleep(15 * time.Minute)
synctest.Wait()
assert.ErrorIs(t, <-done, context.DeadlineExceeded)
})
}
Notice that the job waits for 16 minutes while the context times out after 15 minutes. This guarantees that the context deadline is reached first.
If both durations were 15 minutes, both cases in the select could become ready at the same time. Since Go randomly chooses between ready cases, the function could return either nil or context.DeadlineExceeded, making the test nondeterministic.
Channels Are Different
synctest primarily controls time. It makes timers, sleeps, and context deadlines deterministic, but it doesn’t automatically generate channel events for you.
go func() {
value := <-input
output <- value * 2
}()
// synctest.Wait() synchronizes goroutines,
// but you still need to trigger the channel operation.
input <- 10
synctest.Wait()
assert.Equal(t, 20, <-output)
While synctest handles timer-based goroutines exceptionally well, channel communication still behaves exactly as it does in production code. If your test expects a value to be sent or received on a channel, your test (or another goroutine) still needs to perform that operation.
In other words, synctest removes the uncertainty around when something happens, but it doesn’t invent events that your program never triggers.
Final Thoughts
The biggest advantage of synctest isn’t that it removes time.Sleep from your tests—it removes real time.
Instead of guessing how long to wait for a goroutine, you can let the fake clock advance instantly and use synctest.Wait() to synchronize everything before making assertions.
Once you get used to this model, testing concurrent code becomes much more straightforward. Timeout logic, retry loops, scheduled work, and long-running background goroutines can all be tested in milliseconds without sacrificing correctness.
For me, the biggest takeaway from synctest is that it changes how you think about testing concurrent code. Rather than trying to coordinate goroutines with carefully chosen sleep durations, you let the fake clock advance to the point you want to test and let synctest handle the synchronization. The result is a test suite that’s both faster and far more deterministic.
If you’re writing Go code that relies on timers or context deadlines, testing/synctest is well worth adding to your testing toolbox.
Enjoyed this article? Support my work with a coffee ☕ on Ko-fi.