跳到正文
返回博客
·约 2 分钟读完

从 XCTest 迁移到 Swift Testing

把 XCTest 单元测试改成 Swift Testing:@Test、#expect 与 #require、套件、参数化测试、特性、异步确认,以及哪些测试继续留在 XCTest。

Swift Testing 用标记了 @Test 的普通函数取代 XCTestCase 类,用两个宏取代整个 XCTAssert 系列:#expect(记录失败并继续执行)和 #require(抛出错误并停止)。setUp 和 tearDown 变成 init() 和 deinit。迁移可以一个文件一个文件地做,因为两个框架可以放在同一个测试 target 里,甚至同一个文件里。UI 测试和性能测试仍然留在 XCTest。想在浏览器里转换一个 XCTest 文件,可以试试我们的 XCTest 转 Swift Testing 工具。

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、相等判断、会抛错的调用和解包:

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,因为每个测试都有一个新实例:

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。

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 的迁移文章,只保留最常遇到的几行。

XCTestSwift 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 不用改测试就会被覆盖)和整数范围。

@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 的附加参数。套件上的特性会被其中的测试继承。

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。

@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 的文章把这描述为可以按自己的节奏逐步迁移测试基础设施的做法:

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 日。

Apple 的 API 和默认行为可能变化,请以上述页面的最新内容为准。

作者

独立开发者,在 Obelisk Club 做本地优先的 Mac 与 iOS 效率应用。 关于作者 →