关于 axiom-swift-testing
axiom-swift-testing 是面向 Apple Swift Testing 框架使用者的参考技能,涵盖测试声明、断言、参数化与异步测试、并行执行及加快测试的方法。
主要功能
- 01讲解 @Test、#expect 和测试套件
- 02涵盖参数化与异步测试
- 03介绍标签和测试特征
- 04指导并行与串行执行
- 05展示缩短测试运行时间的方法
适用场景
- 01为异步代码编写测试
- 02将多个输入作为参数化用例运行
- 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-60s | UI 测试、集成测试 |
关键要点:将可测试的逻辑移入 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:
- File → New → Workspace
- 将
.xcodeproj拖入工作区 - File → Add Package Dependencies → Add Local → 选择
MyAppCore/ - 将
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:打破循环依赖
如果包中的代码需要回调应用拥有的类型:
- 在包中定义协议(由包拥有该抽象)
- 在启动时从应用目标注入一个符合协议的实现
- 将实现移入包中,前提是它的所有依赖都已移入包中
阶段 3:同时维护两个测试目标
过渡期间,保留两个测试目标:
MyAppCoreTests— 使用swift test运行(已提取的逻辑)MyAppTests— 使用xcodebuild test运行(剩余的应用级测试)
随着源文件的提取,逐步将测试从 MyAppTests 迁移到 MyAppCoreTests。
目标:每次提取后,应用都应能成功构建,且所有测试都能通过。每次只提取一个模块边界。
策略 2:不含宿主应用的框架
对于必须留在应用项目中的代码:
- 创建框架目标(File → New → Target → Framework)
- 将模型代码移入框架
- 将需要外部访问的类型设为 public
- 在使用该框架的文件中添加导入语句
- 在测试目标设置中将 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 迁移
对比表
| XCTest | Swift 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 |
继续使用 XCTest 的场景
- UI 测试(XCUIApplication)
- 性能测试(XCTMetric)
- Objective-C 测试
迁移提示
- 两个框架可以共存于同一目标中
- 逐步迁移,每次迁移一个测试文件
- 将相似的 XCTest 合并为参数化的 Swift 测试
- 单测试的 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 测试会拖慢所有操作。请将其移除:
- 删除 UI 测试目标(Project Settings → 选择目标 → -)
- 删除 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