Highlight
Xcode 27 默认开启测试框架互操作性(test framework interoperability),允许在同一个测试 Target 中混写 XCTest 和 Swift Testing,开发者可以逐步迁移而非一次性重写所有测试代码。
核心内容
从”全量重写”到”逐步迁移”
去年 Xcode 16 推出 Swift Testing 时,很多团队心动但不敢动。一个成熟的项目往往有几千行 XCTest 代码,全部重写风险高、ROI 低。大多数团队的选择是:新项目用 Swift Testing,老项目继续维护 XCTest。
(03:16)这场 session 的核心主张是:不要一次性修改所有老测试。每次只迁移几个你经常维护的测试,其余 XCTest 留在原地继续工作。同一个测试 Target 可以同时包含两套框架的测试文件,Xcode 会一起运行它们。
互操作性:两套框架的”翻译层”
(05:48)互操作性(interoperability)是 Xcode 27 的新能力。它允许你在 Swift Testing 测试的函数体中调用 XCTest API(如 XCTFail),也允许在 XCTest 中调用 Swift Testing API(如 #expect)。框架在底层自动把问题报告给正确的测试运行器。
举个例子:你有一个被几十个 XCTest 调用的 helper 函数 assertUnique,内部用 XCTFail 报告失败。现在你想在 Swift Testing 测试中也复用这个 helper。互操作性让这件事直接可行——XCTFail 的调用会被转译为 Swift Testing 的 issue 报告。
四种互操作模式
(07:43)Xcode 27 提供了四种模式来控制跨框架问题的处理方式:
- Limited(Xcode 27 之前项目的默认值):XCTest 产生的跨框架问题显示为警告,测试仍通过
- Complete(Xcode 27 新项目的默认值):跨框架问题保持为错误,测试会失败
- Strict:XCTest 产生的跨框架问题直接触发 fatal error,强制你替换为 Swift Testing API
- None:完全关闭互操作性,两个框架互不感知
(09:05)建议策略:先用 Complete 模式让问题暴露出来,然后逐个替换。等迁移得差不多了,在 CI 中切换到 Strict 模式,禁止新的 XCTest API 调用混入 Swift Testing 测试。
参数化测试:替代嵌套循环
(15:38)Swift Testing 的参数化测试(parameterized tests)解决了 XCTest 时代的一个常见痛点。假设你要测试每只鸟在 40 到 100 次拍翅膀下的表现,XCTest 的做法是写嵌套 for 循环:
func testBirdsFlapWings() async throws {
for bird in Aviary.birds {
for count in (40...100) {
try await bird.flapWings(count: count)
}
}
}
问题很明显:循环内某一行失败时,你只知道是哪个 for 循环挂了,但不知道具体是哪只鸟、哪个次数。调试时需要加日志或挂调试器。
(16:47)Swift Testing 的参数化测试把循环展开为独立的测试用例:
struct BirdTests {
@Test(arguments: Aviary.birds, 40...100)
func `Birds flap wings successfully`(bird: Bird, count: Int) async throws {
try await bird.flapWings(count: count)
}
}
每个参数组合变成一个独立的测试用例,在 Test Navigator 中可以展开查看。更重要的是,它们默认并行执行,总耗时大幅缩短。失败时,Xcode 直接标出是哪只鸟在哪个次数下失败,无需猜测。
Exit tests:测试崩溃路径
(18:08)Swift Testing 的 exit test 让你可以测试那些会触发 preconditionFailure 或 fatalError 的代码路径。框架会在子进程中运行测试体,主进程不受影响。
extension BirdTests {
@Test func `Bird with empty name crashes`() async throws {
await #expect(processExitsWith: .failure) {
_ = Bird(name: "")
}
}
}
(19:57)运行后,Code Coverage 中原本标红的 preconditionFailure 行会变成绿色——这条崩溃路径现在被测试覆盖了。
详细内容
在现有 XCTest 文件中添加 Swift Testing 测试
(04:18)迁移的第一步不需要创建新文件。直接在现有的 .swift 测试文件末尾 import Testing,然后写一个 @Test 函数:
import Testing
@testable import DemoApp
@Test func `Default climate: tropical`() async throws {
let fruit = Fruit(name: "Coconut")
#expect(fruit.climate == .tropical)
}
关键点:
@Test宏标记这是一个 Swift Testing 测试函数- 反引号(raw identifier)允许函数名包含空格和标点,测试名可以直接写成可读的自然语言
#expect替代XCTAssertEqual,失败时自动展开表达式显示左右两边的值- 不需要继承
XCTestCase,函数可以放在文件顶层
迁移 helper 函数:从 XCTFail 到 Issue.record
(05:03)假设你有一个 XCTest 时代的 helper 函数:
func assertUnique(_ fruits: [Fruit], file: StaticString = #filePath, line: UInt = #line) {
var uniqueNames = Set<String>()
for name in fruits.map(\.name) {
if !uniqueNames.insert(name).inserted {
XCTFail("Duplicate name: \(name)", file: file, line: line)
}
}
}
(10:12)把它改成同时支持 Swift Testing 的版本:
import Testing
func assertUnique(_ fruits: [Fruit], sourceLocation: SourceLocation = #_sourceLocation) {
var uniqueNames = Set<String>()
for name in fruits.map(\.name) {
if !uniqueNames.insert(name).inserted {
Issue.record("Duplicate name: \(name)", sourceLocation: sourceLocation)
}
}
}
关键点:
XCTFail替换为Issue.recordfile:line:参数替换为sourceLocation: SourceLocation- 更新后的 helper 同时被 XCTest 和 Swift Testing 调用时,两边都能正确报告失败位置
跳过测试的三种写法
(13:10)XCTest 中用 XCTSkipIf 跳过测试。Swift Testing 提供了三种等效写法:
let isFall = false
// XCTest 方式
func testSwallowFallMigration() async throws {
try XCTSkipIf(!isFall, "Wrong season for migration")
}
// Swift Testing:在测试体中调用 Test.cancel
func testSwallowFallMigration() async throws {
if !isFall {
try Test.cancel("Wrong season for migration")
}
}
// Swift Testing 推荐:用 trait 把条件移出测试体
@Test(.enabled(if: isFall, "Wrong season for migration"))
func `Swallow fall migration`() async throws {
// 测试逻辑
}
关键点:
Test.cancel可以在测试体中动态决定是否跳过.enabled(if:)trait 把跳过逻辑声明在函数签名上,测试体更干净- trait 方式让测试的”可执行条件”一目了然,不需要读函数体
用 #require 替代 continueAfterFailure
(13:41)XCTest 中设置 continueAfterFailure = false 让测试在第一个失败断言处停止。Swift Testing 用 #require 宏实现同样的效果:
func testExample() async throws {
#expect(Fruit.banana.climate == .temperate)
try #require(Fruit.banana == Fruit.plantain)
// 如果 #require 失败,会抛出错误,后续代码不会执行
}
关键点:
#expect失败时继续执行后续断言#require失败时抛出错误,测试立即停止- 你可以自由选择哪些断言应该”硬停止”,哪些应该”软收集”
Swift Package 中控制互操作模式
(12:15)Swift Package 项目的默认模式取决于 tools version:
swift-tools-version: 6.3及更早:默认 limited 模式swift-tools-version: 6.4及更新:默认 complete 模式
在终端中可以用环境变量覆盖:
SWIFT_TESTING_XCTEST_INTEROP_MODE=strict swift test
这个命令在 CI 流水线中很有用——你可以把互操作模式设成 strict,确保团队成员不会在 Swift Testing 测试中偷偷调用 XCTest API。
核心启发
给现有项目的测试做”健康体检”
- 做什么:扫描项目中的 XCTest helper 函数,把它们逐步迁移到 Swift Testing API
- 为什么值得做:这些 helper 通常被大量测试调用,迁移后所有调用方自动受益;
Issue.record比XCTFail的报错信息更详细 - 怎么开始:找一个被调用最频繁的 helper(如自定义的
assertSnapshot或assertUnique),把XCTFail换成Issue.record,跑一遍测试确认行为一致
用参数化测试消灭数据驱动测试的循环
- 做什么:把项目中所有用
for循环遍历测试数据的 XCTest,改写成@Test(arguments:) - 为什么值得做:并行执行缩短 CI 时间;失败时精确到具体参数组合,省去调试时的猜测
- 怎么开始:找出一个有嵌套循环的测试,把循环变量提取为函数参数,把循环范围放到
arguments:中
给崩溃路径补上测试
- 做什么:用 exit test 覆盖所有
preconditionFailure、fatalError和assertionFailure的调用点 - 为什么值得做:这些代码路径以前几乎无法测试,现在可以用
#expect(processExitsWith:)安全验证;同时提升 Code Coverage - 怎么开始:在 Xcode 中打开 Code Coverage,找到标红的
preconditionFailure行,写一个对应的 exit test
在 CI 中强制测试框架纯洁性
- 做什么:在 CI 配置中加入
SWIFT_TESTING_XCTEST_INTEROP_MODE=strict - 为什么值得做:防止团队成员在新写的 Swift Testing 测试中混入 XCTest API,避免”缝合怪”代码积累
- 怎么开始:在
.github/workflows/或 Jenkins pipeline 的swift test命令前加上环境变量
用 raw identifier 改善测试可读性
- 做什么:把
testDefaultClimateIsTropical这种驼峰命名改成`Default climate: tropical` - 为什么值得做:测试名本身就是文档,带空格的句子比压缩的驼峰更易读;失败日志中显示的名字也更直观
- 怎么开始:新建 Swift Testing 测试时直接用反引号包裹自然语言描述,不需要缩写
关联 Session
- Go further with Swift Testing — Swift Testing 的进阶特性,包括自定义 traits、parallelization 控制和测试组织策略
- Xcode 27 — Xcode 27 中测试相关的 UI 改进和 Coding Assistant 的迁移辅助功能
- SwiftUI — SwiftUI 预览和 Swift Testing 的集成测试策略
- SwiftData — 数据库层的测试最佳实践,配合 Swift Testing 做数据持久化验证
评论
GitHub Issues · utterances