Swift 并发确定性测试与回归体系
并发测试最大的敌人不是复杂逻辑,而是“不确定性”。很多团队都会遇到同一类场景:CI 昨天红、今天绿、明天又红,日志看起来每次都不一样。最后大家对测试失去信任,开始靠“多点几次 rerun”过关。这是并发质量体系失效的典型信号。
确定性测试的目标不是让并发变串行,而是把关键调度点收敛到可控制、可复现、可解释。只要你能重复制造同一交错,竞态问题就会从玄学变工程问题。
一、定义“可测并发”标准
一套并发测试体系是否合格,可以用四条标准判断:
- 同输入同环境能稳定复现同结果。
- 失败时能还原时序证据,而不是只给堆栈。
- 关键并发路径覆盖取消、超时、重试边界。
- 回归后能量化验证性能与稳定性变化。
如果只满足“偶尔能跑过”,那不叫测试,只叫抽奖。
二、测试架构:把不可控外部依赖隔离掉
flowchart LR
A[Test Driver] --> B[Deterministic Clock]
A --> C[Controlled Scheduler]
A --> D[Mock Network / Storage]
B --> E[UseCase Under Test]
C --> E
D --> E
E --> F[Trace Collector]
F --> G[Assertions + Timeline Diff]
核心思想:
- 时间可控(不要直接用系统时间)。
- 调度可观测(知道每个任务何时运行)。
- 依赖可替换(网络、数据库、随机数都可注入)。
做到这三点,并发测试稳定性会有质变。
三、设计可控调度点
1)时间注入
用可注入 Clock 或时间提供器替代 Date.now、Task.sleep 直呼。测试时推进虚拟时间,避免真实等待和抖动。
2)I/O 栅栏
网络桩应支持“暂停在某一步”,让你能精确制造交错。例如:A 请求在解码前停住,B 请求先提交,观察 A 恢复后行为。
3)任务栅栏
在关键业务节点加测试钩子(仅测试配置开启),让测试可以等待到指定阶段再触发取消或注入故障。
四、并发用例分层:先正确性,再稳定性,再性能
层 1:正确性用例
验证核心不变量,例如余额不为负、状态迁移合法、事件不重复。
层 2:稳定性用例
同用例高频重复执行,统计失败率是否为零,识别低概率竞态。
层 3:性能回归用例
在固定输入下比较 P95/P99 与资源占用,防止“修了竞态但性能退化”。
分层后团队协作更清晰:功能开发先过层 1,模块负责人维护层 2,平台团队守层 3。
五、Swift Testing 与 XCTest 的协同策略
Swift Testing(新框架)在表达力上更适合并发断言,尤其是宏表达和参数化测试。XCTest 仍可用于历史兼容。
建议策略:
- 新并发模块优先使用 Swift Testing。
- 旧用例逐步迁移,不做一次性重写。
- 统一测试工具层(Clock、NetworkStub、Trace),避免两套生态各玩各的。
关键不是框架之争,而是是否建立统一测试抽象。
六、必备测试类型
1)交错测试(Interleaving Test)
人为控制两个或多个任务的执行顺序,验证恢复后状态。
2)取消传播测试
父任务取消后,子任务、网络请求、缓存写入都应停止。
3)超时回退测试
超时后是否触发预期降级,不产生半提交副作用。
4)幂等测试
同一命令重复投递是否只产生一次有效副作用。
5)错误恢复测试
中间阶段失败后,系统是否回到可重试状态。
七、时序证据:失败报告必须可读
失败日志最忌讳“Assert failed at line xx”。并发失败必须输出:
- 请求 ID / 任务 ID。
- 关键事件时间戳。
- 任务状态变迁(created/running/cancelled/completed)。
- 关键变量快照(版本号、状态枚举、队列深度)。
这样复盘时能回答“先发生了什么”,而不只是“哪里挂了”。
八、CI 门禁:把随机失败变成可控信号
推荐三层门禁:
- PR 门禁:快速并发核心用例(分钟级)。
- Nightly 门禁:高重复稳定性测试(小时级)。
- Release 门禁:全链路并发 + 性能回归(发布前)。
并发测试默认超时时间建议控制在 60 秒左右,避免卡死拖垮队列。超时不是失败终点,而是需要补证据的起点。
九、排障流程:从 flake 到根因
flowchart TD
A[CI 出现并发 flake] --> B[收集 trace 与随机种子]
B --> C[本地重放同种子同调度]
C --> D{是否稳定复现}
D -- 是 --> E[定位交错点与共享状态]
D -- 否 --> F[扩大观测: 增加栅栏与事件日志]
E --> G[修复后跑 1k 次重复测试]
F --> C
G --> H[回归性能与取消测试]
这个流程能避免“猜测式修复”。
十、实践骨架:可控时钟与取消测试
@Test("父任务取消应级联终止子任务")
func cancellationPropagation() async throws {
let clock = TestClock()
let env = TestEnvironment(clock: clock)
let task = Task {
try await env.useCase.loadDashboard()
}
await clock.advance(by: .milliseconds(50))
task.cancel()
await #expect(env.traces.contains("child_cancelled"))
await #expect(env.network.activeRequests == 0)
}
关键不在语法,而在 TestClock、TestEnvironment 的可控能力。
十一、性能与正确性的权衡
并发测试覆盖拉满后,执行时间会变长。平衡方法:
- 高价值路径全覆盖,低价值路径采样覆盖。
- 把慢测试迁到 nightly,不堵塞日常提交。
- 用测试标签分组执行。
- 定期清理冗余用例,避免重复劳动。
“测得全”不等于“测得好”,可维护性同样重要。
十二、团队协作规范
- 每个并发缺陷修复都必须新增可复现用例。
- 评审中必须回答“该改动如何被测试证明”。
- 测试失败禁止直接 rerun 合并,先给原因分类。
- 月度复盘统计 flake 来源,持续削减不确定性。
十三、结语
并发确定性测试的本质是把时间和调度纳入工程控制面。一旦你能稳定重放错误,竞态问题就不再神秘;一旦你把门禁放进 CI,质量就不再依赖个体经验。
最终交付目标不是“测试很多”,而是“故障可复现、修复可验证、回归可阻断”。这才是并发工程真正可持续的状态。