mcpField
A
axiom-swift-testing

axiom-swift-testing

github.com/charleswiltgen测试与质量1.2k208 次安装今天更新
代码仓库

关于 axiom-swift-testing

axiom-swift-testing 是面向 Apple Swift Testing 框架使用者的参考技能,涵盖测试声明、断言、参数化与异步测试、并行执行及加快测试的方法。

主要功能

  1. 01讲解 @Test、#expect 和测试套件
  2. 02涵盖参数化与异步测试
  3. 03介绍标签和测试特征
  4. 04指导并行与串行执行
  5. 05展示缩短测试运行时间的方法

适用场景

  1. 01为异步代码编写测试
  2. 02将多个输入作为参数化用例运行
  3. 03将快速逻辑测试与 UI 测试分开

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-swift-testing

安装后在 Claude Code 中输入/axiom-swift-testing确认已加载。

详细介绍

Swift Testing

概述

Swift Testing 是 Apple 于 WWDC 2024 推出的现代化测试框架。它使用 Swift 宏(@Test、#expect)而非命名约定,默认并行运行测试,并与 Swift 并发无缝集成。

核心原则:测试应快速、可靠且表达清晰。最快的测试无需启动应用或模拟器即可运行。

速度层级

测试的运行速度会因配置不同而有显著差异:

配置典型耗时用途
swift test(Package)~0.1s纯逻辑、模型、算法
Host Application: None~3s框架代码,不依赖 UI
绕过应用启动~6s应用目标,但跳过初始化
完整应用启动20-60sUI 测试、集成测试

关键要点:将可测试的逻辑移入 Swift Package 或框架,然后使用 swift test 或“None”主机应用进行测试。


构建模块

@Test 函数
import Testing

@Test func videoHasCorrectMetadata() {
    let video = Video(named: "example.mp4")
    #expect(video.duration == 120)
}

与 XCTest 的主要区别:

  • 无需 test 前缀——@Test 属性会明确标识测试
  • 可以是全局函数,不必只是类中的方法
  • 支持 async、throws 和 Actor 隔离
  • 每个测试都会在其所属测试套件的新实例上运行
#expect 和 #require
// 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)

#expect 优于 XCTAssert 的原因:

  • 自动捕获源代码和值的细节
  • 一个宏即可处理所有运算符(==、>、contains 等)
  • 无需专用断言(XCTAssertEqual、XCTAssertNil 等)
错误测试
// 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
}
@Suite 类型
@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))
    }
}

主要行为:

  • 优先使用结构体(值语义,不会意外共享状态)
  • 每个 @Test 都会获得自己的测试套件实例
  • 使用 init 进行设置,使用 deinit 进行清理(仅限 Actor/类)
  • 支持嵌套测试套件,便于组织

Traits

Traits 可自定义测试行为:

// 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() { }
使用标签进行组织
// 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
}

使用标签可以:

  • 运行部分测试(在 Test Navigator 中按标签筛选)
  • 在快速反馈循环中排除耗时测试
  • 跨不同文件/测试套件对相关测试进行分组

参数化测试

将重复测试改为单个参数化测试:

// ❌ 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)
}
双集合参数化
// 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")
}
相较于 For 循环的优势
For 循环参数化
首次失败时停止运行所有参数
不清楚哪个值导致失败分别显示每个参数
顺序执行并行执行
无法重新运行单个用例可重新运行单个参数

快速测试:面向可测试性的架构

策略 1:使用 Swift Package 处理逻辑(最快)

将应用逻辑提取到 Swift Package 中。测试使用 swift test(~0.4s)运行,而不是 xcodebuild test(~25s)——无需模拟器,也无需启动应用。这是 Claude Code hooks 中实现 TDD 的关键。

步骤 1:创建 Package.swift

在 .xcodeproj 所在目录旁创建 Package 目录:

// MyAppCore/Package.swift
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "MyAppCore",
    platforms: [.iOS(.v18), .macOS(.v15)],
    products: [
        .library(name: "MyAppCore", targets: ["MyAppCore"]),
    ],
    targets: [
        .target(name: "MyAppCore"),
        .testTarget(name: "MyAppCoreTests", dependencies: ["MyAppCore"]),
    ]
)
步骤 2:将 Package 关联到应用

创建一个 .xcworkspace,其中包含应用项目和 Package:

  1. File → New → Workspace
  2. 将 .xcodeproj 拖入工作区
  3. File → Add Package Dependencies → Add Local → 选择 MyAppCore/
  4. 将 MyAppCore 框架添加到应用目标的“Frameworks, Libraries, and Embedded Content”
步骤 3:迁移逻辑并公开根视图

将模型、服务和视图模型移至 MyAppCore/Sources/MyAppCore/。应用使用的类型必须声明为 public。创建一个通过注入接收依赖项的公开根视图:

// In MyAppCore
public struct MyAppRootView: View {
    @State private var appState: AppStateController

    public init(modelContainer: ModelContainer) {
        _appState = State(initialValue: AppStateController(container: modelContainer))
    }

    public var body: some View { /* ... */ }
}
步骤 4:精简 App.swift

应用目标将变为一个精简外壳,负责导入 Package 并委托处理(有关精简外壳原则的完整说明,请参阅 axiom-app-composition):

import SwiftUI
import MyAppCore

@main
struct MyApp: App {
    let container = try! ModelContainer(for: /* schemas */)

    var body: some Scene {
        WindowGroup {
            MyAppRootView(modelContainer: container)
        }
    }
}
保留在何处与移至何处
保留在应用目标中移至包中
@main App.swift(轻量外壳)模型、视图模型、服务
资源目录、资源业务逻辑、算法
Info.plist、entitlements导航、状态管理
启动画面实用工具、扩展

测试使用 @testable import MyAppCore 访问内部内容。

运行测试
cd MyAppCore
swift test                              # All tests (~0.4s)
swift test --filter MyAppCoreTests.UserTests  # Single suite

对于将单元测试与 UI 测试分开的项目级脚本:

# script/test
#!/bin/bash
case "${1:-unit}" in
    unit) cd MyAppCore && swift test ;;
    ui)   xcodebuild test -workspace MyApp.xcworkspace \
            -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 16' ;;
esac
逐步提取现有项目中的代码

对于无法一次性提取所有内容的应用,请逐步迁移模块:

阶段 1:优先处理叶子模块

从不依赖应用目标的代码开始:

  • 数据模型和 DTO
  • 网络层(API 客户端、请求构造器)
  • 业务逻辑和验证规则
  • 实用扩展
阶段 2:打破循环依赖

如果包中的代码需要回调应用拥有的类型:

  1. 在包中定义协议(由包拥有该抽象)
  2. 在启动时从应用目标注入一个符合协议的实现
  3. 将实现移入包中,前提是它的所有依赖都已移入包中
阶段 3:同时维护两个测试目标

过渡期间,保留两个测试目标:

  • MyAppCoreTests — 使用 swift test 运行(已提取的逻辑)
  • MyAppTests — 使用 xcodebuild test 运行(剩余的应用级测试)

随着源文件的提取,逐步将测试从 MyAppTests 迁移到 MyAppCoreTests。

目标:每次提取后,应用都应能成功构建,且所有测试都能通过。每次只提取一个模块边界。

策略 2:不含宿主应用的框架

对于必须留在应用项目中的代码:

  1. 创建框架目标(File → New → Target → Framework)
  2. 将模型代码移入框架
  3. 将需要外部访问的类型设为 public
  4. 在使用该框架的文件中添加导入语句
  5. 在测试目标设置中将 Host Application 设为 "None"
Project Settings → Test Target → Testing
  Host Application: None  ← Key setting
  ☐ Allow testing Host Application APIs

构建和测试耗时:约 3 秒,而启动应用则需要 20–60 秒。

策略 3:绕过 SwiftUI 应用启动

如果无法使用框架,可以绕过应用启动:

// 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
    }
}
// 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
    }
}

异步测试

基本异步测试
@Test func fetchUserReturnsData() async throws {
    let user = try await userService.fetch(id: 123)
    #expect(user.name == "Alice")
}
使用 Continuation 测试回调
// 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)
}
多个事件的确认
@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
}
使用 Concurrency Extras 进行可靠的异步测试

问题:异步测试可能因调度不可预测而不稳定。

// ❌ 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
}

解决方案:使用 Point-Free 的 swift-concurrency-extras:

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)
    }
}

原理:将异步工作串行化到主线程,使挂起点具有确定性。

使用 TestClock 实现确定性时间

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

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()
}

时钟类型:

  • TestClock — 手动推进时间,结果确定
  • ImmediateClock — 所有休眠都会立即返回(非常适合预览)
  • UnimplementedClock — 使用时会失败(捕获意外的时间依赖)

并行测试

Swift Testing 默认并行运行测试。

何时串行运行
// 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) { }
隐藏依赖
// ❌ 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)
    }
}

随机顺序有助于暴露这些错误——应修复它们,而不是改为串行运行。


已知问题

以无干扰的方式处理预期失败:

@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
    }
}

优于 .disabled 的原因:

  • 测试仍会编译(可捕获语法错误)
  • 问题修复后会收到通知
  • 结果显示“预期失败”,而不是“已跳过”

从 XCTest 迁移

对比表
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(针对每个预期)
addTeardownBlockdeinit 或 defer
继续使用 XCTest 的场景
  • UI 测试(XCUIApplication)
  • 性能测试(XCTMetric)
  • Objective-C 测试
迁移提示
  1. 两个框架可以共存于同一目标中
  2. 逐步迁移,每次迁移一个测试文件
  3. 将相似的 XCTest 合并为参数化的 Swift 测试
  4. 单测试的 XCTestCase → 全局 @Test 函数

常见错误

❌ 混用断言
// Don't mix XCTest and Swift Testing
@Test func badExample() {
    XCTAssertEqual(1, 1)  // ❌ Wrong framework
    #expect(1 == 1)       // ✅ Use this
}
❌ 使用类作为测试套件
// ❌ Avoid: Reference semantics can cause shared state bugs
@Suite class VideoTests { }

// ✅ Prefer: Value semantics isolate each test
@Suite struct VideoTests { }
❌ 忘记 @MainActor
// ❌ 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")
}
❌ 过度串行化
// ❌ Don't serialize just because tests use async
@Suite(.serialized) struct APITests { }  // Defeats parallelism

// ✅ Only serialize when tests truly share mutable state
❌ 在 Swift 6.2 MainActor 默认隔离下使用 XCTestCase

Swift 6.2 的 default-actor-isolation = MainActor 会导致 XCTestCase 出错:

// ❌ 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()
    }
}

解决方案:将 XCTestCase 子类标记为 nonisolated:

// ✅ 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
    }
}

原因:XCTestCase 是 Objective-C 类型,未针对 Swift 并发进行注解。其初始化器是 nonisolated,因此会与 MainActor 隔离的子类发生冲突。

更好的解决方案:迁移到 Swift Testing(@Suite struct),它能妥善处理隔离。


优化 Xcode,以获得快速反馈

关闭 XCTest 并行执行

Swift Testing 默认并行运行;XCTest 并行化会增加开销:

Test Plan → Options → Parallelization → "Swift Testing Only"
关闭测试调试器

附加调试器每次运行约耗时 1 秒:

Scheme → Edit Scheme → Test → Info → ☐ Debugger
删除 UI 测试模板

Xcode 默认的 UI 测试会拖慢所有操作。请将其移除:

  1. 删除 UI 测试目标(Project Settings → 选择目标 → -)
  2. 删除 UI 测试源文件夹
为调试构建停用 dSYM
Build Settings → Debug Information Format
  Debug: DWARF
  Release: DWARF with dSYM File
检查构建脚本

未定义输入/输出的 Run Script 阶段会导致完整重建。请务必指定:

  • Input Files / Input File Lists
  • Output Files / Output File Lists

检查清单

编写测试前
  • 确定哪些内容可以移至 Swift Package(纯逻辑)
  • 如果不适合使用包,则设置框架目标
  • 单元测试的 Host Application 设置为 None
编写测试
  • 使用 @Test 并设置清晰的显示名称
  • 所有断言都使用 #expect
  • 使用 #require,在前置条件不满足时快速失败
  • 为相似的测试用例使用参数化
  • 添加 .tags() 以便组织管理
异步测试
  • 将测试函数标记为 async 并使用 await
  • 对基于回调的代码使用 confirmation()
  • 对不稳定的测试考虑使用 withMainSerialExecutor
并行安全
  • 避免测试之间共享可变状态
  • 在每个测试中使用新的实例
  • 仅在绝对必要时使用 .serialized

资源

WWDC:2024-10179、2024-10195

文档:/testing、/testing/migratingfromxctest、/testing/testing-asynchronous-code、/testing/parallelization

GitHub:pointfreeco/swift-concurrency-extras、pointfreeco/swift-clocks


**历史记录:**更改记录请参阅 git log

评论

支持 Markdown