axiom-swift-testing by charleswiltgen/axiom
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-swift-testingSwift Testing 是 Apple 在 WWDC 2024 推出的现代测试框架。它使用 Swift 宏(@Test、#expect)而非命名约定,默认并行运行测试,并与 Swift 并发模型无缝集成。
核心原则:测试应该快速、可靠且富有表现力。最快的测试无需启动你的应用或模拟器即可运行。
根据配置方式的不同,测试运行速度差异巨大:
| 配置 | 典型时间 | 使用场景 |
|---|---|---|
swift test (Package) | ~0.1s | 纯逻辑、模型、算法 |
| 宿主应用:无 | ~3s | 框架代码,无 UI 依赖 |
| 绕过应用启动 | ~6s | 应用目标但跳过初始化 |
| 完整应用启动 | 20-60s |
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
| UI 测试、集成测试 |
关键见解:将可测试的逻辑移入 Swift Package 或框架中,然后使用 swift test 或“无”宿主应用进行测试。
import Testing
@Test func videoHasCorrectMetadata() {
let video = Video(named: "example.mp4")
#expect(video.duration == 120)
}
与 XCTest 的主要区别:
test 前缀 —— @Test 属性是显式的async、throws 和 actor 隔离// 基本期望 —— 失败时测试继续
#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 更好:
// 期望任何错误
#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("视频处理测试")
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 进行清理(仅限 actors/classes)特性用于自定义测试行为:
// 显示名称
@Test("用户可以使用有效凭据登录")
func loginWithValidCredentials() { }
// 禁用并说明原因
@Test(.disabled("等待后端修复"))
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", "在 CI 上不稳定"))
func sometimesFailingTest() { }
// 操作系统版本要求
@available(iOS 18, *)
@Test func iOS18OnlyFeature() { }
// 定义标签
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 标签
}
使用标签来:
将重复性测试转换为单个参数化测试:
// ❌ 之前:重复
@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)
}
// 测试所有组合(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")
}
| For 循环 | 参数化 |
|---|---|
| 在首次失败时停止 | 所有参数都会运行 |
| 不清楚哪个值失败 | 每个参数单独显示 |
| 顺序执行 | 并行执行 |
| 无法重新运行单个用例 | 重新运行单个参数 |
将应用逻辑提取到 Swift Package 中。测试使用 swift test (~0.4s) 运行,而不是 xcodebuild test (~25s) —— 无需模拟器,无需应用启动。这是在 Claude Code 钩子中实现 TDD 的关键推动因素。
在你的 .xcodeproj 旁边创建包目录:
// 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"]),
]
)
创建一个包含应用项目和包的 .xcworkspace:
.xcodeproj 拖入工作区MyAppCore/MyAppCore 框架添加到应用目标的“框架、库和嵌入式内容”中将模型、服务和视图模型移动到 MyAppCore/Sources/MyAppCore/。应用使用的类型必须是 public。创建一个通过依赖注入接受依赖项的公共根视图:
// 在 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 { /* ... */ }
}
应用目标变成一个薄壳,导入包并委托(完整薄壳原则请参阅 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、授权 | 导航、状态管理 |
| 启动屏幕 | 实用工具、扩展 |
测试使用 @testable import MyAppCore 进行内部访问。
cd MyAppCore
swift test # 所有测试 (~0.4s)
swift test --filter MyAppCoreTests.UserTests # 单个套件
用于分离单元测试和 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
对于无法一次性提取所有内容的应用,逐步移动模块:
从没有依赖应用目标的代码开始:
如果包代码需要回调到应用拥有的类型:
在过渡期间,保留两个测试目标:
MyAppCoreTests —— 使用 swift test 运行(提取的逻辑)MyAppTests —— 使用 xcodebuild test 运行(剩余的应用级测试)随着你提取源文件,逐步将测试从 MyAppTests 迁移到 MyAppCoreTests。
目标:每次提取都应使应用能够构建且所有测试通过。切勿一次提取超过一个模块边界。
对于必须保留在应用项目中的代码:
项目设置 → 测试目标 → 测试
宿主应用:无 ← 关键设置
☐ 允许测试宿主应用 API
构建+测试时间:约 3 秒,而应用启动需要 20-60 秒。
如果无法使用框架,则绕过应用启动:
// 简单解决方案(无自定义启动代码)
@main
struct ProductionApp: App {
var body: some Scene {
WindowGroup {
if !isRunningTests {
ContentView()
}
}
}
private var isRunningTests: Bool {
NSClassFromString("XCTestCase") != nil
}
}
// 彻底解决方案(自定义启动代码)
@main
struct MainEntryPoint {
static func main() {
if NSClassFromString("XCTestCase") != nil {
TestApp.main() // 用于测试的空应用
} else {
ProductionApp.main()
}
}
}
struct TestApp: App {
var body: some Scene {
WindowGroup { } // 空
}
}
@Test func fetchUserReturnsData() async throws {
let user = try await userService.fetch(id: 123)
#expect(user.name == "Alice")
}
// 将完成处理程序转换为 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()
}
}
// 确认某事从未发生
await confirmation(expectedCount: 0) { confirm in
let cache = Cache()
cache.onEviction = { confirm() }
cache.store("small-item") // 不应触发驱逐
}
问题:由于调度不可预测,异步测试可能不稳定。
// ❌ 不稳定:任务调度不可预测
@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:
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)
}
}
为什么有效:将异步工作序列化到主线程,使挂起点具有确定性。
使用 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 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 默认并行运行测试。
// 序列化共享外部状态的套件中的测试
@Suite(.serialized)
struct DatabaseTests {
@Test func createUser() { }
@Test func deleteUser() { } // 在 createUser 之后运行
}
// 序列化参数化测试用例
@Test(.serialized, arguments: [1, 2, 3])
func sequentialProcessing(value: Int) { }
// ❌ Bug:测试依赖于执行顺序
@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)
}
}
随机顺序有助于暴露这些 bug —— 修复它们而不是序列化。
处理预期失败而不产生噪音:
@Test func featureUnderDevelopment() {
withKnownIssue("后端尚未就绪") {
try callUnfinishedAPI()
}
}
// 条件已知问题
@Test func platformSpecificBug() {
withKnownIssue("在 iOS 17.0 上失败") {
try reproduceEdgeCaseBug()
} when: {
ProcessInfo().operatingSystemVersion.majorVersion == 17
}
}
比 .disabled 更好,因为:
| 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 |
@Test 函数// 不要混合使用 XCTest 和 Swift Testing
@Test func badExample() {
XCTAssertEqual(1, 1) // ❌ 错误的框架
#expect(1 == 1) // ✅ 使用这个
}
// ❌ 避免:引用语义可能导致共享状态 bug
@Suite class VideoTests { }
// ✅ 首选:值语义隔离每个测试
@Suite struct VideoTests { }
// ❌ 可能因 Swift 6 严格并发而失败
@Test func updateUI() async {
viewModel.updateTitle("New") // 数据竞争警告
}
// ✅ 隔离到主 actor
@Test @MainActor func updateUI() async {
viewModel.updateTitle("New")
}
// ❌ 不要仅仅因为测试使用 async 就序列化
@Suite(.serialized) struct APITests { } // 破坏了并行性
// ✅ 仅当测试真正共享可变状态时才序列化
Swift 6.2 的 default-actor-isolation = MainActor 破坏了 XCTestCase:
// ❌ 错误:主 actor 隔离的初始化器 'init()' 具有
// 与非隔离的覆盖声明不同的 actor 隔离
final class PlaygroundTests: XCTestCase {
override func setUp() async throws {
try await super.setUp()
}
}
解决方案:将 XCTestCase 子类标记为 nonisolated:
// ✅ 适用于 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),它正确处理隔离。
Swift Testing 默认并行运行;XCTest 并行化会增加开销:
测试计划 → 选项 → 并行化 → "仅 Swift Testing"
附加调试器每次运行成本约 1 秒:
方案 → 编辑方案 → 测试 → 信息 → ☐ 调试器
Xcode 的默认 UI 测试会减慢所有操作。删除它们:
构建设置 → 调试信息格式
调试:DWARF
发布:带有 dSYM 文件的 DWARF
未定义输入/输出的运行脚本阶段会导致完全重建。始终指定:
@Test#expect#require 在前提条件上快速失败.tags()async 并使用 awaitconfirmation()withMainSerialExecutor.serializedWWDC:2024-10179, 2024-10195
文档:/testing, /testing/migratingfromxctest, /testing/testing-asynchronous-code, /testing/parallelization
GitHub:pointfreeco/swift-concurrency-extras, pointfreeco/swift-clocks
历史: 查看 git 日志以了解更改
每周安装次数
120
仓库
GitHub 星标数
601
首次出现
2026 年 1 月 21 日
安全审计
安装于
opencode100
codex95
claude-code94
gemini-cli93
cursor88
github-copilot87
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.
Tests run at dramatically different speeds depending on how they're configured:
| Configuration | Typical Time | Use Case |
|---|---|---|
swift test (Package) | ~0.1s | Pure logic, models, algorithms |
| Host Application: None | ~3s | Framework code, no UI dependencies |
| Bypass app launch | ~6s | App target but skip initialization |
| Full app launch | 20-60s | UI tests, integration tests |
Key insight : Move testable logic into Swift Packages or frameworks, then test with swift test or "None" host application.
import Testing
@Test func videoHasCorrectMetadata() {
let video = Video(named: "example.mp4")
#expect(video.duration == 120)
}
Key differences from XCTest :
test prefix required — @Test attribute is explicitasync, throws, and actor isolation// 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 :
// 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("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 :
@Test gets its own suite instanceinit for setup, deinit for teardown (actors/classes only)Traits customize test behavior:
// 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
}
Use tags to :
Transform repetitive tests into a single parameterized test:
// ❌ 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-Loop | Parameterized |
|---|---|
| Stops on first failure | All arguments run |
| Unclear which value failed | Each argument shown separately |
| Sequential execution | Parallel execution |
| Can't re-run single case | Re-run individual arguments |
Extract app logic into a Swift Package. Tests run with swift test (~0.4s) instead of xcodebuild test (~25s) — no simulator, no app launch. This is the key enabler for TDD in Claude Code hooks.
Create the package directory alongside your .xcodeproj:
// 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"]),
]
)
Create an .xcworkspace containing both the app project and the package:
.xcodeproj into the workspaceMyAppCore/MyAppCore framework to your app target's "Frameworks, Libraries, and Embedded Content"Move models, services, and view models into MyAppCore/Sources/MyAppCore/. Types used by the app must be public. Create a public root view that accepts dependencies via injection:
// 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 { /* ... */ }
}
The app target becomes a thin shell that imports the package and delegates (see axiom-app-composition for the full thin-shell principle):
import SwiftUI
import MyAppCore
@main
struct MyApp: App {
let container = try! ModelContainer(for: /* schemas */)
var body: some Scene {
WindowGroup {
MyAppRootView(modelContainer: container)
}
}
}
| Stays in App Target | Moves to Package |
|---|---|
@main App.swift (thin shell) | Models, view models, services |
| Asset catalogs, resources | Business logic, algorithms |
| Info.plist, entitlements | Navigation, state management |
| Launch screen | Utilities, extensions |
Tests use @testable import MyAppCore for internal access.
cd MyAppCore
swift test # All tests (~0.4s)
swift test --filter MyAppCoreTests.UserTests # Single suite
For project-level scripts separating unit from UI tests:
# 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
For apps that can't extract everything at once, move modules incrementally:
Start with code that has no dependencies on the app target:
If package code needs to call back into app-owned types:
During transition, keep two test targets:
MyAppCoreTests — runs with swift test (extracted logic)MyAppTests — runs with xcodebuild test (remaining app-level tests)Gradually migrate tests from MyAppTests to MyAppCoreTests as you extract their source files.
Goal : Each extraction should leave the app building and all tests passing. Never extract more than one module boundary at a time.
For code that must stay in the app project:
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.
If you can't use a framework, bypass the app launch:
// 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")
}
// 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
}
Problem : Async tests can be flaky due to scheduling unpredictability.
// ❌ 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:
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.
Use Point-Free's swift-clocks to control time in tests:
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, deterministicImmediateClock — All sleeps return instantly (great for previews)UnimplementedClock — Fails if used (catch unexpected time dependencies)Swift Testing runs tests in parallel by default.
// 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)
}
}
Random order helps expose these bugs — fix them rather than serialize.
Handle expected failures without noise:
@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 :
| XCTest | Swift Testing |
|---|---|
func testFoo() | @Test func foo() |
XCTAssertEqual(a, b) | #expect(a == b) |
XCTAssertNil(x) | #expect(x == nil) |
XCTAssertThrowsError | #expect(throws:) |
@Test function// 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 { }
// ❌ 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's default-actor-isolation = MainActor breaks 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()
}
}
Solution : Mark XCTestCase subclass as 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
}
}
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 Testing runs in parallel by default; XCTest parallelization adds overhead:
Test Plan → Options → Parallelization → "Swift Testing Only"
Attaching the debugger costs ~1 second per run:
Scheme → Edit Scheme → Test → Info → ☐ Debugger
Xcode's default UI tests slow everything down. Remove them:
Build Settings → Debug Information Format
Debug: DWARF
Release: DWARF with dSYM File
Run Script phases without defined inputs/outputs cause full rebuilds. Always specify:
@Test with clear display names#expect for all assertions#require to fail fast on preconditions.tags() for organizationasync and use awaitconfirmation() for callback-based codewithMainSerialExecutor for flaky tests.serialized when absolutely necessaryWWDC : 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
Weekly Installs
120
Repository
GitHub Stars
601
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode100
codex95
claude-code94
gemini-cli93
cursor88
github-copilot87
测试策略完整指南:单元/集成/E2E测试金字塔与自动化实践
11,200 周安装
XCTUnwrap(x) | try #require(x) |
class FooTests: XCTestCase | @Suite struct FooTests |
setUp() / tearDown() | init / deinit |
continueAfterFailure = false | #require (per-expectation) |
addTeardownBlock | deinit or defer |