Swift Testing 进阶:参数化测试与高级断言
Go further with Swift Testing
2024年6月10日
一句话判断
如果你还在用 XCTest 写测试,Swift Testing 的 #expect、expect throws、参数化测试和 withKnownIssue 四个功能就够说服你迁移了——Xcode 16 内置支持,开箱即用。
这场 Session 讲了什么
Swift Testing 是 Apple 在 WWDC 2024 推出的现代测试框架,随 Xcode 16 一起发布。这个 session 是 “Meet Swift Testing” 的续篇,聚焦于进阶功能。两位演讲者 Jonathan 和 Dorothy 围绕四个主题展开:更强大的断言(expectations)、错误处理的测试方式、已知问题的管理、以及参数化测试。
断言方面,#expect 宏不只是简单的布尔判断,它能处理复杂的表达式并给出精确的失败信息。错误处理方面,#expect(throws:) 系列宏让你用一行代码替代传统的 do-catch 模板。已知问题管理方面,withKnownIssue 让你标记暂时无法修复的失败测试,避免它们污染测试报告。参数化测试方面,你可以用一个测试函数配合多组输入数据运行,系统自动为每组参数生成独立的测试结果。
Session 中用冰激凌制作作为贯穿的示例——从测试冰激凌口味是否含坚果,到测试冰激凌机器的故障处理,示例贴近生活且清晰易懂。
值得深挖的点
参数化测试:告别复制粘贴
参数化测试是 Swift Testing 最实用的功能之一。以前如果你有一个枚举有 10 个 case,每个 case 都需要单独的测试覆盖,你得写 10 个几乎相同的测试函数。用参数化测试,你只需要写一个测试函数,然后把所有 case 作为参数传入。
Swift Testing 支持两种参数化方式。简单的方式是直接在 @Test 属性中用 arguments 参数传入值数组。更高级的方式是传入多个参数集合,系统会自动做笛卡尔积——比如你想测试”5 种冰激凌口味 x 3 种甜度”的所有组合,只需要分别传入两个数组。
参数化测试的测试报告中每个参数组合都是独立的结果条目,你可以单独查看某组参数的失败原因。这比 XCTest 的 XCTAssert 在循环里测所有 case 要友好得多——循环中某个 case 失败后你很难定位是哪个,而参数化测试每个 case 都是独立条目。
withKnownIssue:已知问题的优雅管理
测试维护中一个常见的困境:某个测试因为外部依赖(比如示例中的冰激凌机器坏了)而持续失败,但你暂时无法修复。你的选择要么是给测试加 disabled 标记(问题修复后容易忘掉重新启用),要么让它在 CI 中持续飘红。
withKnownIssue 提供了第三条路。被它包裹的代码如果失败,不会计入测试失败——而是标记为”expected failure”。但测试仍然会执行和编译检查。当底层问题修复后,测试会突然变成”unexpected success”——系统会通知你”嘿,这个已知问题似乎已经不存在了”,你可以移除 withKnownIssue 包裹,恢复正常测试。
这个设计比 disabled 好在:disabled 的测试完全不运行,代码可能已经因为 API 变更而编译失败但你不知道。withKnownIssue 保持代码的活性,同时消除噪音。
代码片段
错误处理的测试
// 测试正常路径:期望函数不抛出错误
@Test func testBrewCoffee() async throws {
// 如果 brew() 抛出错误,测试直接失败
let coffee = try brewCoffee(beans: .arabica)
#expect(coffee.temperature > 60)
}
// 测试错误路径:期望抛出特定类型的错误
@Test func testBrewWithEmptyBeans() {
// 如果没抛出错误,测试失败
// 如果抛出的不是 BrewingError 类型,也失败
#expect(throws: BrewingError.self) {
try brewCoffee(beans: [])
}
}
// 更精确的错误验证
@Test func testBrewWithBurntBeans() {
#expect(throws: BrewingError.beansBurnt) {
// 只接受 beansBurnt 这一个具体的错误
try brewCoffee(beans: [.burnt])
}
}
// 最灵活的自定义验证
@Test func testBrewErrorContainsDetails() {
#expect {
try brewCoffee(beans: [.expired])
} throws: { error in
// 自定义错误检查逻辑
guard let brewingError = error as? BrewingError else {
return false
}
// 验证关联值
return brewingError.message.contains("过期")
}
}
坑点:#expect(throws:) 的闭包中如果函数本身是 throws 的,不需要在闭包内写 try——宏会自动处理。但如果你在闭包内调用了多个可能抛错的函数,只有第一个抛出的错误会被捕获。
参数化测试
// 简单的参数化测试:为每个枚举 case 生成独立测试
@Test(arguments: [Flavor.vanilla, Flavor.chocolate, Flavor.strawberry])
func flavorDoesNotContainNuts(_ flavor: Flavor) {
#expect(!flavor.containsNuts)
}
// 更完整的参数化:测试含坚果的口味
@Test(arguments: [Flavor.pistachio, Flavor.almond])
func flavorContainsNuts(_ flavor: Flavor) {
#expect(flavor.containsNuts)
}
// 多参数组合:笛卡尔积
@Test(
arguments: [.vanilla, .chocolate], // 口味
[.small, .medium, .large] // 尺寸
)
func testServingSize(_ flavor: Flavor, _ size: Size) {
let serving = makeServing(flavor: flavor, size: size)
#expect(serving.volume > 0)
#expect(serving.flavor == flavor)
}
坑点:参数化测试的参数必须遵循 Sendable 协议,因为每组参数可能并行执行。如果你的参数是自定义类型,确保它标记为 Sendable。
已知问题管理
@Test func testIceCreamMachine() {
// 冰激凌机器当前坏了,但我们不想让这个测试持续飘红
withKnownIssue {
// 这段代码仍然会执行和编译检查
let cone = try machine.makeCone(flavor: .vanilla)
#expect(cone.isDelicious)
}
// withKnownIssue 之外的代码正常测试
#expect(machine.isClean)
}
// 也可以指定匹配特定错误
@Test func testMachineMaintenance() {
withKnownIssue(isIntermittent: true) {
// isIntermitting: true 表示这个失败不是每次都发生
try machine.performMaintenance()
}
}
坑点:当问题修复后,测试会报告 “expected issue did not occur”——这不算失败,但会提醒你移除 withKnownIssue。如果加 isIntermittent: true,则不会在问题消失时提醒你。
最佳实践
如果你正在从 XCTest 迁移到 Swift Testing,建议按以下顺序逐步替换:先用 #expect 替换所有 XCTAssert 系列断言(#expect 的错误信息比 XCTest 好得多),然后用 #expect(throws:) 替换所有 do-catch 测试模式,接着把重复的测试用参数化测试合并,最后用 withKnownIssue 管理暂时无法修复的测试。
Swift Testing 和 XCTest 可以在同一个项目中共存。你可以按模块或按文件逐步迁移,不需要一次性全部切换。Xcode 16 的测试导航器已经完全支持 Swift Testing 的结果显示,包括参数化测试的每个参数组合的独立结果。
还有什么值得关注
CustomTestStringConvertible协议让你为测试输出定制类型的描述,避免默认描述中大量无关信息。required关键字可以把#expect变成 required expectation——失败后立即终止当前测试函数,适合在验证可选值时使用。- Swift Testing 是开源的,你可以在 Swift.org 上查看源码和贡献代码。