测试里先启动协程,再 Sleep 一小会儿,最后检查结果,是一种常见写法。它把正确性寄托在机器足够快上:本地通过,到了繁忙的 CI 节点却偶尔失败;把等待时间调大,测试又越来越慢。
Go 1.24 引入的 testing/synctest 尝试改变这种测试方式。使用前需要先分清版本:Go 1.24 的实验接口,与 Go 1.25 正式接口并不完全相同。
用隔离环境控制协程与时间#
靠真实时间等待并发事件,很难兼顾速度和稳定性。testing/synctest 为一组协程建立隔离的执行环境,在其中使用虚拟时钟,并提供等待协程稳定阻塞的同步点。
在 Go 1.24 中,需要启用 GOEXPERIMENT=synctest,入口叫 Run;使用 Go 1.25 正式接口时,入口改为 Test,不再需要这一实验开关。复制代码前应先确认工具链版本。
为什么“等了十毫秒”不是完成证据#
假设有一个后台任务,接收到取消信号后关闭输出通道。测试想确认“取消之后任务会退出”。若先调用取消,再等待十毫秒,实际上只做了一个时间估计,没有与任务建立明确的交接。
机器繁忙时,协程可能还没获得执行机会;机器很快时,十毫秒又大多是浪费。更棘手的是反向断言:取消之前任务不能提前退出。某个瞬间看到通道没关闭,只能说明检查那一刻没有关闭,不能说明所有相关协程都已经运行到应有的位置。
写测试前可以先画出三个事件:任务启动、取消发生、任务退出。需要证明的是它们的先后和条件关系。等待多少毫秒只是实现细节,不能替代这些关系。
先理解同步点,再使用虚拟时间#
Go 1.25 使用 synctest.Test 创建隔离环境,环境内启动的协程也属于其中。synctest.Wait 会等待其他协程达到持久阻塞状态;真实网络 I/O、互斥锁等待等并不都满足这种条件。虚拟时钟的推进同样受阻塞状态约束。
可以把它理解成测试中的检查点:先让相关计算推进到不会自行继续变化的位置,再进行断言。这不意味着所有并发执行顺序都被固定,也不意味着运行一次就穷尽了所有竞争路径。
如果代码不断忙循环,系统没有到达可推进虚拟时间的条件,测试也不会因为用了这个包就自动变快。因此,先把工作协程写成能等待、能取消、能退出的结构,仍然是基础。
验证取消后的退出行为#
以一个等待取消信号的后台任务为例。下面使用 Go 1.25 接口,在取消前后分别检查退出通道;业务测试可以把协程函数替换为实际任务入口。
package canceltest
import (
"context"
"testing"
"testing/synctest"
)
func TestWorkerStopsOnCancel(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
stopped := make(chan struct{})
go func() {
defer close(stopped)
<-ctx.Done()
}()
synctest.Wait()
select {
case <-stopped:
t.Fatal("取消前任务已退出")
default:
}
cancel()
synctest.Wait()
select {
case <-stopped:
default:
t.Fatal("取消后任务仍未退出")
}
})
}通道在隔离环境内创建。第一次检查验证任务没有提前结束;第二次检查验证取消确实导致退出。测试没有猜测调度耗时,也没有用一个很大的超时掩盖不稳定。
真实任务往往还有清理动作,例如刷新缓冲区或释放订阅。此时“通道关闭”应对应业务定义的退出完成点;若协程先关闭通道、再异步清理,这个信号就不能证明清理完成。
怎样拆分超时相关的测试#
以一个会重试的查询任务为例,可以把验收分成三个问题:第一次失败后是否进入等待;等待结束后是否再次查询;收到取消后是否停止等待且不再产生新查询。
这些问题可以分别用可观察的调用次数、输出事件和退出信号表达。不要只检查最终返回了错误,因为“重试了一百次才失败”和“取消后立即退出”可能产生相同错误文本。
| 关心的行为 | 值得观察什么 | 容易漏掉什么 |
|---|---|---|
| 重试次数 | 每次查询的调用记录 | 最终失败掩盖多余调用 |
| 截止时间 | 到期前后状态变化 | 仅检查耗时近似值 |
| 取消清理 | 退出信号与资源释放 | 函数返回后仍有后台任务 |
业务如果允许并发进行中的请求在取消后收尾,应把这一点写进规则,避免测试把合理收尾误判成新增工作。
外部 I/O 应放在哪一层#
数据库查询、HTTP 请求和文件系统操作,还涉及连接池、内核及远端进程。一个只控制内部协程与时间的测试,不能证明网络断开后的实际行为。
对上面的查询任务,可以在单元测试中注入一个固定返回值的查询接口,验证重试与取消策略;另写集成测试,检查真实客户端怎样传播截止时间、关闭连接和分类错误。这样失败时能知道是业务调度问题,还是实际 I/O 行为与预期不同。
替身接口的价值是控制条件,而不是让所有测试都通过。它应能表达失败、延迟、取消和异常返回,且不能悄悄省略生产环境中至关重要的错误路径。
Go 1.24 与 1.25 的接口边界#
Go 1.25 中,testing/synctest 已进入正式可用状态。维护旧模块时,应同时检查 go.mod、本地工具链和 CI 工具链:编辑器能识别代码,不代表 CI 使用的版本也提供相同接口。
示例在 Go 1.25.11、macOS arm64 环境执行 go test -race -count=10 ./...,十次均通过。接入实际任务后,还需验证它的业务状态和资源清理。
采用工具时,还要分清“行为约束”和“实现细节”。假如需求只要求取消后不再接收新任务,就不必强迫实现使用某个通道;可以观察公开入口与最终状态。相反,资源释放属于完成条件时,就应检查释放结果,不能只看到主函数返回便结束测试。
竞态检查与行为测试也要分开理解:两个协程通过锁正确同步,依然可能在业务顺序上出错;没有发现数据竞争,不代表任务只执行了一次或取消传播正确。因此需要同时保留业务断言和竞态检查,让它们分别覆盖不同问题。
对于真实工程,更有价值的推进方式是找一处依赖固定等待的测试,写清事件关系,再替换成明确同步和可控时间,而不是全仓库机械删除 Sleep。每次替换都应保持原来的业务断言;如果旧断言本身不清楚,先澄清它要保护的行为。
参考来源#
- Damien Neil:Testing concurrent code with testing/synctest;发布:2025-02-19。
- Go 项目:testing/synctest,Go 1.25.0。
- Go 项目:Go 1.25 Release Notes。
