WWDC Quick Look 💓 By SwiftGGTeam
Go further with Swift Testing

Go further with Swift Testing

观看原视频

Highlight

Swift Testing 的 #expect(throws:) 宏用一行代码替代 do-catch 模板,withKnownIssue 标记预期失败不污染报告,参数化测试让一个测试函数覆盖多组输入——这三个功能是本 session 的核心增量。

核心内容

写测试时,错误处理是最容易被跳过的部分。用 XCTest 测试一个抛出错误的函数,你需要手写 do-catch,代码冗长,而且如果函数没抛错,do-catch 不会提醒你”测试其实没生效”。Swift Testing 的 #expect(throws:) 宏直接解决这个问题:传一个错误类型进去,函数抛了对应类型的错误就通过,没抛就失败,一行代码搞定。

另一个常见痛点是”已知失败的测试”。某个测试因为外部依赖坏掉了,你暂时修不了,但它每次都在报告里标红,淹没了真正需要关注的失败。有人用 .disabled 跳过整个测试,但这样连编译错误也看不到了。withKnownIssue 是更精确的工具:测试照常运行,如果失败了标记为”预期失败”而不计入失败总数;如果哪天修好了,它会提醒你移除 withKnownIssue

参数化测试解决的是重复代码的问题。一个枚举有六个 case,你要测每个 case 的 containsNuts 属性。复制粘贴六个测试函数?容易出错。for 循环遍历?一个失败会中断后续测试,而且报错信息不告诉你具体哪个值出问题。@Test(arguments:) 让你把输入数组提到测试属性上,框架自动为每个参数生成独立的测试用例,失败时清晰标注哪个输入有问题,且所有用例并行运行。

详细内容

错误断言:#expect(throws:)

#expect(throws:) 提供四个层级的精度(02:57):

// 只关心"有没有抛错"
#expect(throws: (any Error).self) {
    try teaLeaves.brew(forMinutes: 200)
}

// 关心"抛的是不是这个类型"
#expect(throws: BrewingError.self) {
    try teaLeaves.brew(forMinutes: 200)
}

// 关心"是不是这个具体的 case"
#expect(throws: BrewingError.oversteeped) {
    try teaLeaves.brew(forMinutes: 200)
}

// 自定义验证逻辑(检查关联值等)
#expect {
    try teaLeaves.brew(forMinutes: 3)
} throws: { error in
    guard let error = error as? BrewingError,
          case let .needsMoreTime(optimalBrewTime) = error else {
        return false
    }
    return optimalBrewTime == 4
}

关键点:

  • throws: (any Error).self — 只要抛了任何错误就算通过,适合不关心具体错误类型的场景
  • throws: BrewingError.self — 用类型过滤,抛了别的类型的错误会失败
  • throws: BrewingError.oversteeped — 精确匹配某个 case,包括关联值
  • 闭包形式 — 最灵活,可以写任意判断逻辑,闭包返回 Bool 决定测试是否通过

#require(throws:) 是它的”必须通过”变体(04:11):如果断言失败,测试立即终止,后续代码不再执行。适合验证关键前置条件。

已知问题:withKnownIssue

@Test func softServeIceCreamInCone() throws {
    let iceCreamBatter = IceCreamBatter(flavor: .chocolate)
    try #require(iceCreamBatter != nil)
    #expect(iceCreamBatter.flavor == .chocolate)

    withKnownIssue {
        try softServeMachine.makeSoftServe(in: .cone)
    }
}

关键点(04:29):

  • withKnownIssue 只包裹会失败的部分,其他断言照常运行
  • 失败被记录为”预期失败”而非”测试失败”,不污染测试报告
  • 当底层问题修复、代码不再抛错时,框架会提醒你移除 withKnownIssue
  • .disabled 更好:测试仍然编译和运行,不会漏掉编译错误

参数化测试

@Test(arguments: [IceCream.Flavor.vanilla, .chocolate, .strawberry, .mintChip])
func doesNotContainNuts(flavor: IceCream.Flavor) throws {
    try #require(!flavor.containsNuts)
}

@Test(arguments: [IceCream.Flavor.rockyRoad, .pistachio])
func containNuts(flavor: IceCream.Flavor) {
   #expect(flavor.containsNuts)
}

关键点(08:44):

  • @Test(arguments:) 接受任何 Sendable 集合(数组、字典、Range 等)
  • 每个参数生成独立的测试用例,失败时精确标注哪个输入有问题
  • 所有用例并行运行,比 for 循环更快
  • 输入类型遵循 Codable 时,Xcode 支持单独重跑某个失败的用例

多参数组合(11:07):

@Test(arguments: Ingredient.allCases, Dish.allCases)
func cook(_ ingredient: Ingredient, into dish: Dish) async throws { ... }

两个集合做笛卡尔积,4 个食材 x 4 个菜品 = 16 个用例。如果不想测所有组合,用 zip 配对:

@Test(arguments: zip(Ingredient.allCases, Dish.allCases))
func cook(_ ingredient: Ingredient, into dish: Dish) async throws { ... }

zip 把两个集合按顺序配对,只生成 4 个用例。

Suite、Tag 和并行测试

@Suite("Various desserts")
struct DessertTests {
    @Suite struct WarmDesserts {
        @Test func applePieCrustLayers() { /* ... */ }
        @Test func lavaCakeBakingTime() { /* ... */ }
    }

    @Suite struct ColdDesserts {
        @Test func cheesecakeBakingStrategy() { /* ... */ }
        @Test func mangoSagoToppings() { /* ... */ }
    }
}

Suite 支持嵌套(13:11),用于按逻辑分组测试函数。

Tag 用于跨 Suite 关联测试(13:36):

extension Tag {
    @Tag static var caffeinated: Self
    @Tag static var chocolatey: Self
}

@Suite(.tags(.caffeinated)) struct DrinkTests { ... }
@Test(.tags(.caffeinated, .chocolatey)) func espressoBrownieTexture() { ... }

Tag 出现在 Xcode 16 的测试导航器和测试报告中。你可以在测试计划里按 Tag 筛选包含/排除的测试,测试报告的”分布洞察”功能会自动发现具有相同 Tag 的失败测试的共性。

并行测试默认开启(21:20)。测试运行顺序随机,帮助暴露隐含的数据依赖。如果暂时无法解决依赖,用 .serialized trait 让 Suite 内的测试串行执行:

@Suite("Cupcake tests", .serialized)
struct CupcakeTests { ... }

.serialized 会自动继承到嵌套的子 Suite,不需要重复添加。

异步回调测试:confirmation

对于可能触发多次的回调,用 confirmation 替代手动计数(25:50):

@Test func bakeCookies() async throws {
    let cookies = await Cookie.bake(count: 10)
    try await confirmation("Ate cookies", expectedCount: 10) { ateCookie in
        try await eat(cookies, with: .milk) { cookie, crumbs in
            #expect(!crumbs.in(.milk))
            ateCookie()
        }
    }
}

expectedCount 指定期望的回调次数。如果实际调用次数不匹配,测试失败。传 0 可以断言某个回调永远不应该触发。

核心启发

  1. #expect(throws:) 替换所有 do-catch 测试模式:为什么值得做:do-catch 写法冗长且容易遗漏”没抛错”的情况,#expect(throws:) 一行搞定,语义更清晰。怎么开始:找到项目中所有测试错误抛出的 do-catch 块,逐个替换为 #expect(throws: ErrorType.self) 形式。

  2. 用参数化测试消灭重复的测试函数:为什么值得做:同一个逻辑测多组输入,复制粘贴容易出错且维护成本高,参数化测试让输入与逻辑分离,新增 case 只需在数组里加一行。怎么开始:识别项目中”只有输入值不同、断言逻辑相同”的测试函数,用 @Test(arguments:) 合并。

  3. withKnownIssue 管理”暂时修不了”的失败测试:为什么值得做:.disabled 会完全跳过测试,连编译错误都发现不了;withKnownIssue 让测试照常运行,失败不计入总数,修好后自动提醒你移除。怎么开始:在 CI 报告中找到反复失败但你暂时不打算修的测试,用 withKnownIssue 包裹失败部分。

  4. 用 Tag 跨文件关联测试,配合测试计划做精准筛选:为什么值得做:项目变大后,按目录组织测试不够用;Tag 可以按业务属性(如”付费功能”、“网络依赖”)跨 Suite 聚合测试,在测试计划里排除不稳定的 Tag。怎么开始:给关键业务维度的测试加 Tag,在测试计划中用 include/exclude 字段按 Tag 筛选。

关联 Session

评论

GitHub Issues · utterances