axiom-swift-testing

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Swift Testing

Swift Testing

Overview

概述

Swift Testing is Apple's modern testing framework introduced at WWDC 2024. It uses Swift macros (
@Test
,
#expect
) instead of naming conventions, runs tests in parallel by default, and integrates seamlessly with Swift concurrency.
Core principle: Tests should be fast, reliable, and expressive. The fastest tests run without launching your app or simulator.
Swift Testing是苹果在WWDC 2024上推出的现代测试框架。它使用Swift宏(
@Test
#expect
)替代命名约定,默认并行运行测试,并与Swift并发无缝集成。
核心原则:测试应快速、可靠且具表达性。最快的测试无需启动应用或模拟器即可运行。

The Speed Hierarchy

速度层级

Tests run at dramatically different speeds depending on how they're configured:
ConfigurationTypical TimeUse Case
swift test
(Package)
~0.1sPure logic, models, algorithms
Host Application: None~3sFramework code, no UI dependencies
Bypass app launch~6sApp target but skip initialization
Full app launch20-60sUI tests, integration tests
Key insight: Move testable logic into Swift Packages or frameworks, then test with
swift test
or "None" host application.

测试的运行速度因配置不同而有显著差异:
配置典型耗时使用场景
swift test
(Package)
~0.1s纯逻辑、模型、算法
宿主应用:无~3s框架代码,无UI依赖
跳过应用启动~6s应用目标但跳过初始化
完整应用启动20-60sUI测试、集成测试
关键见解:将可测试逻辑迁移到Swift Package或框架中,然后使用
swift test
或“无”宿主应用进行测试。

Building Blocks

核心组件

@Test Functions

@Test 函数

swift
import Testing

@Test func videoHasCorrectMetadata() {
    let video = Video(named: "example.mp4")
    #expect(video.duration == 120)
}
Key differences from XCTest:
  • No
    test
    prefix required —
    @Test
    attribute is explicit
  • Can be global functions, not just methods in a class
  • Supports
    async
    ,
    throws
    , and actor isolation
  • Each test runs on a fresh instance of its containing suite
swift
import Testing

@Test func videoHasCorrectMetadata() {
    let video = Video(named: "example.mp4")
    #expect(video.duration == 120)
}
与XCTest的主要区别
  • 无需
    test
    前缀——
    @Test
    属性是显式的
  • 可以是全局函数,不局限于类中的方法
  • 支持
    async
    throws
    和actor隔离
  • 每个测试都会在其所属套件的新实例上运行

#expect and #require

#expect 和 #require

swift
// Basic expectation — test continues on failure
#expect(result == expected)
#expect(array.isEmpty)
#expect(numbers.contains(42))

// Required expectation — test stops on failure
let user = try #require(await fetchUser(id: 123))
#expect(user.name == "Alice")

// Unwrap optionals safely
let first = try #require(items.first)
#expect(first.isValid)
Why #expect is better than XCTAssert:
  • Captures source code and sub-values automatically
  • Single macro handles all operators (==, >, contains, etc.)
  • No need for specialized assertions (XCTAssertEqual, XCTAssertNil, etc.)
swift
// 基础断言——失败后测试继续
#expect(result == expected)
#expect(array.isEmpty)
#expect(numbers.contains(42))

// 必要断言——失败后测试停止
let user = try #require(await fetchUser(id: 123))
#expect(user.name == "Alice")

// 安全解包可选值
let first = try #require(items.first)
#expect(first.isValid)
#expect 优于 XCTAssert 的原因
  • 自动捕获源代码和子值
  • 单个宏支持所有运算符(==、>、contains等)
  • 无需专用断言(XCTAssertEqual、XCTAssertNil等)

Error Testing

错误测试

swift
// Expect any error
#expect(throws: (any Error).self) {
    try dangerousOperation()
}

// Expect specific error type
#expect(throws: NetworkError.self) {
    try fetchData()
}

// Expect specific error value
#expect(throws: ValidationError.invalidEmail) {
    try validate(email: "not-an-email")
}

// Custom validation
#expect {
    try process(data)
} throws: { error in
    guard let networkError = error as? NetworkError else { return false }
    return networkError.statusCode == 404
}
swift
// 断言会抛出任意错误
#expect(throws: (any Error).self) {
    try dangerousOperation()
}

// 断言会抛出特定错误类型
#expect(throws: NetworkError.self) {
    try fetchData()
}

// 断言会抛出特定错误值
#expect(throws: ValidationError.invalidEmail) {
    try validate(email: "not-an-email")
}

// 自定义验证
#expect {
    try process(data)
} throws: { error in
    guard let networkError = error as? NetworkError else { return false }
    return networkError.statusCode == 404
}

@Suite Types

@Suite 类型

swift
@Suite("Video Processing Tests")
struct VideoTests {
    let video = Video(named: "sample.mp4")  // Fresh instance per test

    @Test func hasCorrectDuration() {
        #expect(video.duration == 120)
    }

    @Test func hasCorrectResolution() {
        #expect(video.resolution == CGSize(width: 1920, height: 1080))
    }
}
Key behaviors:
  • Structs preferred (value semantics, no accidental state sharing)
  • Each
    @Test
    gets its own suite instance
  • Use
    init
    for setup,
    deinit
    for teardown (actors/classes only)
  • Nested suites supported for organization

swift
@Suite("Video Processing Tests")
struct VideoTests {
    let video = Video(named: "sample.mp4")  // 每个测试使用新实例

    @Test func hasCorrectDuration() {
        #expect(video.duration == 120)
    }

    @Test func hasCorrectResolution() {
        #expect(video.resolution == CGSize(width: 1920, height: 1080))
    }
}
核心特性
  • 优先使用结构体(值语义,避免意外的状态共享)
  • 每个
    @Test
    都会获取自己的套件实例
  • 使用
    init
    进行初始化,
    deinit
    进行清理(仅适用于actor/类)
  • 支持嵌套套件用于组织测试

Traits

特性

Traits customize test behavior:
swift
// Display name
@Test("User can log in with valid credentials")
func loginWithValidCredentials() { }

// Disable with reason
@Test(.disabled("Waiting for backend fix"))
func brokenFeature() { }

// Conditional execution
@Test(.enabled(if: FeatureFlags.newUIEnabled))
func newUITest() { }

// Time limit
@Test(.timeLimit(.minutes(1)))
func longRunningTest() async { }

// Bug reference
@Test(.bug("https://github.com/org/repo/issues/123", "Flaky on CI"))
func sometimesFailingTest() { }

// OS version requirement
@available(iOS 18, *)
@Test func iOS18OnlyFeature() { }
特性可自定义测试行为:
swift
// 显示名称
@Test("User can log in with valid credentials")
func loginWithValidCredentials() { }

// 禁用并说明原因
@Test(.disabled("Waiting for backend fix"))
func brokenFeature() { }

// 条件执行
@Test(.enabled(if: FeatureFlags.newUIEnabled))
func newUITest() { }

// 时间限制
@Test(.timeLimit(.minutes(1)))
func longRunningTest() async { }

// Bug 引用
@Test(.bug("https://github.com/org/repo/issues/123", "Flaky on CI"))
func sometimesFailingTest() { }

// 操作系统版本要求
@available(iOS 18, *)
@Test func iOS18OnlyFeature() { }

Tags for Organization

用于组织的标签

swift
// Define tags
extension Tag {
    @Tag static var networking: Self
    @Tag static var performance: Self
    @Tag static var slow: Self
}

// Apply to tests
@Test(.tags(.networking, .slow))
func networkIntegrationTest() async { }

// Apply to entire suite
@Suite(.tags(.performance))
struct PerformanceTests {
    @Test func benchmarkSort() { }  // Inherits .performance tag
}
Use tags to:
  • Run subsets of tests (filter by tag in Test Navigator)
  • Exclude slow tests from quick feedback loops
  • Group related tests across different files/suites

swift
// 定义标签
extension Tag {
    @Tag static var networking: Self
    @Tag static var performance: Self
    @Tag static var slow: Self
}

// 应用到测试
@Test(.tags(.networking, .slow))
func networkIntegrationTest() async { }

// 应用到整个套件
@Suite(.tags(.performance))
struct PerformanceTests {
    @Test func benchmarkSort() { }  // 继承.performance标签
}
标签用途
  • 运行测试子集(在测试导航器中按标签筛选)
  • 在快速反馈循环中排除慢速测试
  • 跨不同文件/套件分组相关测试

Parameterized Testing

参数化测试

Transform repetitive tests into a single parameterized test:
swift
// ❌ Before: Repetitive
@Test func vanillaHasNoNuts() {
    #expect(!IceCream.vanilla.containsNuts)
}
@Test func chocolateHasNoNuts() {
    #expect(!IceCream.chocolate.containsNuts)
}
@Test func almondHasNuts() {
    #expect(IceCream.almond.containsNuts)
}

// ✅ After: Parameterized
@Test(arguments: [IceCream.vanilla, .chocolate, .strawberry])
func flavorWithoutNuts(_ flavor: IceCream) {
    #expect(!flavor.containsNuts)
}

@Test(arguments: [IceCream.almond, .pistachio])
func flavorWithNuts(_ flavor: IceCream) {
    #expect(flavor.containsNuts)
}
将重复测试转换为单个参数化测试:
swift
// ❌ 之前:重复代码
@Test func vanillaHasNoNuts() {
    #expect(!IceCream.vanilla.containsNuts)
}
@Test func chocolateHasNoNuts() {
    #expect(!IceCream.chocolate.containsNuts)
}
@Test func almondHasNuts() {
    #expect(IceCream.almond.containsNuts)
}

// ✅ 之后:参数化
@Test(arguments: [IceCream.vanilla, .chocolate, .strawberry])
func flavorWithoutNuts(_ flavor: IceCream) {
    #expect(!flavor.containsNuts)
}

@Test(arguments: [IceCream.almond, .pistachio])
func flavorWithNuts(_ flavor: IceCream) {
    #expect(flavor.containsNuts)
}

Two-Collection Parameterization

双集合参数化

swift
// Test all combinations (4 × 3 = 12 test cases)
@Test(arguments: [1, 2, 3, 4], ["a", "b", "c"])
func allCombinations(number: Int, letter: String) {
    // Tests: (1,"a"), (1,"b"), (1,"c"), (2,"a"), ...
}

// Test paired values only (3 test cases)
@Test(arguments: zip([1, 2, 3], ["one", "two", "three"]))
func pairedValues(number: Int, name: String) {
    // Tests: (1,"one"), (2,"two"), (3,"three")
}
swift
// 测试所有组合(4 × 3 = 12个测试用例)
@Test(arguments: [1, 2, 3, 4], ["a", "b", "c"])
func allCombinations(number: Int, letter: String) {
    // 测试用例:(1,"a"), (1,"b"), (1,"c"), (2,"a"), ...
}

// 仅测试配对值(3个测试用例)
@Test(arguments: zip([1, 2, 3], ["one", "two", "three"]))
func pairedValues(number: Int, name: String) {
    // 测试用例:(1,"one"), (2,"two"), (3,"three")
}

Benefits Over For-Loops

优于for循环的优势

For-LoopParameterized
Stops on first failureAll arguments run
Unclear which value failedEach argument shown separately
Sequential executionParallel execution
Can't re-run single caseRe-run individual arguments

For循环参数化测试
首次失败后停止运行所有参数
无法明确哪个值导致失败每个参数的结果单独显示
顺序执行并行执行
无法重新运行单个用例可重新运行单个参数用例

Fast Tests: Architecture for Testability

快速测试:为可测试性构建架构

Strategy 1: Swift Package for Logic (Fastest)

策略1:将逻辑放入Swift Package(最快)

Move pure logic into a Swift Package:
MyApp/
├── MyApp/                    # App target (UI, app lifecycle)
├── MyAppCore/                # Swift Package (testable logic)
│   ├── Package.swift
│   └── Sources/
│       └── MyAppCore/
│           ├── Models/
│           ├── Services/
│           └── Utilities/
└── MyAppCoreTests/           # Package tests
Run with
swift test
— no simulator, no app launch:
bash
cd MyAppCore
swift test  # Runs in ~0.1 seconds
将纯逻辑迁移到Swift Package:
MyApp/
├── MyApp/                    # 应用目标(UI、应用生命周期)
├── MyAppCore/                # Swift Package(可测试逻辑)
│   ├── Package.swift
│   └── Sources/
│       └── MyAppCore/
│           ├── Models/
│           ├── Services/
│           └── Utilities/
└── MyAppCoreTests/           # Package测试
使用
swift test
运行——无需模拟器,无需启动应用:
bash
cd MyAppCore
swift test  # 约0.1秒完成

Strategy 2: Framework with No Host Application

策略2:使用无宿主应用的框架

For code that must stay in the app project:
  1. Create a framework target (File → New → Target → Framework)
  2. Move model code into the framework
  3. Make types public that need external access
  4. Add imports in files using the framework
  5. Set Host Application to "None" in test target settings
Project Settings → Test Target → Testing
  Host Application: None  ← Key setting
  ☐ Allow testing Host Application APIs
Build+test time: ~3 seconds vs 20-60 seconds with app launch.
对于必须保留在应用项目中的代码:
  1. 创建框架目标(文件 → 新建 → 目标 → 框架)
  2. 将模型代码迁移到框架
  3. 将需要外部访问的类型设为public
  4. 在使用框架的文件中添加导入语句
  5. 在测试目标设置中将宿主应用设为“无”
项目设置 → 测试目标 → 测试
  宿主应用:无  ← 关键设置
  ☐ 允许测试宿主应用API
构建+测试耗时:约3秒,相比启动应用的20-60秒大幅缩短。

Strategy 3: Bypass SwiftUI App Launch

策略3:绕过SwiftUI应用启动

If you can't use a framework, bypass the app launch:
swift
// Simple solution (no custom startup code)
@main
struct ProductionApp: App {
    var body: some Scene {
        WindowGroup {
            if !isRunningTests {
                ContentView()
            }
        }
    }

    private var isRunningTests: Bool {
        NSClassFromString("XCTestCase") != nil
    }
}
swift
// Thorough solution (custom startup code)
@main
struct MainEntryPoint {
    static func main() {
        if NSClassFromString("XCTestCase") != nil {
            TestApp.main()  // Empty app for tests
        } else {
            ProductionApp.main()
        }
    }
}

struct TestApp: App {
    var body: some Scene {
        WindowGroup { }  // Empty
    }
}

如果无法使用框架,可绕过应用启动:
swift
// 简单方案(无自定义启动代码)
@main
struct ProductionApp: App {
    var body: some Scene {
        WindowGroup {
            if !isRunningTests {
                ContentView()
            }
        }
    }

    private var isRunningTests: Bool {
        NSClassFromString("XCTestCase") != nil
    }
}
swift
// 完整方案(自定义启动代码)
@main
struct MainEntryPoint {
    static func main() {
        if NSClassFromString("XCTestCase") != nil {
            TestApp.main()  // 测试用空应用
        } else {
            ProductionApp.main()
        }
    }
}

struct TestApp: App {
    var body: some Scene {
        WindowGroup { }  // 空界面
    }
}

Async Testing

异步测试

Basic Async Tests

基础异步测试

swift
@Test func fetchUserReturnsData() async throws {
    let user = try await userService.fetch(id: 123)
    #expect(user.name == "Alice")
}
swift
@Test func fetchUserReturnsData() async throws {
    let user = try await userService.fetch(id: 123)
    #expect(user.name == "Alice")
}

Testing Callbacks with Continuations

使用Continuations测试回调

swift
// Convert completion handler to async
@Test func legacyAPIWorks() async throws {
    let result = try await withCheckedThrowingContinuation { continuation in
        legacyService.fetchData { result in
            continuation.resume(with: result)
        }
    }
    #expect(result.count > 0)
}
swift
// 将完成处理程序转换为异步
@Test func legacyAPIWorks() async throws {
    let result = try await withCheckedThrowingContinuation { continuation in
        legacyService.fetchData { result in
            continuation.resume(with: result)
        }
    }
    #expect(result.count > 0)
}

Confirmations for Multiple Events

多事件确认

swift
@Test func cookiesAreEaten() async {
    await confirmation("cookie eaten", expectedCount: 10) { confirm in
        let jar = CookieJar(count: 10)
        jar.onCookieEaten = { confirm() }
        await jar.eatAll()
    }
}

// Confirm something never happens
await confirmation(expectedCount: 0) { confirm in
    let cache = Cache()
    cache.onEviction = { confirm() }
    cache.store("small-item")  // Should not trigger eviction
}
swift
@Test func cookiesAreEaten() async {
    await confirmation("cookie eaten", expectedCount: 10) { confirm in
        let jar = CookieJar(count: 10)
        jar.onCookieEaten = { confirm() }
        await jar.eatAll()
    }
}

// 确认某事件从未发生
await confirmation(expectedCount: 0) { confirm in
    let cache = Cache()
    cache.onEviction = { confirm() }
    cache.store("small-item")  // 不应触发驱逐
}

Reliable Async Testing with Concurrency Extras

使用并发工具实现可靠的异步测试

Problem: Async tests can be flaky due to scheduling unpredictability.
swift
// ❌ Flaky: Task scheduling is unpredictable
@Test func loadingStateChanges() async {
    let model = ViewModel()
    let task = Task { await model.loadData() }
    #expect(model.isLoading == true)  // Often fails!
    await task.value
}
Solution: Use Point-Free's
swift-concurrency-extras
:
swift
import ConcurrencyExtras

@Test func loadingStateChanges() async {
    await withMainSerialExecutor {
        let model = ViewModel()
        let task = Task { await model.loadData() }
        await Task.yield()
        #expect(model.isLoading == true)  // Deterministic!
        await task.value
        #expect(model.isLoading == false)
    }
}
Why it works: Serializes async work to main thread, making suspension points deterministic.
问题:由于调度不可预测,异步测试可能不稳定。
swift
// ❌ 不稳定:任务调度不可预测
@Test func loadingStateChanges() async {
    let model = ViewModel()
    let task = Task { await model.loadData() }
    #expect(model.isLoading == true)  // 经常失败!
    await task.value
}
解决方案:使用Point-Free的
swift-concurrency-extras
swift
import ConcurrencyExtras

@Test func loadingStateChanges() async {
    await withMainSerialExecutor {
        let model = ViewModel()
        let task = Task { await model.loadData() }
        await Task.yield()
        #expect(model.isLoading == true)  // 确定执行!
        await task.value
        #expect(model.isLoading == false)
    }
}
原理:将异步工作序列化到主线程,使挂起点可预测。

Deterministic Time with TestClock

使用TestClock实现确定的时间控制

Use Point-Free's
swift-clocks
to control time in tests:
swift
import Clocks

@MainActor
class FeatureModel: ObservableObject {
    @Published var count = 0
    let clock: any Clock<Duration>
    var timerTask: Task<Void, Error>?

    init(clock: any Clock<Duration>) {
        self.clock = clock
    }

    func startTimer() {
        timerTask = Task {
            while true {
                try await clock.sleep(for: .seconds(1))
                count += 1
            }
        }
    }
}

// Test with controlled time
@Test func timerIncrements() async {
    let clock = TestClock()
    let model = FeatureModel(clock: clock)

    model.startTimer()

    await clock.advance(by: .seconds(1))
    #expect(model.count == 1)

    await clock.advance(by: .seconds(4))
    #expect(model.count == 5)

    model.timerTask?.cancel()
}
Clock types:
  • TestClock
    — Advance time manually, deterministic
  • ImmediateClock
    — All sleeps return instantly (great for previews)
  • UnimplementedClock
    — Fails if used (catch unexpected time dependencies)

使用Point-Free的
swift-clocks
在测试中控制时间:
swift
import Clocks

@MainActor
class FeatureModel: ObservableObject {
    @Published var count = 0
    let clock: any Clock<Duration>
    var timerTask: Task<Void, Error>?

    init(clock: any Clock<Duration>) {
        self.clock = clock
    }

    func startTimer() {
        timerTask = Task {
            while true {
                try await clock.sleep(for: .seconds(1))
                count += 1
            }
        }
    }
}

// 使用受控时间进行测试
@Test func timerIncrements() async {
    let clock = TestClock()
    let model = FeatureModel(clock: clock)

    model.startTimer()

    await clock.advance(by: .seconds(1))
    #expect(model.count == 1)

    await clock.advance(by: .seconds(4))
    #expect(model.count == 5)

    model.timerTask?.cancel()
}
时钟类型
  • TestClock
    — 手动推进时间,执行确定
  • ImmediateClock
    — 所有sleep立即返回(非常适合预览)
  • UnimplementedClock
    — 使用时会失败(捕获意外的时间依赖)

Parallel Testing

并行测试

Swift Testing runs tests in parallel by default.
Swift Testing默认并行运行测试。

When to Serialize

何时序列化执行

swift
// Serialize tests in a suite that share external state
@Suite(.serialized)
struct DatabaseTests {
    @Test func createUser() { }
    @Test func deleteUser() { }  // Runs after createUser
}

// Serialize parameterized test cases
@Test(.serialized, arguments: [1, 2, 3])
func sequentialProcessing(value: Int) { }
swift
// 序列化共享外部状态的套件中的测试
@Suite(.serialized)
struct DatabaseTests {
    @Test func createUser() { }
    @Test func deleteUser() { }  // 在createUser之后运行
}

// 序列化参数化测试用例
@Test(.serialized, arguments: [1, 2, 3])
func sequentialProcessing(value: Int) { }

Hidden Dependencies

隐藏依赖

swift
// ❌ Bug: Tests depend on execution order
@Suite struct CookieTests {
    static var cookie: Cookie?

    @Test func bakeCookie() {
        Self.cookie = Cookie()  // Sets shared state
    }

    @Test func eatCookie() {
        #expect(Self.cookie != nil)  // Fails if runs first!
    }
}

// ✅ Fixed: Each test is independent
@Suite struct CookieTests {
    @Test func bakeCookie() {
        let cookie = Cookie()
        #expect(cookie.isBaked)
    }

    @Test func eatCookie() {
        let cookie = Cookie()
        cookie.eat()
        #expect(cookie.isEaten)
    }
}
Random order helps expose these bugs — fix them rather than serialize.

swift
// ❌ 错误:测试依赖执行顺序
@Suite struct CookieTests {
    static var cookie: Cookie?

    @Test func bakeCookie() {
        Self.cookie = Cookie()  // 设置共享状态
    }

    @Test func eatCookie() {
        #expect(Self.cookie != nil)  // 如果先运行则失败!
    }
}

// ✅ 修复:每个测试独立
@Suite struct CookieTests {
    @Test func bakeCookie() {
        let cookie = Cookie()
        #expect(cookie.isBaked)
    }

    @Test func eatCookie() {
        let cookie = Cookie()
        cookie.eat()
        #expect(cookie.isEaten)
    }
}
随机顺序有助于暴露此类错误——应修复错误而非序列化执行。

Known Issues

已知问题

Handle expected failures without noise:
swift
@Test func featureUnderDevelopment() {
    withKnownIssue("Backend not ready yet") {
        try callUnfinishedAPI()
    }
}

// Conditional known issue
@Test func platformSpecificBug() {
    withKnownIssue("Fails on iOS 17.0") {
        try reproduceEdgeCaseBug()
    } when: {
        ProcessInfo().operatingSystemVersion.majorVersion == 17
    }
}
Better than .disabled because:
  • Test still compiles (catches syntax errors)
  • You're notified when the issue is fixed
  • Results show "expected failure" not "skipped"

处理预期失败而不产生冗余信息:
swift
@Test func featureUnderDevelopment() {
    withKnownIssue("Backend not ready yet") {
        try callUnfinishedAPI()
    }
}

// 条件性已知问题
@Test func platformSpecificBug() {
    withKnownIssue("Fails on iOS 17.0") {
        try reproduceEdgeCaseBug()
    } when: {
        ProcessInfo().operatingSystemVersion.majorVersion == 17
    }
}
比.disabled更好的原因
  • 测试仍会编译(捕获语法错误)
  • 问题修复时会收到通知
  • 结果显示“预期失败”而非“已跳过”

Migration from XCTest

从XCTest迁移

Comparison Table

对比表

XCTestSwift Testing
func testFoo()
@Test func foo()
XCTAssertEqual(a, b)
#expect(a == b)
XCTAssertNil(x)
#expect(x == nil)
XCTAssertThrowsError
#expect(throws:)
XCTUnwrap(x)
try #require(x)
class FooTests: XCTestCase
@Suite struct FooTests
setUp()
/
tearDown()
init
/
deinit
continueAfterFailure = false
#require
(per-expectation)
addTeardownBlock
deinit
or defer
XCTestSwift Testing
func testFoo()
@Test func foo()
XCTAssertEqual(a, b)
#expect(a == b)
XCTAssertNil(x)
#expect(x == nil)
XCTAssertThrowsError
#expect(throws:)
XCTUnwrap(x)
try #require(x)
class FooTests: XCTestCase
@Suite struct FooTests
setUp()
/
tearDown()
init
/
deinit
continueAfterFailure = false
#require
(按断言设置)
addTeardownBlock
deinit
或 defer

Keep Using XCTest For

仍需使用XCTest的场景

  • UI tests (XCUIApplication)
  • Performance tests (XCTMetric)
  • Objective-C tests
  • UI测试(XCUIApplication)
  • 性能测试(XCTMetric)
  • Objective-C测试

Migration Tips

迁移技巧

  1. Both frameworks can coexist in the same target
  2. Migrate incrementally, one test file at a time
  3. Consolidate similar XCTests into parameterized Swift tests
  4. Single-test XCTestCase → global
    @Test
    function

  1. 两个框架可在同一目标中共存
  2. 逐步迁移,一次迁移一个测试文件
  3. 将相似的XCTest合并为参数化Swift测试
  4. 单个测试的XCTestCase → 全局
    @Test
    函数

Common Mistakes

常见错误

❌ Mixing Assertions

❌ 混合使用断言

swift
// Don't mix XCTest and Swift Testing
@Test func badExample() {
    XCTAssertEqual(1, 1)  // ❌ Wrong framework
    #expect(1 == 1)       // ✅ Use this
}
swift
// 不要混合XCTest和Swift Testing
@Test func badExample() {
    XCTAssertEqual(1, 1)  // ❌ 错误的框架
    #expect(1 == 1)       // ✅ 使用这个
}

❌ Using Classes for Suites

❌ 使用类作为套件

swift
// ❌ Avoid: Reference semantics can cause shared state bugs
@Suite class VideoTests { }

// ✅ Prefer: Value semantics isolate each test
@Suite struct VideoTests { }
swift
// ❌ 避免:引用语义可能导致状态共享错误
@Suite class VideoTests { }

// ✅ 推荐:值语义隔离每个测试
@Suite struct VideoTests { }

❌ Forgetting @MainActor

❌ 忘记@MainActor

swift
// ❌ May fail with Swift 6 strict concurrency
@Test func updateUI() async {
    viewModel.updateTitle("New")  // Data race warning
}

// ✅ Isolate to main actor
@Test @MainActor func updateUI() async {
    viewModel.updateTitle("New")
}
swift
// ❌ 在Swift 6严格并发模式下可能失败
@Test func updateUI() async {
    viewModel.updateTitle("New")  // 数据竞争警告
}

// ✅ 隔离到主线程actor
@Test @MainActor func updateUI() async {
    viewModel.updateTitle("New")
}

❌ Over-Serializing

❌ 过度序列化

swift
// ❌ Don't serialize just because tests use async
@Suite(.serialized) struct APITests { }  // Defeats parallelism

// ✅ Only serialize when tests truly share mutable state
swift
// ❌ 不要仅因为测试使用异步就序列化
@Suite(.serialized) struct APITests { }  // 失去并行优势

// ✅ 仅当测试确实共享可变状态时才序列化

❌ XCTestCase with Swift 6.2 MainActor Default

❌ Swift 6.2默认MainActor下的XCTestCase

Swift 6.2's
default-actor-isolation = MainActor
breaks XCTestCase:
swift
// ❌ Error: Main actor-isolated initializer 'init()' has different
// actor isolation from nonisolated overridden declaration
final class PlaygroundTests: XCTestCase {
    override func setUp() async throws {
        try await super.setUp()
    }
}
Solution: Mark XCTestCase subclass as
nonisolated
:
swift
// ✅ Works with MainActor default isolation
nonisolated final class PlaygroundTests: XCTestCase {
    @MainActor
    override func setUp() async throws {
        try await super.setUp()
    }

    @Test @MainActor
    func testSomething() async {
        // Individual tests can be @MainActor
    }
}
Why: XCTestCase is Objective-C, not annotated for Swift concurrency. Its initializers are
nonisolated
, causing conflicts with MainActor-isolated subclasses.
Better solution: Migrate to Swift Testing (
@Suite struct
) which handles isolation properly.

Swift 6.2的
default-actor-isolation = MainActor
会破坏XCTestCase:
swift
// ❌ 错误:主线程actor隔离的初始化器'init()'与非隔离的重写声明的actor隔离不同
final class PlaygroundTests: XCTestCase {
    override func setUp() async throws {
        try await super.setUp()
    }
}
解决方案:将XCTestCase子类标记为
nonisolated
swift
// ✅ 在默认MainActor隔离下正常工作
nonisolated final class PlaygroundTests: XCTestCase {
    @MainActor
    override func setUp() async throws {
        try await super.setUp()
    }

    @Test @MainActor
    func testSomething() async {
        // 单个测试可以标记为@MainActor
    }
}
原因:XCTestCase是Objective-C实现,未针对Swift并发进行注解。其初始化器是
nonisolated
,与MainActor隔离的子类冲突。
更好的解决方案:迁移到Swift Testing(
@Suite struct
),它能正确处理隔离。

Xcode Optimization for Fast Feedback

Xcode优化以实现快速反馈

Turn Off Parallel XCTest Execution

关闭XCTest并行执行

Swift Testing runs in parallel by default; XCTest parallelization adds overhead:
Test Plan → Options → Parallelization → "Swift Testing Only"
Swift Testing默认并行运行;XCTest并行化会增加开销:
测试计划 → 选项 → 并行化 → "仅Swift Testing"

Turn Off Test Debugger

关闭测试调试器

Attaching the debugger costs ~1 second per run:
Scheme → Edit Scheme → Test → Info → ☐ Debugger
附加调试器每次运行约耗时1秒:
方案 → 编辑方案 → 测试 → 信息 → ☐ 调试器

Delete UI Test Templates

删除UI测试模板

Xcode's default UI tests slow everything down. Remove them:
  1. Delete UI test target (Project Settings → select target → -)
  2. Delete UI test source folder
Xcode默认的UI测试会拖慢所有流程。删除它们:
  1. 删除UI测试目标(项目设置 → 选择目标 → -)
  2. 删除UI测试源文件夹

Disable dSYM for Debug Builds

禁用Debug构建的dSYM

Build Settings → Debug Information Format
  Debug: DWARF
  Release: DWARF with dSYM File
构建设置 → 调试信息格式
  Debug: DWARF
  Release: DWARF with dSYM File

Check Build Scripts

检查构建脚本

Run Script phases without defined inputs/outputs cause full rebuilds. Always specify:
  • Input Files / Input File Lists
  • Output Files / Output File Lists

未定义输入/输出的Run Script阶段会导致完整重建。始终指定:
  • 输入文件 / 输入文件列表
  • 输出文件 / 输出文件列表

Checklist

检查清单

Before Writing Tests

编写测试前

  • Identify what can move to a Swift Package (pure logic)
  • Set up framework target if package isn't viable
  • Configure Host Application: None for unit tests
  • 确定可迁移到Swift Package的内容(纯逻辑)
  • 如果无法使用Package,设置框架目标
  • 为单元测试配置宿主应用:无

Writing Tests

编写测试时

  • Use
    @Test
    with clear display names
  • Use
    #expect
    for all assertions
  • Use
    #require
    to fail fast on preconditions
  • Use parameterization for similar test cases
  • Add
    .tags()
    for organization
  • 使用
    @Test
    并设置清晰的显示名称
  • 所有断言使用
    #expect
  • 使用
    #require
    在前置条件失败时快速终止
  • 对相似测试用例使用参数化
  • 添加
    .tags()
    用于组织

Async Tests

异步测试

  • Mark test functions
    async
    and use
    await
  • Use
    confirmation()
    for callback-based code
  • Consider
    withMainSerialExecutor
    for flaky tests
  • 标记测试函数为
    async
    并使用
    await
  • 对基于回调的代码使用
    confirmation()
  • 对不稳定的测试考虑使用
    withMainSerialExecutor

Parallel Safety

并行安全性

  • Avoid shared mutable state between tests
  • Use fresh instances in each test
  • Only use
    .serialized
    when absolutely necessary

  • 避免测试之间共享可变状态
  • 在每个测试中使用新实例
  • 仅在绝对必要时使用
    .serialized

Resources

资源

WWDC: 2024-10179, 2024-10195
Docs: /testing, /testing/migratingfromxctest, /testing/testing-asynchronous-code, /testing/parallelization
GitHub: pointfreeco/swift-concurrency-extras, pointfreeco/swift-clocks

History: See git log for changes
WWDC:2024-10179, 2024-10195
文档:/testing, /testing/migratingfromxctest, /testing/testing-asynchronous-code, /testing/parallelization
GitHub:pointfreeco/swift-concurrency-extras, pointfreeco/swift-clocks

历史记录:查看git log了解变更