第 13 / 23 篇

第12章 测试并发行为,而不是复制实现

从观察契约出发

并发测试最常见的问题不是断言太少,而是断言了错误对象。检查内部 channel 的长度,可能只是重复实现;检查额外任务在容量耗尽时被及时拒绝,才对应用户可依赖的行为。检查 cancel 被调用,不代表工作结束;等待退出通知并确认连接恢复,才覆盖资源契约。

随书测试分为工作池单元行为、真实 HTTP/TCP 行为和进程级退出。每层提供不同证据。单元测试可以精确制造调度点,真实网络暴露缓冲与连接生命周期,子进程验证信号、main 流程和退出码。它们互相补充,不应为了“全部都是集成测试”而丢失可定位性。

一个好测试还应有失败诊断。等待某状态超时后,报告当前 accepted、active、completed 等信息,比只说 timeout 更容易定位。必要时输出 goroutine 堆栈,但避免正常路径打印大量数据。诊断应围绕尚未完成的责任,而不是堆满与故障无关的环境细节。

用同步点代替猜测

固定 Sleep 很容易在本机通过、CI 失败。要测试取消中的阻塞读取,可以让服务端先发送“已进入”通知,再等待客户端取消。要测试队列满,可以让 worker 在闩锁上停住,再提交后续任务。这样测试控制的是状态,不是机器速度。

有时必须等待网络或进程状态,可以使用有截止的轮询。轮询间隔影响测试成本,截止决定失败边界;成功条件则必须可观测。不要用无限 for 循环等计数变化,也不要让断言失败跳过清理,导致后面的测试受到遗留 goroutine 或端口影响。

时间断言应给调度留余量,同时仍然检验有界性。八十毫秒写截止不应要求测试在八十一毫秒完成;race、虚拟化和共享 runner 会增加耗时。可以要求在数秒总上限内返回,并检查实际错误来自写截止。这样不会把快慢误当成协议对错。

不依赖某一种合法竞争结果

提交与关闭同时发生时,一部分任务可能被接纳,一部分被拒绝。只要边界满足契约,具体数量不必固定。测试应统计成功接纳任务,并要求这些任务都有合法终结,而不是硬编码每轮必须成功十七个。后者常常测试的是偶然调度顺序。

结果与取消同时就绪时,也不能假定 select 总选择取消。如果业务确实要求某种优先级,就要在实现中设计提交仲裁,测试对应不变量。没有设计的地方,不应通过偶然测试输出暗示更强语义。并发程序的正确结果往往是一组允许状态,而非单一路径。

重复执行有助于暴露稀有竞争,但不是穷举证明。随书故障测试在 race 下多轮运行,进程 SIGTERM 也重复验证,目的在于提高对常见调度变化的覆盖。通过一百轮之后仍需保留逻辑推理:谁关闭、谁发送、谁等待,为什么不会发生循环等待。

race 与超时各有证据范围

go test -race ./... 只检测实际执行到的竞争访问。没有覆盖的路径不会自动被证明安全,逻辑竞态也可能没有数据竞争。例如两个线程都在锁内检查然后分别执行副作用,内存访问安全但业务仍可能重复。测试应从业务结果统计,而非把 race 当作万能认证。

测试总超时能够避免无限挂起,但总超时触发时往往已经失去局部上下文。更好的做法是在每个等待处设置局部截止,及时输出尚未完成的状态。全局 -timeout 是最后保护,不应成为日常判断机制。子进程测试还必须在失败路径终止并等待子进程,避免孤儿进程。

测试本身也可能产生竞争。多个 goroutine 调用测试辅助状态时需要同步,handler 中不宜直接 Fatal 终止当前 goroutine而让主测试继续等。可以通过有缓冲错误 channel 报告给测试主流程,统一断言与清理。测试代码同样需要生命周期设计。

练习:评价一条测试的证明能力

挑出一个测试,为它写一句“通过说明什么”和一句“通过不能说明什么”。例如连接耗尽恢复测试说明 Body 释放能恢复连接槽,却不能说明真实上游的容量和 TLS 行为。然后删除某一关键清理操作,确认测试会失败;如果仍然通过,说明它可能只测试了表面输出。

最后从新目录解压实验 ZIP,执行 race 与重复故障测试。确认没有依赖仓库外缓存、固定路径、已启动服务或隐藏环境变量。能独立运行的实验包,是读者检验书中结论的入口,也能反过来检查作者是否遗漏了真正的构建输入。

本章实测证据

先让handler进入等待,再调用DrainRequests;短截止必须超时,取消handler后必须完成,之后的新入口被拒绝。 以下摘自最终本机 go test -race -v ./...,完整命令及环境见附录B;括号中的耗时仅是当次运行记录。

--- PASS: TestDrainRequestsWaitsForHandlerAndGatesAdmission (0.02s)