WWDC Quick Look 💓 By SwiftGGTeam
Embrace Expected Failures in XCTest

Embrace Expected Failures in XCTest

观看原视频

Highlight

Apple 在 XCTest 中提供 XCTExpectFailure,让已知测试失败继续执行并在报告中标记为预期失败,从而减少噪声并保留新问题信号。

核心内容

一个测试昨天还在通过,今天开始失败。第一次失败很有价值。它可能说明产品有 bug,也可能说明测试写错了,还可能是依赖框架或系统行为发生了变化。

问题在于,团队不一定能马上修掉它。接下来的每次 CI 运行都会继续报同一个失败。红色结果还在,但它已经不再提供新信息。真正的新失败会被埋在旧失败里。

以前常见的处理方式有两个。第一种是在 test plan 或 scheme 里禁用测试。代码仍然会编译,但测试不执行,报告里也看不到这个问题。第二种是用 XCTSkip。测试会执行到跳过点,报告里有记录,但跳过点之后的代码不会运行,后续信息也丢了。

Apple 在这场 session 里介绍的工具是 XCTExpectFailure。它适合处理已经登记、暂时无法修复的失败。测试照常运行,命中的失败会在报告中变成 expected failure(预期失败),包含它的 test suite 会显示为通过的混合状态。你仍然能看到这个问题,也能更快发现新的失败。

已知失败仍然需要链接到缺陷

XCTExpectFailure 接受一个 failure reason(失败原因)。演讲建议把 bug 跟踪系统的 URL 放进这个字符串。Xcode 的测试报告会识别 URL,悬停时显示跳转按钮,方便从测试报告回到问题记录。

作用域越小,噪声越少

XCTExpectFailure 有两种用法。stateful(有状态)用法会影响调用之后的失败。scoped(作用域)用法把可能失败的代码包进闭包,闭包外的失败照常报告。共享测试工具函数里更适合用 scoped 写法,因为它减少对外层测试状态的影响。

严格模式帮你删除过期标记

默认行为是 strict(严格)。如果你声明了预期失败,但这次没有任何失败,XCTest 会生成一个新的失败,叫 unmatched expected failure。这会提醒你:底层问题可能已经修复,应该删除 XCTExpectFailure。

对于 flaky test(不稳定测试),严格模式会制造额外噪声。XCTExpectedFailure.Options 提供 isStrict,可以把它关闭。Swift 版本也支持在 XCTExpectFailure 里直接传 strict: false。

详细内容

1. 配置限制用 XCTSkip

03:31)XCTSkip 适合表达配置限制。演讲中的例子是测试只支持 iPad。测试会执行到这一行,设备非 iPad 时跳过。

try XCTSkipUnless(UIDevice.current.userInterfaceIdiom == .pad, "Only supported on iPad")

关键点:

  • tryXCTSkipUnless 会抛出 skip 异常,测试方法需要处理它。
  • XCTSkipUnless(...):条件为 false 时跳过当前测试。
  • UIDevice.current.userInterfaceIdiom == .pad:限制测试运行环境为 iPad。
  • "Only supported on iPad":写入测试报告的跳过原因。

这个 API 解决的是环境不匹配。它不适合处理已知失败,因为跳过点之后的测试代码不会继续运行。

2. 用 XCTExpectFailure 标记已知失败

04:31)XCTExpectFailure 用来管理已经登记的失败。调用后,测试继续执行,命中的失败会显示为预期失败。

XCTExpectFailure("<https://dev.myco.com/bugs/4923> myValidationFunction is returning false")

关键点:

  • XCTExpectFailure(...):声明当前测试中接下来的失败是已知失败。
  • "<https://dev.myco.com/bugs/4923> ...":失败原因包含 bug 链接,Xcode 报告可以跳转到问题跟踪系统。
  • myValidationFunction is returning false:原因文字说明具体失败点,避免只留下一个无上下文标记。

演讲里,加入这一行后,失败测试的红色 X 变成灰色 X。包含它的 test suite 变成绿色短横,表示通过但包含 skip 或 expected failure。

3. 用 scoped 写法限制影响范围

07:14)如果只有某几行会触发已知失败,把它们放进闭包。闭包外出现失败时,XCTest 仍然按普通失败处理。

XCTExpectFailure("<https://dev.myco.com/bugs/4923> fix myValidationFunction") {
    XCTAssert(myValidationFunction())
}

关键点:

  • XCTExpectFailure(... ) { ... }:创建一个只覆盖闭包内部代码的预期失败作用域。
  • "<https://dev.myco.com/bugs/4923> fix myValidationFunction":记录问题链接和修复目标。
  • XCTAssert(myValidationFunction()):真实断言仍然执行,失败时被闭包作用域捕获。
  • 闭包外代码:如果发生失败,会按普通失败进入报告。

演讲还说明,XCTExpectFailure 支持嵌套。失败会先匹配最近的调用点,如果 matcher 拒绝,再向外层调用传递。共享测试库中使用闭包写法,可以减少对调用方测试的影响。

4. 用 issueMatcher 精确匹配失败类型

08:34)默认情况下,作用域内任何失败都会被捕获。为了发现新的问题,可以给 options 设置 issueMatcher,只接受符合条件的 XCTIssue。

let options = XCTExpectedFailure.Options()
options.issueMatcher = { issue in
    return issue.type == .assertionFailure
}

XCTExpectFailure("<https://dev.myco.com/bugs/4923> fix myValidationFunction", options: options)

关键点:

  • let options = XCTExpectedFailure.Options():创建预期失败配置对象。
  • options.issueMatcher = { issue in ... }:给配置对象设置失败匹配器。
  • issue:XCTest 传入的 XCTIssue,包含失败细节。
  • return issue.type == .assertionFailure:只把断言失败当作预期失败处理。
  • XCTExpectFailure(..., options: options):把匹配规则传给预期失败声明。

如果 matcher 拒绝某个失败,这个失败不会被当作预期失败。这样,旧问题继续降噪,新问题仍然能把测试变红。

5. 按平台关闭预期失败

09:03)有些失败只出现在特定平台。演讲给出的例子是测试在 macOS 通过,在 iOS 失败。可以通过 options.isEnabled 关闭某些平台上的预期失败声明。

let options = XCTExpectedFailure.Options()
#if os(macOS)
options.isEnabled = false
#endif

XCTExpectFailure("<https://dev.myco.com/bugs/4923> fix myValidationFunction", options: options) {
    XCTAssert(myValidationFunction())
}

关键点:

  • let options = XCTExpectedFailure.Options():准备配置对象。
  • #if os(macOS):只在 macOS 编译分支内执行配置。
  • options.isEnabled = false:在 macOS 上关闭这个预期失败声明。
  • #endif:结束平台条件编译。
  • XCTExpectFailure(..., options: options) { ... }:使用配置运行闭包内断言。
  • XCTAssert(myValidationFunction()):失败只会在启用预期失败的平台上被捕获。

这种写法适合确定性的环境差异,例如平台、设备类型或系统版本差异。

6. 为不稳定测试关闭 strict 行为

10:39)XCTExpectFailure 默认是 strict。声明了预期失败但没有捕获到失败时,XCTest 会报告 unmatched expected failure。这个行为适合稳定失败,因为它提示你删除过期标记。

let options = XCTExpectedFailure.Options()
options.isStrict = false

XCTExpectFailure("<https://dev.myco.com/bugs/4923> fix myValidationFunction", options: options) {
    XCTAssert(myValidationFunction())
}

关键点:

  • let options = XCTExpectedFailure.Options():创建配置对象。
  • options.isStrict = false:关闭 strict 行为。
  • XCTExpectFailure(..., options: options) { ... }:使用非严格配置运行预期失败作用域。
  • XCTAssert(myValidationFunction()):这次断言通过时,测试仍然通过。

Swift 还提供直接参数写法。

XCTExpectFailure("<https://dev.myco.com/bugs/4923> fix myValidationFunction", strict: false) {
    XCTAssert(myValidationFunction())
}

关键点:

  • strict: false:直接关闭 strict 行为,不需要单独创建 options。
  • 闭包内部断言:失败时按预期失败处理,通过时不会制造 unmatched expected failure。

演讲把这种用法放在 nondeterministic failure(非确定性失败)场景中,包括 timing issue、ordering dependency 和 concurrency bug。

核心启发

  • 做什么:给每个已知失败测试加上 bug 链接。 为什么值得做:XCTExpectFailure 的 reason 可以包含 URL,Xcode 测试报告能从预期失败跳回问题系统。 怎么开始:把失败测试中的临时注释改成 XCTExpectFailure("<bug URL> 失败原因")

  • 做什么:把共享测试工具函数里的已知失败改成 scoped 写法。 为什么值得做:闭包作用域只捕获内部失败,外层测试的新失败会继续变红。 怎么开始:用 XCTExpectFailure("原因") { ... } 包住已知失败断言。

  • 做什么:为 flaky test 建一个非严格预期失败清单。 为什么值得做isStrict = false 避免偶发通过时产生 unmatched expected failure。 怎么开始:给相关测试创建 XCTExpectedFailure.Options(),设置 options.isStrict = false,再传给 XCTExpectFailure

  • 做什么:只把特定失败类型标记为预期失败。 为什么值得做issueMatcher 可以过滤 XCTIssue,旧断言失败被降噪,新的崩溃或其他失败仍然暴露。 怎么开始:设置 options.issueMatcher,先从 issue.type == .assertionFailure 这样的规则开始。

  • 做什么:按平台拆分预期失败策略。 为什么值得做:演讲中的 options.isEnabled 支持在某些平台关闭预期失败,适合 iOS 失败、macOS 已修复的测试。 怎么开始:用 #if os(macOS) 或其他平台条件设置 options.isEnabled = false

关联 Session

评论

GitHub Issues · utterances