同一组 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 会因目标已存在而退出。
验收时可以采用四条明确标准:
- 单进程与四工作进程得到相同断言结果。
- 每个用例只写入自己的目录、设置域和数据库。
- 监听服务不使用硬编码端口。
- 失败日志能够定位到种子、沙箱和结果包。
最后再检查清理策略。成功任务可以删除临时沙箱;失败任务应保留必要日志和结果包,但不要长期保留凭据、仓库令牌或未脱敏请求内容。并行测试稳定性的核心不是更多重试,而是让每个用例只拥有自己的状态,并让每次失败都留下足够的复现条件。
常见问题
Swift Testing 并行失败时,应该先关闭并行执行吗?
不应把关闭并行作为默认修复。先为临时目录、UserDefaults、数据库和端口建立用例级隔离;只有无法安全并行的遗留测试,才使用 .serialized 作为局部兜底。
如何让只在 CI 出现的随机失败可以复现?
为每次任务记录固定随机种子、并行工作进程数、测试目标和结果包路径。失败后使用相同种子与相同并行度重跑,而不是只重复执行单个测试。
并行测试使用固定端口有什么风险?
多个测试进程可能同时绑定同一端口,造成地址占用或误连到其他用例的服务。测试服务器应请求端口 0,由系统分配空闲端口,再把实际端口传给客户端。
为持续构建任务选择独享云端 Mac
核对 M4、内存、存储、节点和计费周期,再把固定构建环境接入现有流水线。