自 Swift 语言诞生以来,XCTest 一直是绝大多数 Swift 开发者的首选测试框架。然而,由于其深植于 Objective-C 的根基,API 设计大量沿袭了该语言的传统,无法充分体现 Swift 的现代编程最佳实践。在某些方面,这甚至成为了发展的障碍。为了克服这些限制,苹果在 WWDC 2024 上正式介绍了由社区开发的 Swift Testing —— 一个专门为 Swift 语言设计的全新测试框架。这个框架已被集成到 Xcode 16 中,并被定位为官方首选的测试工具。在本文中,我们将深入探讨 Swift Testing 框架的特性、用法和其独特之处,分析它如何帮助开发者以更快(Swifter)的方式编写出更符合 Swift 编程习惯(Swifty)的测试代码。
配置和使用 Swift Testing
本章将带你了解如何在不同环境下设置和运行 Swift Testing,以及如何编写你的第一个测试用例。
在 Xcode 项目中集成 Swift Testing
Swift Testing 已被无缝集成到 Xcode 16 中,成为官方首推的测试框架。在创建新项目时,你可以轻松选择 Swift Testing 作为默认测试框架,如下图所示:
作为 Swift 6 工具链的组件,Swift Testing 无需额外的依赖声明即可直接使用,大大简化了配置流程。
对于 Swift Package,Swift Testing 同样提供了直接支持。在创建新的 Package 时,可以直接选择 Swift Testing 框架:
在 VSCode 中配置 Swift Testing
为了照顾使用其他开发环境的开发者,Swift Server Work Group 为 VSCode 用户提供了完善的支持。通过其开发的 VSCode 插件,在 Swift 6 环境下,Swift Testing 可以实现真正的即插即用体验。
命令行下的 Swift Testing
值得注意的是,截至 Xcode 16 beta2 版本,命令行中的 swift test
命令默认仍使用 XCTest 框架。要启用 Swift Testing,需要添加特定参数:
swift test --enable-swift-testing
添加此参数后,将确保执行基于 Swift Testing 框架的测试代码:
编写你的第一个 Swift Testing 测试用例
Swift Testing 的语法比 XCTest 更加简洁明了。以下是一个基本的用户名检查测试用例:
@testable import Demo1
import Testing
@Test func checkName() async throws {
let fat = People.fat
#expect(fat.name == "fat")
}
这段代码展示了 Swift Testing 的几个特性:
- 使用
import Testing
引入框架 @Test
宏标记测试函数- 支持全局函数形式的测试代码
- 测试函数命名灵活,无特殊限制
在 Xcode 的测试导航栏中,你可以找到并运行这个测试用例:
为提高可读性,Swift Testing 允许你为测试用例添加描述性名称:
@Test("检查姓名") func checkName() async throws {
这样,测试导航栏中将显示更易理解的测试名称:
通过这些简单步骤,你已经成功配置并编写了第一个 Swift Testing 测试用例。接下来的章节将探讨 Swift Testing 的更多高级特性,帮助你充分利用这个强大的测试框架。
预期(Expectations)
在编写测试时,库通常提供了用于比较值的 API —— 例如,验证一个函数是否返回了预期的结果。若比较结果不成功,则报告为测试失败。这些 API 在不同的库中可能被称作“断言(assertions)”、“预期(expectations)”、“检查(checks)”、“要求(requirements)”、“匹配器(matchers)”等。在 Swift Testing 中称之为 Expectations。
#expect 宏
不同于 XCTest 拥有超过 40 个断言函数,Swift Testing 旨在尽可能地简化开发者的学习曲线,并提供更多的灵活性。利用 Swift 强大的语言表达能力,在 Swift Testing 中,开发者大多数情况下只需将预期表达为一个布尔表达式,并通过 #expect
宏进行验证。
let x = 2
#expect(x < 1) // failed: (x → 2) < 1
let a = [1, 2, 3]
let b = 4
#expect(a.contains(b)) // failed: (a → [1, 2, 3]) does not contain (b → 4)
let str = "fatbobman"
#expect(str.count > 5) // success
let array1 = [1, 2, 3, 4, 5]
let array2 = [1, 2, 3, 3, 4, 5]
#expect(array1 == array2) // failed: (array1 → [1, 2, 3, 4, 5]) != (array2 → [1, 2, 3, 3, 4, 5])
#expect
还支持多种用于捕获异常场景的形式:
- 不应抛出错误
let age = 10
#expect(throws: Never.self) { // Expectation failed: an error was thrown when none was expected: "err1" of type MyError
if age > 0 {
throw MyError.err1
}
}
- 必须抛出错误(任意错误类型)
let age = 10
#expect(throws: any Error.self) { // Expectation failed: an error was expected but none was thrown
if age < 10 {
throw MyError.err1
}
}
- 必须抛出指定的错误(错误类型应符合
Equatable
协议)
enum MyError: Error, Equatable {
case err1
case err2(Int)
}
let age = 10
#expect(throws: MyError.err1) { // Expectation failed: expected error "err1" of type MyError, but "err2(10)" of type MyError was thrown
if age > 5 {
throw MyError.err2(age)
}
}
- 必须抛出错误,并根据错误捕获逻辑的布尔返回值进行判断
let age = 10
#expect(performing: {
if age > 5 {
throw MyError.err2(age)
}
}, throws: { err in
guard let error = err as? MyError, case let .err2(age) = error else {
return false
}
return age > 10 // 对错误捕获逻辑的布尔返回值进行判断
})
以上示例清晰地展示了,对于 Swift 开发者来说,使用 #expect
结合 Swift 自身的语法来构建测试表达更为清晰、简洁且灵活。
#require 宏
#require
宏主要用于在测试中解包可选值,功能类似于 XCTest 的 XCTUnwrap
:
let x: Int? = 10
let y: String? = nil
let z = try #require(x) // 通过,z == 10
let w = try #require(y) // 失败,测试因抛出错误而提前结束
除了解包,#require
还提供了与 #expect
宏相同的所有构造方法,主要区别在于其语义——#require
用于表达“必须通过的测试”,而 #expect
更侧重于“预期获得的结果”。
任何使用 #expect
的代码均可用 #require
进行替换:
try #require(a == b)
try #require(throws: any Error.self, performing: {
if age < 10 {
throw MyError.err1
}
})
使用 #require
时,测试函数必须标记为可以抛出错误,并且需要在 #require
前使用 try
。
confirmation
当需要验证某个事件发生的次数时,特别是当 #expect
和 #require
无法满足需求时,confirmation
函数便显得尤为重要。它不仅可以验证事件在特定上下文中的发生情况,如事件处理器或委托回调,还可以用于确认事件发生的次数。
以下代码示例展示了如何使用 confirmation
函数:我们设定了一个期望,即 pressDown
事件必须被触发三次。测试只有在确实收到三次 pressDown
事件时才会通过:
await confirmation(expectedCount: 3) { keyPressed in
keyHandler.eventHandler = { event in
if event == .pressDown {
keyPressed() // 标记事件发生
}
}
await keyHandler.getEvent() // 激活事件处理器以等待事件
}
在某些测试场景中,验证特定事件未发生同样重要。例如,以下代码片段展示了如何确保在测试执行期间未触发 pressDown
事件。测试仅在没有接收到任何 pressDown
事件时才会通过:
await confirmation(expectedCount: 0) { keyPressed in
keyHandler.eventHandler = { event in
if event == .pressDown {
keyPressed()
}
}
await keyHandler.getEvent()
}
withKnownIssue
对于已知可能产生问题或错误的函数,但不希望这些问题导致测试失败时,可以使用 withKnownIssue
来进行标记。
@Test func example() async throws {
withKnownIssue(isIntermittent: true) { // Expected failure
try flakyCall()
}
}
这种方法具有以下优势:
- 提升测试的可靠性,尤其是对于包含不稳定因素的测试。
- 允许在不影响总体测试结果的情况下继续运行包含已知问题的测试。
- 提供一种机制来记录和追踪已知问题,而不是简单地禁用测试。
withKnownIssue
是一种强大的工具,适用于处理复杂的测试场景和已知的系统限制,使开发者能够在承认问题存在的同时继续进行测试,这对于维护大型和复杂的测试套件非常有价值。
在 Swift Testing 中,使用上述几种工具就可以替代 XCTest 中的多种断言方法,极大地简化了测试代码的编写过程。
组织测试用例
Swift Testing 提供了多种方式和维度来组织和管理测试用例,使开发者能够更有效地控制和维护他们的测试代码。通过灵活的结构化方法,包括套件、嵌套套件以及标签系统,开发者可以根据项目需求构建清晰且逻辑性强的测试架构。
测试套件(Suite)
Swift Testing 不仅支持通过全局函数构建测试,还允许在结构体(struct
)、类(class
)和演员(actor
)中定义测试用例,从而形成结构化的测试套件。
一个包含 @Test
函数的类型,无需额外配置即被隐式视为一个套件。以下示例展示了一个基本的测试套件,包含对姓名和年龄的验证:
struct PeopleTests {
@Test func checkName() async throws {
let fat = People.fat
#expect(fat.name == "fat")
}
@Test func checkAge() async throws {
let fat = People.fat
#expect(fat.name.count > 0)
}
}
若需对套件进行重命名或设定特定条件,可使用 @Suite
宏明确指定:
@Suite("人员测试")
struct PeopleTests {
// 测试函数定义
}
测试方法在 Swift Testing 中不仅支持 async
和 throws
,还可以通过使用 mutating
关键字来修改 struct
类型套件的变量数据。
struct Group {
var count = 0
@Test mutating func test1() {
count += 1
#expect(count > 0)
}
}
套件嵌套
为了更复杂的测试需求,Swift Testing 支持套件嵌套,允许在一个套件中嵌入其他套件,以构建更加细致的测试结构。
struct PeopleTests {
struct NameTests {
@Test func checkName() async throws {
let fat = People.fat
#expect(fat.name == "fat")
}
}
struct AgeTests {
@Test func checkAge() async throws {
let fat = People.fat
#expect(fat.name.count > 0)
}
}
}
在 Swift Testing 中,测试套件需包含无参数的 init()
,因此,不能在枚举中直接定义测试用例。然而,枚举可以用来组织套件:
enum PeopleTests {
struct NameTests {
@Test func checkName() async throws {
let fat = People.fat
#expect(fat.name == "fat")
}
}
struct AgeTests {
@Test func checkAge() async throws {
let fat = People.fat
#expect(!fat.name.isEmpty)
}
}
}
利用标签进行测试管理
Swift Testing 还提供了基于标签(Tag)的分类方式,增加了对测试用例管理的灵活性和维度。
@Suite(.tags(.people))
struct PeopleTests {
struct NameTests {
@Test func checkName() async throws {
let fat = People.fat
#expect(fat.name == "fat")
}
}
struct AgeTests {
@Test(.tags(.numberCheck)) func checkAge() async throws {
let fat = People.fat
#expect(!fat.name.isEmpty)
}
}
}
为套件(@Suite
)或测试用例(@Test
)添加标签后,不仅可以直接运行包含特定标签的测试用例,还能便利地构建和管理测试计划(Test Plan)。这使得针对不同功能或需求集中进行测试变得更加简单高效。
特征( Trait )
Swift Testing 提供了多种特征(Traits)定义,使开发者能够控制和配置测试套件(@Suite
)和测试用例(@Test
)。这些特征允许对测试执行的行为进行细致的管理,如上文中出现的 .tag
特征,专用于添加标签。
tag
给套件或测试用例添加标签。开发者可以通过如下方式声明标签:
@Suite(.tags(.people))
extension Tag {
@Tag static var people: Self
@Tag static var numberCheck: Self
}
enabled
仅在满足特定条件时执行相应的测试:
let enableTest = true
@Test(.enabled(if: enableTest)) func example() {}
disabled
禁用当前测试。与通过注释禁用测试相比,disabled
特征允许在测试日志中提供更明确的屏蔽原因:
@Test(.enabled(if: enableTest),.disabled("ignore for this loop"))
func example() {} // Test 'example()' skipped: ignore for this loop
bug
为测试用例或套件添加与 Bug 相关的信息(如 URL、标识符或备注),这些信息将被集成到测试报告中:
@Test(.bug("https://example.org/bugs/1234"))
func example() {
withKnownIssue {
#expect( 3 > 4)
}
}
timeLimit
设置测试的最大运行时间,超时则视为测试失败:
@Test(.timeLimit(.minutes(1)))
func example() async throws{ // Time limit was exceeded: 60.000 seconds
try await Task.sleep(for: .seconds(70))
#expect( 4 > 3)
}
serialized
虽然 Swift Testing 默认采用并行化的测试模式,serialized
特征允许指定的测试套件以串行方式执行:
@Suite(.serialized)
struct PeopleTests {
struct NameTests {
@Test func checkName() async throws {
let fat = People.fat
#expect(fat.name == "fat")
}
}
struct AgeTests {
@Test(.tags(.numberCheck)) func checkAge() async throws {
let fat = People.fat
#expect(!fat.name.isEmpty)
}
}
}
特征继承
测试套件上定义的特征会被其所有嵌套类型和测试用例继承。例如,添加到 PeopleTests
的 .tags(.people)
特征会自动应用到 NameTests
和 AgeTests
,以及它们包含的测试用例上
@Suite(.tags(.people)) // 添加 people 标签到套件
struct PeopleTests {
struct NameTests { // 默认继承 people tag
@Test func checkName() async throws { // 默认继承 people tag
let fat = People.fat
#expect(fat.name == "fat")
}
}
struct AgeTests { // 默认继承 people tag
@Test func checkAge() async throws { // 默认继承 people tag
let fat = People.fat
#expect(!fat.name.isEmpty)
}
}
}
了解特征的继承规则和它们在多层级结构中的相互关系是至关重要的。在下面的示例中,test1()
只有在满足 Suite
和个别测试用例的条件时才会执行。如果条件不满足,如 count
未大于 20,相关的测试用例将被跳过:
let count = 10
@Suite(.enabled(if: count > 3))
struct Group1 {
@Test(.enabled(if:count > 20)) // skip
func test1() async throws {
#expect(true)
}
@Test
func test2() async throws { // success
#expect(true)
}
}
让测试内容和测试结果更清晰
Swift Testing 已经显著改善了失败提示的质量,优于 XCTest。然而,我们还可以采取以下措施,进一步提升错误报告的清晰度和信息丰富性。
自定义错误提示
Swift Testing 提供了强大的自定义错误提示功能,适用于 #expect
、#require
、confirmation
和 withKnownIssue
等断言:
@Test func checkAge() async throws {
let age = 0
#expect(age > 10,"age should be greater than 10") // Expectation failed: (age → 5) > 10 age should be greater than 10
}
这种自定义消息使错误原因一目了然,有助于快速定位问题。
自定义类型在错误报告中的表达方式
对于复杂的数据类型,我们可以实现 CustomTestStringConvertible
协议,自定义 testDescription
属性,以改善其在错误报告中的表现。这样做不仅使错误信息更加直观,也增强了报告的可读性。
struct Student: Equatable {
let name: String
let age: Int
let address: String
let id: Int
}
@Test func checkStudent() async throws {
let fat = Student(name: "fat", age: 5, address: "", id: 0)
let bob = Student(name: "bob", age: 5, address: "", id: 0)
// Expectation failed: (fat → Student(name: "fat", age: 5, address: "", id: 0)) == (bob → Student(name: "bob", age: 5, address: "", id: 0))
#expect(fat == bob)
}
// 自定义 testDescription
extension Student: CustomTestStringConvertible {
var testDescription: String {
"student: \(name)"
}
}
@Test func checkStudent() async throws {
let fat = Student(name: "fat", age: 5, address: "", id: 0)
let bob = Student(name: "bob", age: 5, address: "", id: 0)
// Expectation failed: (fat → student: fat) == (bob → student: bob)
#expect(fat == bob)
}
通过这些技术,测试结果的表述不仅更具体,而且更容易理解,极大地简化了测试代码的调试过程。
参数化测试( Parameterized testing )
参数化测试是 Swift Testing 一项非常有特色的功能,它显著减少了重复测试相同逻辑但使用不同参数的需要。这使得开发者能够在最小化代码重复的同时扩展测试覆盖范围,覆盖更广泛的使用场景。
考虑以下多个测试用例的示例:
struct VideoContinentsTests {
@Test func mentionsFor_A_Beach() async throws {
let videoLibrary = try await VideoLibrary()
let video = try #require(await videoLibrary.video(named: "A Beach"))
#expect(!video.mentionedContinents.isEmpty)
#expect(video.mentionedContinents.count <= 3)
}
@Test func mentionsFor_By_the_Lake() async throws {
let videoLibrary = try await VideoLibrary()
let video = try #require(await videoLibrary.video(named: "By the Lake"))
#expect(!video.mentionedContinents.isEmpty)
#expect(video.mentionedContinents.count <= 3)
}
@Test func mentionsFor_Camping_in_the_Woods() async throws {
let videoLibrary = try await VideoLibrary()
let video = try #require(await videoLibrary.video(named: "Camping in the Woods"))
#expect(!video.mentionedContinents.isEmpty)
#expect(video.mentionedContinents.count <= 3)
}
// ...and more, similar test functions
}
通过使用参数化测试,上述代码可以被简化为:
struct VideoContinentsTests {
@Test("Number of mentioned continents", arguments: [
"A Beach",
"By the Lake",
"Camping in the Woods",
"The Rolling Hills",
"Ocean Breeze",
"Patagonia Lake",
"Scotland Coast",
"China Paddy Field",
])
func mentionedContinentCounts(videoName: String) async throws {
let videoLibrary = try await VideoLibrary()
let video = try #require(await videoLibrary.video(named: videoName))
#expect(!video.mentionedContinents.isEmpty)
#expect(video.mentionedContinents.count <= 3)
}
}
Swift Testing 自动跟踪每次测试调用的参数,并在结果中记录它们。开发者可以选择性地重新运行失败的特定参数组合,进行细粒度的调试:
let data = [
("fat",3),
("bob",2)
]
@Test(arguments:data)
func matchStrLength(str:String,count:Int) {
#expect(str.count == count)
}
在上面的测试中,与 bob
对应的长度数据 2
出现了错误,将其调整为 3
后,可以点击左侧导航栏中对应的 bob
参数,单独执行这一组数据的测试。
Swift Testing 支持使用任何符合 Collection
协议的数据结构作为参数源,要求数据类型一致且每个数据项符合 Sendable
协议。以下几种方式声明的数据均可作为 matchStrLength
的测试参数源:
let data: [String: Int] = [
"fat": 3,
"bob": 3,
]
@Test(arguments: data)
let strs = ["fat","bob"]
let counts = [3,3]
@Test(arguments: strs,counts)
let strs = ["fat","bob"]
let counts = [3,3]
@Test(arguments: zip(strs,counts))
参数化测试的并行化执行不仅简化了测试代码,而且通过自动记录每次测试的参数以及提供选择性重运行的机制,显著提高了测试的灵活性和执行的准确性。
并行化
Swift Testing 采用默认并行化的测试方式。并行化不仅加快了测试结果的输出和缩短了迭代周期,还有助于揭示测试之间的隐藏依赖关系,从而促使开发者在代码中实施更严格的状态隔离措施。
对于不适宜并行执行的测试,可以通过为 @Suite
添加 serialized
特征来禁用并行化。由于特征的继承性,一旦套件被标记为 serialized
,其内部的所有测试都将按顺序执行。
参数化测试默认采用并行化方式,相比于传统的 for in
循环迭代数据,这种方法显著提高了测试的效率。然而,这种方法不保证数据处理的顺序。
在 XCTest 中,为 XCTestCase
子类添加 @MainActor
或其他 GlobalActor
标注可能会触发警告,这迫使开发者需要为每个测试用例单独添加 @MainActor
。
@MainActor // Main actor-isolated class 'LoggerTests' has different actor isolation from nonisolated superclass 'XCTestCase'; this is an error in the Swift 6 language mode
class LoggerTests: XCTestCase {
}
而在 Swift Testing 中,这类限制不存在。你可以直接在 Suite
上使用 @MainActor
标注,或声明 actor
类型的 Suite
。需要注意的是,即便在这种情况下,其包含的测试用例仍将以并行方式执行,因此无法保证执行顺序。
开发者可以通过定义套件的 init
和 deinit
方法,为每个测试用例准备和清理数据:
actor IntermediateTests {
private var count: Int
init() async throws {
// 在此类型中的每个 @Test 实例方法之前运行
self.count = try await fetchInitialCount()
}
deinit {
// 在此类型中的每个 @Test 实例方法之后运行
print("count: \(count), delta: \(delta)")
}
@Test func example3() async throws {
delta = try await computeDelta()
count += delta
// ...
}
}
测试框架会为每个测试用例创建一个独立的套件实例,确保每个实例的变量独立,从而支持更高的测试隔离度和并行性。
与 XCTest 混合使用
Swift Testing 是一个新兴的测试框架,尚未支持某些特定功能,如性能测试和 UI 测试,这使得它需要与其他测试框架如 XCTest 配合使用。此外,对于已有大量基于 XCTest 的测试用例,要求开发者一次性全面转换到 Swift Testing 是不现实的。因此,Swift Testing 设计为可以与 XCTest 在同一个目标(Target)中共存,支持逐步迁移。
需要特别注意的是,在 Swift Testing 中使用 XCTest 的断言或反之都是不允许的。
在命令行中执行 swift test --enable-swift-testing
命令后,可以在单次运行中同时执行基于 Swift Testing 和 XCTest 的测试用例,实现两种测试框架的无缝整合。
Swift Testing 只能运行于 Swift 6 语言模式吗?
虽然 Swift Testing 需要在使用 Swift 6 工具链的项目中运行,但它并不强制要求使用 Swift 6 语言模式。即便是在 Swift 5 语言模式下,甚至是采用最小化的并发检查,Swift Testing 仍能正常执行。然而,为了充分利用 Swift Testing 提供的并发测试能力,建议开发者在编写测试代码时采用更现代的并发模型并严格限制数据竞争,从而享受到这些默认特性所带来的优势。
开源与跨平台
Swift Testing 是一个由社区主导开发的开源框架,其适用范围涵盖了 Swift 语言支持的所有主要平台,包括苹果生态系统、Linux 以及 Windows。作为一个开源项目,Swift Testing 不仅仅是一个测试工具,更是一个充满活力的技术社区。框架的开发团队积极鼓励全球开发者参与项目的 讨论 和 开发。无论是提出新想法、报告问题,还是直接贡献代码,每一份努力都将推动 Swift Testing 更快速地成长和成熟。
这种开放、协作的模式不仅确保了框架能够持续改进,适应不断变化的开发需求,还为 Swift 开发者提供了一个深入学习和影响测试工具发展方向的宝贵机会。通过积极参与,开发者们可以共同塑造 Swift 生态系统中这一关键组件的未来。
总结
Swift Testing 框架为 Swift 开发者提供了全新的测试体验。它的简洁语法、灵活的预期表达方式,以及强大的特性支持,使得编写和维护测试代码不仅更加迅速(Swifter),也更符合 Swift 开发者的习惯(Swifty)。通过本文对其核心功能的详细介绍和代码示例,我们展示了如何在项目中应用 Swift Testing,并利用其独特优势提升测试质量。
在实际开发过程中,逐步迁移到 Swift Testing 框架是一个明智的决策。开发者可以从新增的测试用例开始采用 Swift Testing,并逐步将现有的 XCTest 测试用例迁移过来。同时,对于性能测试和 UI 测试,Swift Testing 仍需与其他测试框架配合使用,以实现全面的测试覆盖。随着 Swift Testing 的持续发展和完善,我们期待它将带来更多的功能和优化。Swift Testing 的引入不仅丰富了 Swift 语言生态,更是提升了开发者的生产力的重要一步。让我们一起期待 Swift Testing 为 Swift 开发带来的更多惊喜!