云端 Mac 上治理 Swift Testing 并行测试污染

云端 Mac 上治理 Swift Testing 并行测试污染

同一组 Swift 测试单独运行全部通过,放进云端 Mac 的并行任务后却偶发失败,通常不是机器不稳定,而是测试共享了目录、偏好设置、数据库、端口或随机状态。增加重试只能掩盖污染。更可靠的做法是让每个测试拥有可识别、可清理、可复现的运行边界。

先确认失败是否来自共享状态

先保留并行度,不要立即改成串行。分别以单工作进程和多个工作进程执行相同目标,并为两次运行生成独立结果包:

set -o pipefail

xcodebuild test \
  -scheme AppTests \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -parallel-testing-enabled NO \
  -resultBundlePath Artifacts/serial.xcresult

xcodebuild test \
  -scheme AppTests \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -parallel-testing-enabled YES \
  -maximum-parallel-testing-workers 4 \
  -resultBundlePath Artifacts/parallel.xcresult

若单进程稳定、并行运行失败,优先检查以下资源,而不是先怀疑断言:

共享资源 常见症状 隔离方法
临时目录 文件被覆盖、清理时不存在 每个测试创建唯一子目录
UserDefaults 读取到其他用例写入的值 使用独立 suiteName
SQLite 文件 锁冲突、数据数量漂移 每个测试使用独立数据库
固定端口 Address already in use 绑定端口 0
全局随机数 顺序变化后结果不同 显式保存随机种子

串行通过只能证明共享状态没有同时被访问,不能证明测试已经正确隔离。

给每个测试分配独立沙箱

不要让所有用例写入 /tmp/app-tests。测试启动时生成唯一标识,把文件、数据库和导出结果全部放进该目录,并在结束时清理。

import Foundation
import Testing

struct TestSandbox {
    let id: String
    let root: URL

    init(name: String) throws {
        id = "\(name)-\(UUID().uuidString)"
        root = FileManager.default.temporaryDirectory
            .appending(path: "ZoneMiniTests")
            .appending(path: id)
        try FileManager.default.createDirectory(
            at: root,
            withIntermediateDirectories: true
        )
    }

    func preferences() throws -> UserDefaults {
        guard let defaults = UserDefaults(suiteName: "tests.\(id)") else {
            throw CocoaError(.fileWriteUnknown)
        }
        return defaults
    }

    func remove() throws {
        try FileManager.default.removeItem(at: root)
        UserDefaults.standard.removePersistentDomain(
            forName: "tests.\(id)"
        )
    }
}

用例通过依赖注入把 root 交给被测代码,避免生产代码自行读取全局临时路径。清理动作应放在 defer 中,即使断言失败也能执行。若需要保留失败现场,可根据环境变量跳过删除,并把沙箱路径写入测试附件。

不要用测试名称作为唯一键

参数化测试可能以相同函数名并行执行多组输入。只使用函数名仍会碰撞,应组合测试名、参数摘要和 UUID。参数中含令牌或用户数据时,不要直接拼进目录名,先生成不可逆摘要。

隔离配置、数据库与监听端口

UserDefaults 的标准域属于进程级共享状态。测试应注入专用实例,并在结束时删除对应持久域。数据库也遵循同一原则:每个测试创建自己的文件,迁移测试与普通读写测试不要复用同一副本。

网络测试最容易因固定端口产生假失败。测试服务器应绑定端口 0,让系统选择空闲端口,再把实际端口传给客户端。不要采用“先扫描空闲端口、关闭扫描连接、随后重新绑定”的做法;扫描与绑定之间存在竞争窗口。

对确实依赖全局单例、且短期无法重构的测试,可以局部串行:

import Testing

@Suite(.serialized)
struct LegacyDatabaseTests {
    @Test
    func migratesExistingStore() async throws {
        // 测试实现
    }
}

.serialized 应只包住遗留范围。若整个测试目标都被串行化,并行污染会消失,但执行时间和问题线索也会一起消失。

固定随机种子而不是重复碰运气

涉及乱序、重试退避或生成数据时,应从环境变量读取种子。任务开始时生成一次并记录,所有测试进程都使用该值派生自己的子种子。

let environment = ProcessInfo.processInfo.environment
let seed = UInt64(environment["TEST_SEED"] ?? "") ?? 20260806

子种子可以由基础种子与测试唯一键稳定计算。失败报告至少保留基础种子、工作进程数、测试目标和运行命令。这样才能复现“特定输入加特定并行度”的组合,而不是盲目重跑十次。

在 ZoneMini 的持续运行节点上,建议把结果包写入任务编号对应的目录,避免后一次任务覆盖前一次:

RUN_ID="${CI_RUN_ID:-local-$(date +%s)}"
mkdir -p "Artifacts/$RUN_ID"

TEST_SEED=20260806 xcodebuild test \
  -scheme AppTests \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -parallel-testing-enabled YES \
  -maximum-parallel-testing-workers 4 \
  -resultBundlePath "Artifacts/$RUN_ID/tests.xcresult"

用压力重复验证修复结果

修复后不要只跑一次。先保持相同种子和并行度连续执行,再更换种子覆盖不同输入。每轮结果包必须使用不同路径,否则 xcodebuild 会因目标已存在而退出。

验收时可以采用四条明确标准:

  1. 单进程与四工作进程得到相同断言结果。
  2. 每个用例只写入自己的目录、设置域和数据库。
  3. 监听服务不使用硬编码端口。
  4. 失败日志能够定位到种子、沙箱和结果包。

最后再检查清理策略。成功任务可以删除临时沙箱;失败任务应保留必要日志和结果包,但不要长期保留凭据、仓库令牌或未脱敏请求内容。并行测试稳定性的核心不是更多重试,而是让每个用例只拥有自己的状态,并让每次失败都留下足够的复现条件。

常见问题

Swift Testing 并行失败时,应该先关闭并行执行吗?

不应把关闭并行作为默认修复。先为临时目录、UserDefaults、数据库和端口建立用例级隔离;只有无法安全并行的遗留测试,才使用 .serialized 作为局部兜底。

如何让只在 CI 出现的随机失败可以复现?

为每次任务记录固定随机种子、并行工作进程数、测试目标和结果包路径。失败后使用相同种子与相同并行度重跑,而不是只重复执行单个测试。

并行测试使用固定端口有什么风险?

多个测试进程可能同时绑定同一端口,造成地址占用或误连到其他用例的服务。测试服务器应请求端口 0,由系统分配空闲端口,再把实际端口传给客户端。

DEDICATED BUILD SLOT

为持续构建任务选择独享云端 Mac

核对 M4、内存、存储、节点和计费周期,再把固定构建环境接入现有流水线。

选择配置并订购