在 Go 语言里,写并发代码是日常操作。但只要涉及到 测试异步函数,很多人都会头大。为什么?因为这不是你调用完就能立马拿到结果的同步函数,而是活还没干完,就已经收工了。
Go1.25 正式引入的 testing/synctest 包,正是为了解决这个问题。它能帮我们用更快、更可靠的方式测试并发和异步代码。
我将结合官方案例和代码,聊聊这个包解决了什么痛点,以及分享带来的新用法。
什么是异步函数?
先来对比一下。
同步函数很好理解:调用它 → 执行任务 → 返回。比如一个删除缓存目录的函数:
go
func (c *Cache) Cleanup() {
os.RemoveAll(c.cacheDir)
}
异步函数就不一样了:你调用它 → 它马上返回 → 后台再慢慢处理。
go
func (c *Cache) CleanupInBackground() {
go os.RemoveAll(c.cacheDir)
}
再比如 context.WithDeadline,它会返回一个 context,然后在未来的某个时间点自动取消:
go
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
像这种 和时间、并发挂钩的逻辑,测试起来就是 “灾难”。
为什么测试异步很难?
例子
测试同步函数,套路很简单:
- 设置初始条件;
- 调用函数;
- 校验结果。
但测试异步函数呢? - 你调用了函数;
- 它提前返回;
- 你得等它真正执行完;
- 然后才能校验结果。
问题
问题来了: - **等多久才合适?**如果等太短,可能还没执行完;等太长,测试又变得很慢。
- 怎么保证“不发生”的事? 比如我要验证 deadline 之前 context 没被取消,这个“没发生”怎么测?
结果就是:要么慢,要么不稳定。CI 跑一遍还好,跑多了不是超时就是有其他问题。
传统做法:Sleep + “好运”
我们写个测试,验证 context.WithDeadline 在 deadline 后被取消。
例如:
go
func TestWithDeadlineAfterDeadline(t *testing.T) {
deadline := time.Now().Add(1 * time.Second)
ctx, _ := context.WithDeadline(t.Context(), deadline)
time.Sleep(time.Until(deadline))
if err := ctx.Err(); err != context.DeadlineExceeded {
t.Fatalf("context not canceled after deadline")
}
}
看似合理,但问题很明显:
1.** 太脆弱**:等到 deadline 那一刻,context 可能还没取消完。
2. 太慢:这个测试要跑 1 秒多,一个简单 case 占用这么久,很不划算。
大家都知道。我们最常见的改法是加点 buffer,比如等 1.1s:
go
time.Sleep(time.Until(deadline) + 100*time.Millisecond)
但这依然不靠谱。在本机可能 100ms 很长,在 CI 里遇上调度卡顿就不够了。
于是陷入经典困境:慢 或 不稳定,二选一。
synctest:测试里的 “bubble”
Go 团队最后给出的解法是:testing/synctest。已经在本次 Go1.25 的新特性中引入。
它的核心思想是:在测试里开一个 “bubble”,里面的时间是假的,执行逻辑是可控的。
API 只有两个:
go
// 在 bubble 里执行测试函数
func Test(t *testing.T, f func(*testing.T))
// 等待 bubble 内的 goroutine 全部 quiesce(进入稳定状态)
func Wait()
看个对比:还是测试 context.WithDeadline。
以前写法:
go
time.Sleep(time.Until(deadline) + 100*time.Millisecond)
用 synctest 改造后:
go
func TestWithDeadlineAfterDeadline(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
deadline := time.Now().Add(1 * time.Second)
ctx, _ := context.WithDeadline(t.Context(), deadline)
time.Sleep(time.Until(deadline))
synctest.Wait()
if err := ctx.Err(); err != context.DeadlineExceeded {
t.Fatalf("context not canceled after deadline")
}
})
}
变化不大:加了 synctest.Test 和 synctest.Wait。
但效果大不同:
- 测试几乎瞬间完成,不会真的睡 1 秒。
- 不用 hack buffer,也不会 flaky。
- 更重要的是,不需要改动 context 的源码。
时间与等待:更强的控制力
假时间
在 bubble 里,时间从 2000-01-01 00:00:00 UTC 开始。你可以随意 sleep,它会马上跳过去。
go
func TestSleep(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now()
time.Sleep(10 * time.Second)
t.Log(time.Since(start)) // 输出 10s,瞬间完成
})
}
所以测试里再也不会有“等一分钟”的尴尬。
**等待 quiescence
**
synctest.Wait 会等所有 goroutine 都进入稳定状态。比如:
go
func TestWait(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
done := false
go func() { done = true }()
synctest.Wait()
t.Log(done) // true
})
}
如果不加 Wait,可能会出现数据竞争。但 Wait 提供了隐式同步,连 -race 检测器都能识别。
实战例子:测试 io.Copy
即便是 io.Copy 这种看似同步的函数,也存在异步行为。来看测试:
go
func TestIOCopy(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
srcReader, srcWriter := io.Pipe()
defer srcWriter.Close()
var dst bytes.Buffer
go io.Copy(&dst, srcReader)
data := "1234"
srcWriter.Write([]byte(data))
synctest.Wait()
if got, want := dst.String(), data; got != want {
t.Errorf("Copy wrote %q, want %q", got, want)
}
})
}
这里我们不用加锁,不用加 sync.WaitGroup,只要 synctest.Wait 就能保证数据写入完成。
测试能做到更简洁,语义更清晰。
限制与注意点
当然,synctest 不是万能的:
- 真实 I/O、syscall、cgo 调用不算“durably blocking”,没法完全模拟。
- mutex 也不会被当成 durable block,fake clock 不会在等锁时推进。
- 网络场景要用 fake network(比如 net.Pipe),不能依赖真实 socket。
但对绝大多数异步逻辑,已经够用了。
总结
并发本身就不 “简单”,更别提测试了。以前我们只能在 慢 vs 不稳定 之间做选择。
现在有了 synctest,我们能写出 快且稳 的并发测试。
它的核心就是:bubble。在这个 bubble 里,时间是假的,等待是可控的。