Go further with Swift Testing
SwiftUI & UI Frameworks 进阶 20m

Swift Testing 进阶:参数化测试与高级断言

Go further with Swift Testing

2024年6月10日

在 Apple 官方观看视频

一句话判断

如果你还在用 XCTest 写测试,Swift Testing 的 #expectexpect 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 上查看源码和贡献代码。
WWDC 2024