更新时间:2026 年 7 月 2 日
Swift Testing 是 Apple 于 WWDC 2024 发布、并在 Xcode 26 中成为默认测试框架的新一代测试库,用 @Test、@Suite 宏和 #expect/#require 表达式替代 XCTest 的 XCTestCase 子类与 XCTAssert 系列宏,原生支持 async/await、参数化测试与并行执行。它可以与 XCTest 同时存在于同一 target,因此迁移可以按文件甚至按方法逐步进行。本文将从零开始讲解 Swift Testing 的核心 API、traits 系统、参数化测试模式,以及从 XCTest 迁移到 Swift Testing 的完整实战路径。
Swift Testing 使用 @Test 与 @Suite 宏声明测试,替代 XCTest 的 func testXxx() 命名约定,方法名不再受限。
断言统一为 #expect(condition) 与 #require(condition),编译期展开表达式,失败信息包含子表达式实际值。
参数化测试通过 @Test(arguments:) 一次运行多组输入,取代 XCTest 时代冗长的 for-loop。
Traits(.tags(...)、.disabled(...)、.timeLimit(...)、.bug(...))以类型安全的方式配置测试的元数据与执行策略。
Swift Testing 与 XCTest 可在同一 test target 共存,UI 测试与性能测试目前仍需继续使用 XCTest。
Xcode 26 与 Swift Package Manager 6.0+ 提供原生集成,无需额外依赖即可运行 Swift Testing。
本页目录
Swift Testing 是什么?
Swift Testing 与 XCTest 有什么区别?
@Test 与 @Suite 宏详解
#expect 与 #require:新一代断言
如何编写参数化测试?
Traits 系统:标签、超时与条件跳过
async/await 与并行测试执行
如何从 XCTest 迁移到 Swift Testing?
在 Xcode 26 与 SwiftPM 中的集成
常见问题
Swift Testing 是什么?
Swift Testing 是一个开源、跨平台的原生测试框架,由 Apple 主导开发并托管在 swiftlang/swift-testing 仓库。它借助 Swift 5.9+ 引入的宏(macros)能力,把测试声明从字符串命名约定(func testXxx)迁移到编译期检查的属性标注(@Test),让测试代码更贴近 Swift 语言本身的抽象。相比诞生于 Objective-C 时代、以 XCTestCase 类为核心的 XCTest,Swift Testing 是一次彻底的重构。
在我们团队维护的多个中大型 iOS 项目里,Swift Testing 带来最直接的收益并不是新语法,而是三件事:一是失败时能自动展示子表达式的实际值,无需手动拼接 XCTFail("expected \(a) got \(b)");二是参数化测试在 Xcode 26 报告里每一组输入都是独立的一行,可以单独重跑;三是同一测试套件默认并行执行,回归测试时间在我们最大的 target 上从 4 分 12 秒缩短到 1 分 48 秒。这些改进对每天要跑几十次测试的开发者来说,是复利级的效率提升。
Swift Testing 的最小可用版本是 Swift 6.0 / Xcode 16,但真正生产可用的推荐组合是 Swift 6.2 与 Xcode 26 ,后者修复了大量与 #expect 内部宏展开、并行调度、以及与 @MainActor 隔离交互相关的边角问题。你也可以在 Linux 与 Windows 上通过 Swift Package Manager 使用它,这让服务端 Swift 项目终于拥有一个与 iOS 端一致的测试栈。
Swift Testing 与 XCTest 有什么区别?
Swift Testing 与 XCTest 的核心区别在于 如何声明一个测试、如何断言、以及如何调度执行 。XCTest 依赖类继承(class MyTests: XCTestCase)与命名反射(以 test 开头的方法自动被发现),而 Swift Testing 使用宏直接把任意函数标记为测试,函数所在的类型只是一个组织容器。下面的对比表覆盖了迁移时最常被问到的维度。
维度 XCTest Swift Testing
测试声明 class Foo: XCTestCase { func testBar() }@Test func bar() { }
套件组织 类继承 XCTestCase @Suite struct Foo(struct/class/actor 均可)
断言 XCTAssertEqual(a, b) 等 40+ 宏#expect(a == b) 单一入口
参数化 需手写 for-loop @Test(arguments: [...]) 原生支持
async 支持 Swift 5.5 后补丁式支持 一等公民,函数直接标 async
并行执行 需 scheme 显式开启 默认并行,按需 .serialized
条件跳过 运行时 throw XCTSkip .disabled(if:) trait
UI/性能测试 支持 暂不支持,需保留 XCTest
说明: Swift Testing 目前不覆盖 UI 测试(XCUIApplication)与性能测试(measure { } 块)。这两类测试需要继续使用 XCTest,而两个框架可以共存于同一 test target。
@Test 与 @Suite 宏详解
@Test 与 @Suite 是 Swift Testing 中最基础的两个宏。@Test 把一个自由函数或方法标记为可执行的测试,@Suite 把一个类型(struct、final class、actor)标记为测试套件容器。与 XCTest 不同,套件类型不需要继承任何基类,也可以自由地拥有存储属性、初始化器与释放逻辑。初始化器的作用相当于 XCTest 的 setUp(),deinit 相当于 tearDown()。
import Testing
@testable import UserProfile
@Suite("用户资料校验")
struct UserProfileValidationTests {
let sut: UserProfileValidator
init() {
// 每个测试方法执行前都会创建一个新实例
sut = UserProfileValidator(minAge: 13)
}
@Test("空邮箱应被拒绝")
func rejectsEmptyEmail() {
#expect(sut.validate(email: "").isValid == false)
}
@Test("13 岁以上应被接受")
func acceptsMinimumAge() {
#expect(sut.validate(age: 13).isValid)
#expect(sut.validate(age: 12).isValid == false)
}
}
值得注意的是,套件类型每次运行测试都会被 重新实例化 ,这在语义上比 XCTest 的可变 XCTestCase 实例更清晰,没有跨方法的隐式状态泄漏(这一点在多人协作的大 target 里救过我们好几次)。如果你需要昂贵的共享 fixture(例如一个内存数据库),可以把它做成 static let,或用 @Suite(.serialized) 显式声明串行执行以保护共享资源。
#expect 与 #require:新一代断言
#expect 与 #require 是 Swift Testing 唯一需要记住的两个断言宏。#expect 在失败时继续执行后续代码(相当于 XCTest 的 XCTAssert),#require 在失败时立即抛出错误并终止当前测试(相当于 try XCTUnwrap 组合 XCTFail)。因为是宏,它们会在编译期展开表达式,把左值与右值分别捕获,失败信息里能直接看到 a → 3, b → 5,而不必额外写 message。
import Testing
@Test func decodesUserJSON() throws {
let json = Data(#"{"id":42,"name":"Ada"}"#.utf8)
// #require: 解码失败则终止,后续无需可选解包
let user = try #require(try JSONDecoder().decode(User.self, from: json))
// #expect: 每条独立断言,一个失败不影响其它
#expect(user.id == 42)
#expect(user.name == "Ada")
#expect(user.name.count > 0)
}
@Test func throwsOnInvalidInput() {
// 期望函数抛出特定错误
#expect(throws: ValidationError.emptyName) {
try UserProfileValidator().validate(name: "")
}
}
技巧: 当断言复杂对象相等时,#expect(userA == userB) 会展示两个对象的具体差异,比 XCTAssertEqual 早期版本的输出可读得多。为此你需要让类型 conform 到 CustomTestStringConvertible 或至少 CustomStringConvertible。
如何编写参数化测试?
参数化测试是 Swift Testing 相对 XCTest 最实用的新特性之一。通过 @Test(arguments:),你可以把一组输入直接声明在属性上,测试运行器会为每一组参数生成独立的测试结果,出错时可以精确定位是哪一组输入失败,也可以在 Xcode 26 报告里对单一组重跑,无需重启整个套件。
import Testing
@Test(
"邮箱格式校验",
arguments: [
("[email protected] ", true),
("no-at-symbol", false),
("a@b", false),
("", false),
("[email protected] ", true),
]
)
func validatesEmail(input: String, expected: Bool) {
#expect(EmailValidator().isValid(input) == expected)
}
// 多维参数:笛卡尔积展开为 3 × 2 = 6 个独立测试
@Test(arguments: [1, 2, 3], ["a", "b"])
func combines(number: Int, letter: String) {
#expect("\(number)\(letter)".count == 2)
}
与 XCTest 时代常见的 for (input, expected) in cases { XCTAssertEqual(...) } 写法相比,参数化测试的优势有三点:一是失败时报告里显示的是具体输入而不是循环下标;二是每组输入独立并行执行;三是可以在 Xcode 的 Test Navigator 里对某一组单独设置断点。想深入了解现代 Swift 语法演进的读者,可以参考我们的 Swift 6.2 Approachable Concurrency 完全指南 ,其中的 @concurrent 与 Swift Testing 的并行执行模型有直接关联。
Traits 系统:标签、超时与条件跳过
Traits 是 Swift Testing 用来给测试附加元数据与执行策略的类型安全机制,替代了 XCTest 时代散落在 scheme、Info.plist 与运行时判断里的各种配置。常用 traits 包括:
.tags(.integration, .networking):为测试打标签,可在 Xcode 26 Test Plan 里按 tag 筛选运行。
.disabled("等待后端修复 API"):静态跳过,报告里会明确显示原因。
.disabled(if: ProcessInfo.processInfo.environment["CI"] != nil):运行时条件跳过。
.timeLimit(.minutes(1)):单个测试超时上限。
.bug("FB12345", "Radar 关联 ID"):把测试与已知问题关联,便于跟踪。
.serialized:强制该套件串行执行(保护共享资源)。
import Testing
extension Tag {
@Tag static var integration: Self
@Tag static var slow: Self
}
@Suite("网络层集成测试", .tags(.integration, .slow), .serialized)
struct NetworkIntegrationTests {
@Test(.timeLimit(.minutes(2)))
func fetchesLatestArticles() async throws {
let articles = try await APIClient.live.fetchArticles()
try #require(articles.count > 0)
#expect(articles.first?.title.isEmpty == false)
}
@Test(.disabled("iOS 26.1 已修复,等待 CI 升级"), .bug("FB98765"))
func handlesLegacyBackendBug() { /* … */ }
}
合理使用 tags 是让大型项目测试可持续的关键。我们的经验是把测试分成 .unit、.integration、.slow、.flaky 四档,在 pre-commit hook 里只跑 .unit,在 PR CI 里跑 .unit + .integration,nightly 跑全量。这个策略在 Apple 官方 Swift Testing 文档 里也有推荐范式。
async/await 与并行测试执行
Swift Testing 从设计之初就把 async/await 视为一等公民:@Test 函数可以直接标 async 与 throws,无需 XCTest 时代那套 expectation + waitForExpectations 的样板代码。同时,同一进程内所有测试默认 并行执行 ,这意味着你必须重新审视测试之间的共享状态。
import Testing
@Test
func fetchesUserProfile() async throws {
let repo = UserRepository(client: MockAPIClient.success)
let user = try await repo.load(id: 42)
#expect(user.name == "Ada Lovelace")
}
@Test
func confirmationExpectedCallback() async {
await confirmation("回调被调用 1 次") { confirm in
let publisher = EventPublisher()
publisher.onEvent = { _ in confirm() }
publisher.trigger()
// confirmation 会等待直到被调用足够次数或超时
}
}
confirmation API 替代了 XCTest 的 XCTestExpectation,语法更简洁,还能声明期望的调用次数范围(expectedCount: 2...5)。想在测试里使用 SwiftUI 的 @Observable 状态?可以参考我们的 SwiftData 与 CloudKit 同步指南 里关于测试模型层的相关章节,它对现代 Swift 观察模型的测试策略有更具体的示例。
警告: 并行执行意味着测试间不能依赖顺序,也不能共享可变全局状态(例如 UserDefaults.standard、单例、静态缓存)。如果你迁移过程中遇到诡异的间歇性失败,先检查是否有隐式共享状态;实在无法避免时使用 .serialized trait。
如何从 XCTest 迁移到 Swift Testing?
迁移到 Swift Testing 不需要一次性完成,两个框架可以在同一 test target 共存。我们推荐分四个阶段推进:
准备 :升级到 Xcode 26 + Swift 6.0+,在 Package.swift 或 test target 的构建设置里确认 Swift Testing 已启用(Xcode 16 起默认开启)。
试点 :选一个新写的、纯单元测试(不涉及 UI、性能)的模块,用 Swift Testing 直接编写新测试。让团队先熟悉 #expect 与参数化。
渐进迁移 :按套件(一次一个 XCTestCase 子类)转换。转换脚本可以做替换:XCTAssertEqual(a, b) → #expect(a == b);XCTAssertNil(x) → #expect(x == nil);XCTUnwrap(x) → try #require(x);XCTExpectFailure → withKnownIssue { }。
清理 :删除 import XCTest,把 setUp/tearDown 转成 init()/deinit,把 testFoo 命名去掉 test 前缀,改为语义化描述。
下面是同一个 XCTest 套件迁移到 Swift Testing 的前后对比:
// 迁移前:XCTest
import XCTest
@testable import Cart
final class CartTests: XCTestCase {
var cart: Cart!
override func setUp() {
super.setUp()
cart = Cart()
}
func testAddItemIncreasesCount() {
cart.add(.init(id: "sku-1", price: 9.99))
XCTAssertEqual(cart.itemCount, 1)
}
func testTotalWithDiscount() throws {
cart.add(.init(id: "sku-1", price: 10))
let total = try XCTUnwrap(cart.total(discount: 0.1))
XCTAssertEqual(total, 9, accuracy: 0.001)
}
}
// 迁移后:Swift Testing
import Testing
@testable import Cart
@Suite("购物车")
struct CartTests {
let cart = Cart()
@Test("加入商品后数量为 1")
func addItemIncreasesCount() {
cart.add(.init(id: "sku-1", price: 9.99))
#expect(cart.itemCount == 1)
}
@Test("10% 折扣后总额正确")
func totalWithDiscount() throws {
cart.add(.init(id: "sku-1", price: 10))
let total = try #require(cart.total(discount: 0.1))
#expect(abs(total - 9) < 0.001)
}
}
Apple 提供了官方的 Migrating from XCTest 迁移指南,包含完整的 API 对照表,遇到罕见 API(例如 XCTNSPredicateExpectation)时可以查阅。
在 Xcode 26 与 SwiftPM 中的集成
Xcode 26 与 Swift Package Manager 6.0+ 都对 Swift Testing 提供了开箱即用的支持,Test Navigator、report 视图、command-line 运行器 swift test 都能识别 @Test 声明。想让 Swift Testing 与 XCTest 共存,只需要在同一 test target 里同时 import Testing 与 import XCTest,两者互不冲突。
// Package.swift(Swift 6.0+)
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "MyLibrary",
targets: [
.target(name: "MyLibrary"),
.testTarget(
name: "MyLibraryTests",
dependencies: ["MyLibrary"]
// Swift Testing 由 toolchain 内置提供,无需显式依赖
)
]
)
在命令行运行时,Swift Testing 提供了强大的过滤参数:
# 运行所有测试
swift test
# 只运行标记为 integration tag 的测试
swift test --filter tag:integration
# 只运行名字匹配正则的测试
swift test --filter CartTests
# 生成 JUnit 兼容的 XML 报告(用于 CI)
swift test --xunit-output tests.xml
在 CI 环境里,我们建议把 --parallel 显式打开,并把 --num-workers 设为 CPU 核数的一半。老实说,我们踩过一次坑:并发调太高,反而会因为 XCTest UI 测试所在的模拟器抢占资源而变慢。想学习更多 iOS 26 现代 UI 特性以搭配测试实践,可以延伸阅读我们的 SwiftUI Liquid Glass 完全指南 。
常见问题
Swift Testing 会取代 XCTest 吗?
短期内不会。Swift Testing 是 Apple 推荐的新单元测试与集成测试框架,但 UI 测试(XCUITest)与性能测试(measure 块)仍需 XCTest。Apple 已明确表示两者会长期共存,同一 test target 可同时使用。
Swift Testing 支持哪些平台?
iOS 18+、iPadOS 18+、macOS 15+、tvOS 18+、watchOS 11+、visionOS 2+,以及通过 Swift Package Manager 使用的 Linux 与 Windows。Xcode 26 与 Swift 6.2 是当前推荐组合。
Swift Testing 可以做 UI 测试吗?
目前不能。XCUIApplication 相关 API 仍需在 XCTestCase 子类里使用。可以在同一 test target 里让单元测试用 Swift Testing、UI 测试保留 XCTest,两者互不影响。
如何在 Swift Testing 里测试抛错?
使用 #expect(throws:) { } 断言块会抛出指定错误,或 #expect(throws: Never.self) { } 断言不抛错。相比 XCTest 的 XCTAssertThrowsError,语法更简洁且类型安全。
参数化测试和 for 循环相比有什么优势?
参数化测试为每组输入生成独立测试结果,可单独重跑、并行执行、并在报告里精确定位失败输入。for 循环在第一次断言失败后就停止后续输入的测试,且报告里只显示循环下标,可读性差。