Spotlight: New in Go 1.25: the synctest package
Go 1.25 is expected to arrive in August. With the release of Go 1.25RC1, the changes to the language, the stdlib, and the toolchain are stable enough to start having a look what's to come.
In this Spotlight, I explore the new package synctest
that helps to test asynchronous behavior.
What is synctest, and how does it work?
Package synctest
introduces two functions, Test()
and Wait()
. The Test()
function executes a function inside an isolated environment called “bubble”:
func TestTiming(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// everything in here belongs to "The Bubble"
})
}
Interesting things happen inside a bubble!
In a nutshell:
Wall-clock time stands still while code executes, unless all goroutines in the bubble are “durably blocked”; that is, they are blocked and cannot be unblocked from outside the bubble.
If all goroutines in the bubble are durably blocked, and the main goroutine is blocked by a call to Wait()
, then Wait()
returns instantly to let the main goroutine continue.
If all goroutines are durably blocked and no Wait()
call is pending, the time instantly advances to the next point in time that unblocks a goroutine, such as the end of a Sleep()
call or the timeout of a Context
.
Only if no Wait()
call or a timing event is pending, the runtime panics.
What does “durably blocked” mean?
A goroutine in a bubble is durably blocked if only another goroutine in the bubble can unblock it. The following operations durably block a goroutine:
- Calling
time.Sleep()
- When a send or receive on a channel created within the bubble blocks
- When a select statement blocks on channels created within the bubble
- Calling
sync.Cond.Wait()
Goroutines are not durably blocked if they
- lock a
sync.(RW)Mutex
- block on system I/O
- do system calls
because all of these can be unblocked from outside the bubble.
What problems does synctest solve?
All the above sounds kinda sophisticated, but what does synctest
buy us?
Testing timing behavior of goroutines has two major problems:
- Slow tests
- Unreliable behavior
Let me look at both in turn.
How synctest speeds up slow tests (tremendously)
Concurrent tests may need to test timeouts or other time-related behavior, as in this test of a context timeout:
func TestTiming(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now()
ctx, _ := context.WithTimeout(t.Context(), 5*time.Second)
<-ctx.Done()
t.Log(time.Since(start))
})
}
The timeout is set to 5 seconds, yet the whole test finishes within a fraction of a second:
> go1.25rc1 test -v
=== RUN TestTiming
synctest_test.go:15: 5s
--- PASS: TestTiming (0.00s)
PASS
ok mod/path/to/synctest 0.153s
How synctest fixes flaky tests through synthetic time
Testing concurrent events can sometimes be non-deterministic. To illustrate this, let me add a test that “proves” that the timeout has not occurred yet.
Also, because a five seconds timeout makes the test terribly slow, I change the timeout to 5 microseconds.
Without the synctest.Test()
harness, the test turns out to generate some errors:
func TestTiming(t *testing.T) {
start := time.Now()
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Microsecond)
defer cancel()
if err := ctx.Err(); err != nil {
t.Fatalf("Should not be timed out yet, got: %v", err)
}
<-ctx.Done()
if err := ctx.Err(); err != context.DeadlineExceeded {
t.Fatalf("Expected DeadlineExceeded, got: %v", err)
}
t.Log(time.Since(start))
}
The following Bash command pipeline runs the test 1000 times, looks for occurrences of got:
, counts them, and prints the deduplicated messages:
> go1.25rc1 test -v -count=1000 | grep 'got:' | tee >(wc -l) | uniq
synctest_test.go:18: Should not be timed out yet, got: context deadline exceeded
9
In nine cases, the test detected a context timeout when it shouldn't.
Add synctest.Test()
back to the test (Playground link) and the same Bash command never lists a single error. The synthetic time concept inside the bubble replaces linearly moving time by a deterministic ordering of timed events.
Shortening the timeout for tests speeds up testing but makes the test more unreliable. The timeout timer, which runs in a separate goroutine, may stop earlier than the main goroutine reaches the end of its Sleep()
call.
In fact, running this test reveals a quite significant count of errors:
> go1.25rc1 test -v -count=1000 | grep 'got:' | tee >(wc -l) | uniq
synctest_test.go:16: Should not be timed out yet, got: context deadline exceeded
575
In more than half of the tests, the context timed out before the main goroutine finished sleeping.
Adding the sycntest
bubble back fixes the problem instantly:
go1.25rc1 test -v -count=1000 | grep 'got:' | tee >(wc -l) | uniq
0
I could even change the timeout back to 5 seconds and still run 1,000 tests in seconds.
How synctest fixes flaky tests with Wait()
Now let's change the test a bit: I want to verify if the timeout happens after the specified duration. Inside the bubble, the synthetic time allows my test to advance the time to just one nanosecond before the timeout. Then I add a Wait()
call to ensure the test runs only if all other goroutines (read: the timer's goroutine) are durably blocked and no concurrent activity is going on.
Then the code advances by another nanosecond and calls Wait() again, to ensure the timer (that should have triggered by then) has ceased any concrrent activity before testing the context error.
The resulting code looks like this:
func TestTiming(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now()
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
time.Sleep(5*time.Second - time.Nanosecond)
synctest.Wait()
if err := ctx.Err(); err != nil {
t.Fatalf("Should not be timed out yet, got: %v", err)
}
time.Sleep(time.Nanosecond)
synctest.Wait()
if err := ctx.Err(); err != context.DeadlineExceeded {
t.Fatalf("Expected DeadlineExceeded, got: %v", err)
}
t.Log(time.Since(start))
})
}
The result is reliably zero errors, even with 100,000 iterations:
go1.25rc1 test -v -count=100000 | grep 'got:' | tee >(wc -l) | uniq
0
How to use synctest pre-Go 1.25
Until Go 1.25 is out, there are three ways of trying out synctest:
- In the Go playground, set the Go version to “Go dev branch”
- Download the latest release candidate of Go 1.25 (here’s how)
- With Go 1.24, use the environment setting
GOEXPERIMENT=syntest
(1)
(1) Note that in Go 1.24, you need to use the now-deprecated synctest.Run()
instead of synctest.Test(*testing.T, func(*testing.T))
.