# 从 XCTest 迁移到 Swift Testing > 把 XCTest 单元测试改成 Swift Testing:@Test、#expect 与 #require、套件、参数化测试、特性、异步确认,以及哪些测试继续留在 XCTest。 - Canonical page: https://obelisk.club/blog/xctest-to-swift-testing-migration-zh - Author: Peter Zhang - Published: 2026-11-01 - Tags: swift-testing, xctest, testing, xcode, swift, indie, 中文 Swift Testing 用标记了 `@Test` 的普通函数取代 `XCTestCase` 类,用两个宏取代整个 `XCTAssert` 系列:`#expect`(记录失败并继续执行)和 `#require`(抛出错误并停止)。`setUp` 和 `tearDown` 变成 `init()` 和 `deinit`。迁移可以一个文件一个文件地做,因为两个框架可以放在同一个测试 target 里,甚至同一个文件里。UI 测试和性能测试仍然留在 XCTest。想在浏览器里转换一个 XCTest 文件,可以试试我们的 [XCTest 转 Swift Testing 工具](/tools/swift-testing-xctest)。 Swift Testing 随 Swift 6.0 和 Xcode 16 提供。Apple 的 Xcode 测试页面说,Xcode 会继续包含 XCTest,用来编写 UI 测试。 ## 导入与转换测试类 把 `import XCTest` 换成 `import Testing`。如果一个文件里还同时有两个框架的测试,就两个都导入。Apple 的迁移文章说,同一个源文件里可以同时包含用两种框架写的测试。 `XCTestCase` 的子类变成套件(suite):去掉对 `XCTestCase` 的继承即可。Apple 建议尽量用结构体或 actor 而不是类,这样编译器能更好地检查并发安全。给类型加 `@Suite` 是可选的,需要给套件起显示名或加特性时才加。 任何带 `@Test` 的函数都是一个测试,不需要以 `test` 开头,可以是 `async`、`throws`,也可以隔离到某个全局 actor。有一点要留意:XCTest 默认在主 actor 上运行同步测试方法,而 Swift Testing 在任意任务上运行测试函数。如果测试会碰到与界面相关的代码,要加上 `@MainActor`。 套件类型里的每个测试方法都在该类型的一个全新实例上运行,所以状态不会从一个测试漏到下一个。 ## 改写前后 先看一个 XCTest 类,包含常见的 setUp、tearDown、相等判断、会抛错的调用和解包: ```swift import XCTest @testable import Cart final class CartXCTests: XCTestCase { var cart: Cart! override func setUp() { super.setUp() cart = Cart() } override func tearDown() { cart = nil super.tearDown() } func testAddIncreasesCount() { cart.add("tea") XCTAssertEqual(cart.items.count, 1) XCTAssertFalse(cart.items.isEmpty) } func testUnknownItemThrows() { XCTAssertThrowsError(try cart.price(of: "gold")) } func testLookup() throws { let price = try XCTUnwrap(cart.lookup("tea")) XCTAssertEqual(price, 3) } } ``` 下面是用 Swift Testing 写的同样的测试。带初始值的存储属性就顶替了 `setUp`,因为每个测试都有一个新实例: ```swift import Testing @testable import Cart @Suite("Cart") struct CartTests { let cart = Cart() @Test func addIncreasesCount() { cart.add("tea") #expect(cart.items.count == 1) #expect(!cart.items.isEmpty) } @Test func unknownItemThrows() { #expect(throws: CartError.self) { try cart.price(of: "gold") } } @Test func lookup() throws { let price = try #require(cart.lookup("tea")) #expect(price == 3) } } ``` ## 准备与清理 Apple 给的规则是:用 `init()`,需要时再加 `deinit`,来代替 `setUp()` 和 `tearDown()`。`init` 可以是 `async` 和 `throws`。需要清理时,把套件声明成类或 actor,因为只有它们才能有 `deinit`。 ```swift final class Scratch: @unchecked Sendable { static nonisolated(unsafe) var live = 0 init() { Scratch.live += 1 } deinit { Scratch.live -= 1 } } @Suite(.serialized) final class ScratchTests { let scratch = Scratch() deinit { /* clean up files or fixtures here */ } @Test func isLive() { #expect(Scratch.live >= 1) } } ``` ## 断言对照表 下表来自 Apple 的迁移文章,只保留最常遇到的几行。 | XCTest | Swift Testing | |---|---| | `XCTAssert(x)`、`XCTAssertTrue(x)` | `#expect(x)` | | `XCTAssertFalse(x)` | `#expect(!x)` | | `XCTAssertNil(x)` | `#expect(x == nil)` | | `XCTAssertNotNil(x)` | `#expect(x != nil)` | | `XCTAssertEqual(x, y)` | `#expect(x == y)` | | `XCTAssertNotEqual(x, y)` | `#expect(x != y)` | | `XCTAssertIdentical(x, y)` | `#expect(x === y)` | | `XCTAssertGreaterThan(x, y)` | `#expect(x > y)` | | `XCTAssertLessThan(x, y)` | `#expect(x < y)` | | `XCTAssertThrowsError(try f())` | `#expect(throws: (any Error).self) { try f() }` | | `XCTAssertNoThrow(try f())` | `#expect(throws: Never.self) { try f() }` | | `try XCTUnwrap(x)` | `try #require(x)` | | `XCTFail("...")` | `Issue.record("...")` | `XCTAssertEqual(_:_:accuracy:)` 没有直接对应的写法。Apple 的建议是用 swift-numerics 包里的 `isApproximatelyEqual()` 做带误差的数值比较。`#require` 也是 `continueAfterFailure = false` 的替代:它抛出的是 Swift 错误,其他测试仍会继续运行。 ## 参数化测试 XCTest 里用循环时,看不出是哪个输入失败了;Swift Testing 则为每个输入跑一个测试用例,并分别报告。把一个集合传给 `arguments:` 即可。Apple 的文档还展示了遵循 `CaseIterable` 的枚举(传入 `allCases`,新增一个 case 不用改测试就会被覆盖)和整数范围。 ```swift @Test("Known prices", arguments: [("tea", 3), ("cake", 5)]) func price(item: String, expected: Int) throws { #expect(try cart.price(of: item) == expected) } ``` 参数化测试的各个用例默认彼此并行运行。 ## 特性(Traits) 特性是传给 `@Test` 或 `@Suite` 的附加参数。套件上的特性会被其中的测试继承。 ```swift import Foundation import Testing extension Tag { @Tag static var pricing: Self } @Suite(.tags(.pricing)) struct TraitTests { @Test(.disabled("Waiting for the new price list")) func futurePrices() { #expect(Bool(false)) } @Test(.bug("https://example.com/issues/42", "Price rounds wrong"), .timeLimit(.minutes(1))) func slowPath() { #expect(Cart().lookup("tea") == 3) } @Test(.enabled(if: ProcessInfo.processInfo.environment["RUN_SLOW"] == nil)) func enabledByDefault() { #expect(true) } } ``` - `.disabled(_:)` 带着说明跳过一个测试,`.enabled(if:)` 只在条件成立时运行。它们取代了 `XCTSkip`、`XCTSkipIf` 和 `XCTSkipUnless`。 - `.bug(_:_:)` 把测试关联到问题追踪里的一条记录。 - `.tags(_:)` 接收用 `@Tag` 宏声明的 `Tag` 值,用来给测试分组和筛选。 - `.timeLimit(_:)` 让运行过久的测试失败,可用版本为 iOS 16、macOS 13 和 watchOS 9。 - `.serialized` 让套件里的测试依次运行。Swift Testing 默认并行运行测试,而 XCTest 默认逐个运行同一个类里的测试,所以如果测试共享全局状态,要加上 `.serialized`。 对于预期会失败的情况,`withKnownIssue { ... }` 取代 `XCTExpectFailure`。Apple 指出,不带闭包的 `XCTExpectFailure` 没有直接对应物;改用 `withKnownIssue` 包住整个测试体即可。 ## 用 confirmation 测试异步回调 能直接 `await` 异步函数就直接 `await`,或者用 continuation 包装带回调的函数。Apple 的文章推荐这样做,而不是用 expectation。对于自己到来的事件,比如稍后才触发的回调,用 `confirmation(_:expectedCount:)`,它对应 `XCTestExpectation`。 ```swift @Test func changeHandlerFires() async { await confirmation("onChange called") { changed in cart.onChange = { changed() } cart.add("tea") } } @Test func changeHandlerFiresTwice() async { await confirmation(expectedCount: 2) { changed in cart.onChange = { changed() } cart.add("tea") cart.add("cake") } } ``` 和 expectation 有两点不同。confirmation 不会阻塞或等待:事件必须在 `confirmation` 返回之前发生,否则就会记录一个问题。默认值是恰好确认一次,这点和 `XCTestExpectation` 一样。如果要表达"至少几次",可以传一个有下界的范围,比如 `1...` 或 `0 ..< 100`;`expectedCount: 0` 用来检查某件事从未发生。 ## 仍留在 XCTest 里的测试 - **UI 测试。** Apple 的 Xcode 测试页面说,Xcode 保留 XCTest 用于控制 App 界面的 UI 测试。Apple 的"添加测试"指南也把 UI 测试描述为 `XCTestCase` 的子类。 - **性能测试。** Apple 的性能测试指南说它们使用 XCTest 框架,在 `XCTestCase` 里调用 `measure`。Swift Testing 没有对应功能。 - **依赖 XCTest 专有 API 的测试**,比如带 accuracy 参数的 `XCTAssertEqual`。 ## 两者并行使用 Apple 的指南说,Swift Testing 和 XCTest 可以在同一个测试 target 里构建,并共存于同一个测试包中,所以不需要新建 target。有时间再逐个文件转换即可。 辅助函数可以共用。启用互操作后,被 Swift Testing 测试调用的辅助函数里的 `XCTAssert` 会被报告为失败;被 XCTest 方法调用的辅助函数里的 `#expect` 会让该方法失败。默认模式取决于工具链:工具链低于 6.4 时是 `none`;6.4 及以上时是 `limited`,如果包的 `swift-tools-version` 也是 6.4 及以上,则是 `complete`。想自己指定模式,可以设置环境变量 `SWIFT_TESTING_XCTEST_INTEROP_MODE`。更稳妥的做法是先把共用的辅助函数改成 `#expect`,Apple 的文章把这描述为可以按自己的节奏逐步迁移测试基础设施的做法: ```swift func assertUnique(_ elements: [Int]) { #expect(Set(elements).count == elements.count) } @Test func uniqueElements() { assertUnique([1, 2, 3]) } final class UniqueXC: XCTestCase { func testUnique() { assertUnique([1, 2, 3]) } func testMeasure() { measure { _ = (0..<1000).map { $0 * 2 } } } } ``` ## 来源 以下页面均阅读于 2026 年 11 月 1 日。 - [Migrating a test from XCTest](https://developer.apple.com/documentation/testing/migratingfromxctest)(Swift Testing;对照表、准备与清理、互操作、已知问题) - [Testing](https://developer.apple.com/documentation/xcode/testing)(Xcode) - [Adding tests to your Xcode project](https://developer.apple.com/documentation/xcode/adding-tests-to-your-xcode-project)(Xcode) - [Writing and running performance tests](https://developer.apple.com/documentation/xcode/writing-and-running-performance-tests)(Xcode) - [Implementing parameterized tests](https://developer.apple.com/documentation/testing/parameterizedtesting) - [Organizing test functions with suite types](https://developer.apple.com/documentation/testing/organizingtests) - [Traits](https://developer.apple.com/documentation/testing/traits),含 `timeLimit(_:)`、`disabled(_:sourceLocation:)`、`enabled(if:_:sourceLocation:)`、`bug(_:_:)` 和 `tags(_:)` - [Testing asynchronous code](https://developer.apple.com/documentation/testing/testing-asynchronous-code) *Apple 的 API 和默认行为可能变化,请以上述页面的最新内容为准。*