Skip to content

Swift 并发确定性测试与回归体系

5 min read

并发测试最大的敌人不是复杂逻辑,而是“不确定性”。很多团队都会遇到同一类场景:CI 昨天红、今天绿、明天又红,日志看起来每次都不一样。最后大家对测试失去信任,开始靠“多点几次 rerun”过关。这是并发质量体系失效的典型信号。

确定性测试的目标不是让并发变串行,而是把关键调度点收敛到可控制、可复现、可解释。只要你能重复制造同一交错,竞态问题就会从玄学变工程问题。

一、定义“可测并发”标准

一套并发测试体系是否合格,可以用四条标准判断:

  1. 同输入同环境能稳定复现同结果。
  2. 失败时能还原时序证据,而不是只给堆栈。
  3. 关键并发路径覆盖取消、超时、重试边界。
  4. 回归后能量化验证性能与稳定性变化。

如果只满足“偶尔能跑过”,那不叫测试,只叫抽奖。

二、测试架构:把不可控外部依赖隔离掉

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. 时间可控(不要直接用系统时间)。
  2. 调度可观测(知道每个任务何时运行)。
  3. 依赖可替换(网络、数据库、随机数都可注入)。

做到这三点,并发测试稳定性会有质变。

三、设计可控调度点

1)时间注入

用可注入 Clock 或时间提供器替代 Date.nowTask.sleep 直呼。测试时推进虚拟时间,避免真实等待和抖动。

2)I/O 栅栏

网络桩应支持“暂停在某一步”,让你能精确制造交错。例如:A 请求在解码前停住,B 请求先提交,观察 A 恢复后行为。

3)任务栅栏

在关键业务节点加测试钩子(仅测试配置开启),让测试可以等待到指定阶段再触发取消或注入故障。

四、并发用例分层:先正确性,再稳定性,再性能

层 1:正确性用例

验证核心不变量,例如余额不为负、状态迁移合法、事件不重复。

层 2:稳定性用例

同用例高频重复执行,统计失败率是否为零,识别低概率竞态。

层 3:性能回归用例

在固定输入下比较 P95/P99 与资源占用,防止“修了竞态但性能退化”。

分层后团队协作更清晰:功能开发先过层 1,模块负责人维护层 2,平台团队守层 3。

五、Swift Testing 与 XCTest 的协同策略

Swift Testing(新框架)在表达力上更适合并发断言,尤其是宏表达和参数化测试。XCTest 仍可用于历史兼容。

建议策略:

  1. 新并发模块优先使用 Swift Testing。
  2. 旧用例逐步迁移,不做一次性重写。
  3. 统一测试工具层(Clock、NetworkStub、Trace),避免两套生态各玩各的。

关键不是框架之争,而是是否建立统一测试抽象。

六、必备测试类型

1)交错测试(Interleaving Test)

人为控制两个或多个任务的执行顺序,验证恢复后状态。

2)取消传播测试

父任务取消后,子任务、网络请求、缓存写入都应停止。

3)超时回退测试

超时后是否触发预期降级,不产生半提交副作用。

4)幂等测试

同一命令重复投递是否只产生一次有效副作用。

5)错误恢复测试

中间阶段失败后,系统是否回到可重试状态。

七、时序证据:失败报告必须可读

失败日志最忌讳“Assert failed at line xx”。并发失败必须输出:

  1. 请求 ID / 任务 ID。
  2. 关键事件时间戳。
  3. 任务状态变迁(created/running/cancelled/completed)。
  4. 关键变量快照(版本号、状态枚举、队列深度)。

这样复盘时能回答“先发生了什么”,而不只是“哪里挂了”。

八、CI 门禁:把随机失败变成可控信号

推荐三层门禁:

  1. PR 门禁:快速并发核心用例(分钟级)。
  2. Nightly 门禁:高重复稳定性测试(小时级)。
  3. 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)
}

关键不在语法,而在 TestClockTestEnvironment 的可控能力。

十一、性能与正确性的权衡

并发测试覆盖拉满后,执行时间会变长。平衡方法:

  1. 高价值路径全覆盖,低价值路径采样覆盖。
  2. 把慢测试迁到 nightly,不堵塞日常提交。
  3. 用测试标签分组执行。
  4. 定期清理冗余用例,避免重复劳动。

“测得全”不等于“测得好”,可维护性同样重要。

十二、团队协作规范

  1. 每个并发缺陷修复都必须新增可复现用例。
  2. 评审中必须回答“该改动如何被测试证明”。
  3. 测试失败禁止直接 rerun 合并,先给原因分类。
  4. 月度复盘统计 flake 来源,持续削减不确定性。

十三、结语

并发确定性测试的本质是把时间和调度纳入工程控制面。一旦你能稳定重放错误,竞态问题就不再神秘;一旦你把门禁放进 CI,质量就不再依赖个体经验。

最终交付目标不是“测试很多”,而是“故障可复现、修复可验证、回归可阻断”。这才是并发工程真正可持续的状态。