Featured image of post Go 1.24: New STD-lib synctest

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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:

1
2
3
➜  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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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

1
2
3
4
➜  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:

1
2
func Run(f func()) { synctest.Run(f) }  
func Wait() { synctest.Wait() }  

Key Features

  1. Run: Executes the provided function f in a new goroutine under its bubble, ensuring that the virtual clock controls all related goroutines.
  2. Wait: Synchronizes the bubble’s goroutines and blocks the main goroutine until all others are in a durably blocked state.

A durably blocked state occurs when goroutines are waiting on:

  • Channel send/receive within the bubble.
  • select statements involving bubble channels
  • sync.Cond.Wait
  • time.Sleep
    However, goroutines blocked by system calls or external events are not considered durably 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 to be completed instantly if all goroutines are idle, significantly speeding up tests.

List 3: TestSynctest Virtual Clocks are not affected by program run time

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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:

1
2
3
4
5
6
➜  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.

Reference

synctest.go

Licensed under CC BY-NC-SA 4.0
Last updated on Dec 23, 2024 17:44 CST
Built with Hugo
Theme Stack designed by Jimmy