Go 1.24: New STD-lib synctest
Go 1.24 introduces `testing/synctest`, enhancing concurrent testing by using virtual clocks for faster, reliable results. #Golang #Testing
Introduction
Testing concurrent code in Go has always been challenging. This is especially true for test cases involving time-dependent behavior. Traditional testing methods often rely on the actual system clock and synchronization mechanisms, leading to slow and unreliable tests.
For instance, when testing the expiration feature of the go-cache library, a common approach would look like this:
go-cache is a popular Go caching library that supports time-to-live (TTL) and periodic cleanup of expired cache items. It is well-suited for caching data and automatically deletes expired entries.
List 1: TestGoCacheEntryExpires testing cache in the usual way
func TestGoCacheEntryExpires(t *testing.T) {
c := cache.New(5*time.Second, 10*time.Second)
c.Set("foo", "bar", cache.DefaultExpiration)
v, found := c.Get("foo")
assert.True(t, found)
assert.Equal(t, "bar", v)
time.Sleep(5 * time.Second)
v, found = c.Get("foo")
assert.False(t, found)
assert.Nil(t, v)
}
Running this test requires waiting for 5 seconds for the cache entry to expire:
➜ synctest git:(main) ✗ time go test go-cache_test.go
ok command-line-arguments 5.012s
go test go-cache_test.go 0.38s user 1.27s system 27% cpu 6.049 total
The testing/synctest Approach
Starting with Go 1.24, the testing/synctest package significantly improves this process. Here's how the same test case can be rewritten using testing/synctest
Test Example 2: TestGoCacheEntryExpiresWithSynctest testing cache in the synctest
func TestGoCacheEntryExpiresWithSynctest(t *testing.T) {
c := cache.New(2*time.Second, 5*time.Second)
synctest.Run(func() {
c.Set("foo", "bar", cache.DefaultExpiration)
if got, exist := c.Get("foo"); !exist && got != "bar" {
t.Errorf("c.Get(k) = %v, want %v", got, "bar")
}
time.Sleep(1 * time.Second)
if got, exist := c.Get("foo"); !exist && got != "bar" {
t.Errorf("c.Get(k) = %v, want %v", got, "bar")
}
time.Sleep(3 * time.Second)
if got, exist := c.Get("foo"); exist {
t.Errorf("c.Get(k) = %v, want %v", got, nil)
}
})
}
Running this test
➜ synctest git:(main) ✗ time GOEXPERIMENT=synctest gotip test -run TestGoCacheEntryExpiresWithSynctest
PASS
ok blog-example/go/go1.24/synctest 0.009s
GOEXPERIMENT=synctest gotip test -run TestGoCacheEntryExpiresWithSynctest 0.36s user 1.23s system 171% cpu 0.933 total
The test now completes in just 0.009s.
Why is it so fast?
Unveiling testing/synctest
testing/synctest simplifies testing concurrent code by using virtual clocks and goroutine groups (also known as "bubbles"). This makes tests both fast and reliable. The package offers two core functions:
func Run(f func()) { synctest.Run(f) }
func Wait() { synctest.Wait() }
Key Features
- Run: Executes the provided function
fin a newgoroutineunder its bubble, ensuring that the virtual clock controls all relatedgoroutines. - Wait: Synchronizes the bubble’s
goroutinesand blocks the maingoroutineuntil all others are in adurably blockedstate.
A durably blocked state occurs when goroutines are waiting on:
- Channel send/receive within the bubble.
selectstatements involving bubble channelssync.Cond.Waittime.Sleep
However,goroutinesblocked by system calls or external events are not considereddurably blocked.
Virtual Clocks
Each bubble has a virtual clock starting from 2000-01-01 00:00:00 UTC. The clock advances only when all goroutines are idle. This allows time.Sleep calls will be completed instantly if all goroutines are idle, significantly speeding up tests.
List 3: TestSynctest Virtual Clocks are not affected by program run time
func TestSynctest(t *testing.T) {
synctest.Run(func() {
before := time.Now()
fmt.Println("before", before)
f1 := func() {
for i := 0; i < 10e9; i++ { // time consuming, It's about 3s in my machine
}
}
go f1()
synctest.Wait() //wait f1
after := time.Now() // time is not affected by the running time of f1
fmt.Println("after", after)
})
}
Output:
➜ synctest git:(main) ✗ time GOEXPERIMENT=synctest gotip test -run TestSynctest
before 2000-01-01 08:00:00 +0800 CST m=+946164282.733407335
after 2000-01-01 08:00:00 +0800 CST m=+946164282.733407335
PASS
ok blog-example/go/go1.24/synctest 3.131s
GOEXPERIMENT=synctest gotip test -run TestSynctest 3.45s user 1.25s system 133% cpu 3.533 total
Even computationally intensive functions do not affect the virtual clock’s time.
Conclusion
The testing/synctest package introduces a powerful way to test concurrent Go code efficiently and reliably by leveraging virtual clocks and goroutine control. It eliminates the need for real-time waiting and makes time-dependent tests faster and more predictable.