在Go语言中,测试并发代码一直是一个具有挑战性的任务。尤其是测试一些需要跟时间相关的case的时候,传统的测试方法通常依赖于真实的系统时钟和同步机制,这会导致测试变得缓慢且容易出现不确定性。
假设我们需要测试go-cache 的过期删除功能,我们可能会写出这种代码
go-cache是一个非常流行的 Go 缓存库,支持过期时间(TTL)和定期清理过期的缓存项。它适用于缓存数据,能够自动删除过期的条目。
list1: TestGoCacheEntryExpires
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)
}
|
运行这个case 需要等待5s,让 cache过期。
1
2
3
|
➜ synctest git:(main) ✗ time go test go-cacha_test.go
ok command-line-arguments 5.012s
go test go-cacha_test.go 0.38s user 1.27s system 27% cpu 6.049 total
|
在 Golang 1.24 之后,如何使用testing/synctest 解决这个问题呢?首先看看基于 testing/synctest 如何写这个test case 把。
List 2: TestGoCacheEntryExpiresWithSynctest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
func TestGoCacheEntryExpiresWithSynctest(t *testing.T) {
c := cache.New(2*time.Second, 5*time.Second)
synctest.Run(func() {
c.Set("foo", "bar", cache.DefaultExpiration)
// Get an entry from the cache.
if got, exist := c.Get("foo"); !exist && got != "bar" {
t.Errorf("c.Get(k) = %v, want %v", got, "bar")
}
// Verify that we get the same entry when accessing it before the expiry.
time.Sleep(1 * time.Second)
if got, exist := c.Get("foo"); !exist && got != "bar" {
t.Errorf("c.Get(k) = %v, want %v", got,"bar")
}
// Wait for the entry to expire and verify that we now get a new one.
time.Sleep(3 * time.Second)
if got, exist := c.Get("foo"); exist {
t.Errorf("c.Get(k) = %v, want %v", got, nil)
}
})
}
|
运行时间:
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
|
仅仅耗时 0.009s
testing/synctest 揭秘
testing/synctest主要目的是为了简化并发代码的测试。它的核心思想是通过使用虚拟时钟和goroutine组(也称为bubble)来控制并发代码的执行,从而使测试既快速又可靠。它仅仅只有两个接口。
1
2
|
func Run(f func()) { synctest.Run(f) }
func Wait() { synctest.Wait() }
|
Run函数在一个新的goroutine中执行f函数,并创建一个独立的bubble,确保所有相关的goroutine都在虚拟时钟的控制下执行。
Wait 用来同步 bubble 中goroutine‘s 的状态。调用该函数调用后,主goroutine将阻塞,直到bubble中的其他goroutine都处于durably blocked 状态。
durably blocked 是 bubble 中goroutine 的一个特有的状态,有几种方式可以使 goroutine 进入 durably blocked 状态。
- 在
bubble内向channel发送或接收数据
- select语句中,每个case都是
bubble内的channel
- A send or receive on a channel from within the bubble
- A select statement where every case is a channel within the bubble
sync.Cond.Wait
time.Sleep
但是如果goroutine 是因为 系统调用或外部事件而阻塞, 它就不是 durably blocked 状态,synctest.Wait 也不会因为它而阻塞。
每个bubble都有一个虚拟时钟,从2000-01-01 00:00:00 UTC 开始. 这个虚拟时钟不会根据代码的执行时间前进,只有在所有goroutine都处于空闲状态时才会向前推进。在TestGoCacheEntryExpiresWithSynctest 中调用time.Sleep 的时候, goroutine 只有一个并且处于空闲状态,时间会快速前进,而不必等待真实时间过去,所以测试运行速度很快。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
func TestSynctest(t *testing.T) {
synctest.Run(func() {
before := time.Now()
fmt.Println("before", before)
f1:=func() {
count := 0
for i := 0; i < 10e9; i++ { // time consuming, It's about 3s in my machine
count++
}
}
go f1()
synctest.Wait() //wait f1
after := time.Now() // time is not affected by the running time of f1
fmt.Println("after", after)
})
}
|
运行结果为:
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
|
在 gosrc 中已经已经有蛮多使用 synctest 优化case 的例子。

参考资料:
https://github.com/golang/go/blob/05d8984781f7cf2f0f39b53699a558b6a1965c6c/src/testing/synctest/synctest.go#L41