Swift Testing 框架:@Test 宏如何革新 iOS 单元测试

发布时间:2026/7/26 7:57:14
Swift Testing 框架:@Test 宏如何革新 iOS 单元测试 1. 项目概述告别 XCTest 的冗长迎接 Test 的简洁如果你和我一样是个在 Swift 和 iOS 开发领域摸爬滚打多年的老手那你一定对 XCTest 框架又爱又恨。爱的是它作为苹果官方的测试框架与 Xcode 深度集成开箱即用恨的是它那套基于XCTestCase子类和test前缀方法的写法实在是有些“古典”和啰嗦。每次新建一个测试方法都要遵循func testExample() throws的格式方法名还得绞尽脑汁让它能自解释。更别提那些setUp()和tearDown()方法了虽然逻辑清晰但写多了总觉得样板代码太多。就在我们以为单元测试的写法就要这样一成不变时Swift 社区特别是 Swift 官方团队给我们带来了一个令人兴奋的新玩具基于宏Macro的新测试框架核心就是Test和Suite这两个注解。这不仅仅是语法糖这是一次测试编写体验的范式转移。简单来说Test宏允许你直接将任何一个函数标记为测试用例无需让它属于某个特定的XCTestCase子类。而Suite则用于逻辑上组织这些测试函数替代了传统的测试类。想象一下你不再需要为了测试一个简单的工具函数而去创建一个庞大的测试类你可以直接把测试写在被测试代码的旁边或者在一个独立的、结构更扁平的文件里。这种写法更符合 Swift 现代、简洁的语言特性也让测试代码的意图更加直白。对于新手而言它降低了编写测试的门槛因为规则更简单了对于老手它能显著减少样板代码让开发者更专注于测试逻辑本身。接下来我就结合自己这段时间的实践带你彻底搞懂这套新写法看看它如何重塑我们的 iOS 单元测试工作流。2. 核心思路与设计哲学从“类中心”到“函数中心”要理解Test和Suite的价值我们得先回顾一下 XCTest 的“类中心”模型。在 XCTest 中测试的组织单元是类XCTestCase的子类。所有测试方法都必须是这个类的实例方法并且以test开头。测试的执行生命周期如setUp、tearDown也与这个类实例绑定。这种模型源于更早期的 xUnit 框架如 JUnit它强调状态隔离和固定的执行顺序。而新的 Swift Testing 框架采用了“函数中心”模型。在这里最基本的单元就是一个被Test标记的函数。这个函数可以是全局函数也可以是某个类型如 struct、class上的静态方法或实例方法。Suite用于将一组相关的Test函数组织在一起但它本身不承载状态更多是起到一个逻辑分组和配置的作用比如为组内所有测试设置通用的标签或条件。这种转变带来了几个根本性的优势2.1 更灵活的代码组织你不再被强制要求将测试塞进特定的类里。你可以为每个被测试的模块或类型创建一个对应的测试文件里面直接包含一系列Test函数。甚至对于小型项目或原型你可以把测试直接写在主代码文件的底部虽然不推荐长期项目这样做。这种自由让测试代码的结构可以更紧密地映射产品代码的结构。2.2 更少的样板代码告别class MyTests: XCTestCase {和func testSomething() {这样的固定格式。现在一个测试就是一个普通的函数加上一个Test注解。函数名可以更自由只要它是有效的 Swift 标识符即可当然起个清晰的名字是良好实践。你也不需要为了使用某个辅助方法而把它定义在类里一个普通的工具函数就行。2.3 更强大的表达能力和配置新的框架通过宏和属性包装器Property Wrappers提供了更丰富的配置选项。例如你可以直接使用Test(arguments: [...])来轻松实现基于不同输入参数的参数化测试这在 XCTest 中需要额外的样板代码。你还可以用Test(.tags(.feature))来给测试打标签方便过滤和分类执行。这些配置都以一种声明式、内联的方式完成代码更紧凑意图更明确。2.4 更好的隔离性在 XCTest 中测试类实例的属性会在所有测试方法间共享如果不小心容易造成测试间的状态污染。虽然可以通过每次测试都重新初始化来避免但需要开发者自觉。在新的模型中每个Test函数执行时其上下文更加独立从设计上鼓励无状态的测试这有助于提高测试的可靠性和可并行性。注意Swift Testing 框架目前是作为 Swift 包的一个工具链的一部分在发展和推广它旨在成为未来 Swift 跨平台开发的标准化测试方案。虽然它现在与 XCTest 并存并且底层可能仍会调用 XCTest 的某些基础设施尤其在 iOS/macOS 上但其上层的 API 和开发体验是全新的。在 Xcode 中你需要确保项目支持 Swift 宏并且测试目标链接了相应的库。3. 环境准备与基础配置在开始编写新的测试之前我们需要确保开发环境已经就绪。这套新的测试框架对 Xcode 和 Swift 版本有一定要求因为它重度依赖 Swift 宏功能。3.1 环境要求首先确认你使用的 Xcode 版本。由于 Swift 宏在 Xcode 15 及更高版本中才达到生产可用的稳定状态因此我强烈建议使用 Xcode 15.0 或以上版本。你可以在终端输入xcodebuild -version来查看。其次你的项目需要将 Swift 语言版本设置为 5.9 或更高。你可以在项目设置的Build Settings中搜索Swift Language Version进行确认。3.2 在 Swift Package Manager (SPM) 项目中使用如果你使用的是纯 SPM 项目那么配置非常简单。在你的Package.swift文件中首先需要在dependencies数组里添加swift-testing库。然后在对应的 target比如你的测试 target的dependencies里引入它。// Package.swift let package Package( name: MyLibrary, products: [...], dependencies: [ .package(url: https://github.com/apple/swift-testing.git, branch: main), // 注意目前可能仍在主分支未来会有稳定版本标签 ], targets: [ .target(name: MyLibrary, dependencies: []), .testTarget( name: MyLibraryTests, dependencies: [ MyLibrary, .product(name: Testing, package: swift-testing), ] ), ] )添加依赖后在终端进入项目目录运行swift package resolve来获取依赖。之后你就可以在Tests/MyLibraryTests目录下创建新的 Swift 文件开始使用Test了。3.3 在 Xcode 项目中使用对于传统的 Xcode 项目步骤会稍微多一些因为需要手动将测试框架集成到构建过程中。目前最推荐的方式是通过 SPM 将swift-testing作为包依赖引入到你的 Xcode 工程中。在 Xcode 中打开你的项目点击菜单栏的File-Add Packages...。在搜索框中输入仓库地址https://github.com/apple/swift-testing.git。将Dependency Rule设置为Branch并输入main同样请关注官方发布稳定版本后的标签。点击Add PackageXcode 会解析包。在下一个窗口中确保将Testing产品添加到你的测试目标通常是YourProjectNameTests中。这一点至关重要不要添加到主应用或框架目标。完成添加后在你的测试文件中就可以通过import Testing来引入模块了。3.4 验证配置创建一个新的 Swift 文件例如BasicTests.swift放在你的测试目标目录下。写入以下内容import Testing Test func simpleAddition() { let result 1 1 #expect(result 2) }然后尝试运行这个测试点击测试方法旁边的菱形按钮或使用CmdU运行所有测试。如果一切配置正确测试应该能够编译并通过在测试导航器中你会看到这个测试用例。这里出现的#expect是新框架中的断言宏相当于 XCTest 中的XCTAssertEqual我们稍后会详细讲解。实操心得在初期集成时最常见的问题是Testing模块找不到或者宏展开失败。如果遇到编译错误首先检查1) 测试目标的Framework and Libraries里是否确实链接了Testing2) 项目的Build Settings中Swift Compiler - Custom Flags下的Other Swift Flags是否包含了-enable-experimental-feature Macros对于某些早期版本可能需要。如果问题依旧尝试清理构建文件夹CmdShiftK和CmdOptionShiftK并重启 Xcode。4. Test 宏详解编写你的第一个现代单元测试现在让我们深入Test宏的细节。它的基本用法极其简单在任何函数声明前加上Test即可。这个函数可以是同步的也可以是异步的async甚至可以抛出错误throws。4.1 基本结构一个最简单的测试函数如下所示import Testing Test func testThatOnePlusOneEqualsTwo() { let sum 1 1 #expect(sum 2) }几点关键变化函数名不再强制要求以test开头。你可以使用任何能清晰表达测试意图的名字例如addition_producesCorrectResult()或userProfile_isNilAfterLogout()。这提高了代码的可读性。断言使用#expect(...)宏进行断言。它接受一个返回布尔值的表达式。如果表达式为true测试通过为false测试失败。#expect的设计更现代化支持在测试失败时提供更丰富的诊断信息。4.2 参数化测试这是新框架的一大亮点。你可以轻松地为同一个测试逻辑提供多组输入输出数据框架会自动为每组数据生成一个独立的测试项。Test(arguments: [1, 2, 3, 5, 8]) func fibonacciNumberIsPositive(_ number: Int) { #expect(number 0) } Test(arguments: [ (input: 0, expected: 0), (input: 1, expected: 1), (input: 2, expected: 1), (input: 3, expected: 2), ]) func testFibonacci(_ input: Int, expected: Int) throws { let result try calculateFibonacci(input) // 假设这是你的函数 #expect(result expected) }在测试导航器中你会看到testFibonacci被展开为四个子测试分别对应四组参数。这比在 XCTest 中手动写循环或者创建多个测试方法要清晰和高效得多。4.3 异步与抛出测试处理异步操作和可能抛出错误的函数同样直观Test func fetchUserData() async throws { let service UserService() let user try await service.fetchUser(byId: 123) #expect(!user.name.isEmpty) }这个测试同时是异步的async和可抛错的throws。测试框架会正确处理异步等待和错误捕获。如果fetchUser抛出错误或者#expect断言失败测试都会相应地标记为失败。4.4 配置测试行为Test宏接受一个Test.CaseOptions参数用于配置测试的各个方面。这是一个强大的功能让你可以精细控制测试的执行。import Testing Test(.disabled(等待后端API修复)) // 禁用此测试并给出原因 func testBuggyAPI() { // ... } Test(.tags(.critical, .integration)) // 为测试打上“关键”和“集成”标签 func testPaymentProcessing() { // ... } Test(.timeLimit(.seconds(5))) // 设置超时时间为5秒 func testNetworkRequest() async throws { // ... } Test(.enabled(if: ProcessInfo.processInfo.environment[RUN_SLOW_TESTS] ! nil)) // 根据环境变量决定是否启用 func testVerySlowAlgorithm() { // ... }这些配置都以声明式的方式附加在测试函数上一目了然管理起来非常方便。你可以通过命令行或 Xcode 的测试计划Test Plan来过滤运行特定标签的测试或者跳过被禁用的测试。5. Suite 宏与测试的组织艺术当测试数量增多时良好的组织就变得至关重要。Suite宏就是用来解决这个问题的。它用于将一组测试函数以及嵌套的子套件逻辑上组织在一起。5.1 基本用法你可以使用Suite来标记一个结构体struct或枚举enum其内部的所有Test函数都会被自动收集为该套件的一部分。import Testing Suite struct UserModelTests { Test func userInitialization() { ... } Test func userEncoding() { ... } } Suite struct NetworkServiceTests { Test func testSuccessfulRequest() async throws { ... } Test(.tags(.slow)) func testTimeoutHandling() async throws { ... } }在测试导航器中你会看到以UserModelTests和NetworkServiceTests为分组显示的测试套件。这比 XCTest 中基于类名的分组更加灵活因为struct/enum本身没有继承关系更轻量。5.2 套件级别的配置与Test类似Suite也可以接受配置选项这些配置会应用到套件内的所有测试除非测试自身有覆盖。Suite(.tags(.integration), .disabled(if: !shouldRunIntegrationTests)) struct IntegrationTestSuite { Test func testDatabaseMigration() { ... } Test(.tags(.critical)) // 这个测试会同时拥有 .integration 和 .critical 标签 func testOrderProcessing() { ... } Test(.disabled(“单独禁用”)) // 套件的 .disabled 条件不满足时此测试仍被禁用 func testLegacyEndpoint() { ... } }这种层级化的配置管理非常强大。例如你可以轻松地为一整套集成测试设置超时、标签或执行条件。5.3 嵌套套件与扁平化组织你甚至可以创建嵌套的套件结构来反映更复杂的代码层次。Suite struct AppTests { Suite struct ModelTests { Test func testA() { ... } } Suite(.tags(.ui)) struct ViewTests { Test func testB() { ... } } }然而根据我的实践经验过度嵌套可能会让测试导航器变得复杂。对于大多数项目我倾向于使用相对扁平的套件结构按功能模块如UserAuthenticationTests、DataPersistenceTests、APIClientTests来组织每个模块一个文件文件内用一个Suite结构体包裹所有相关测试。这样在文件和导航器中的查找效率都很高。6. 断言与验证掌握 #expect 及其伙伴在新的测试框架中断言是测试逻辑的核心。#expect是主力但它还有几个兄弟共同构成了一个灵活而强大的断言系统。6.1 #expect核心断言宏#expect(_:is:)是通用断言。第一个参数是待检查的布尔表达式第二个可选参数is:可以指定一个自定义的失败描述。Test func testArrayOperations() { let array [1, 2, 3] #expect(array.count 3) #expect(array.isEmpty false, “数组不应为空”) // 自定义失败信息 let optionalValue: Int? 5 #expect(optionalValue ! nil) // 检查非空 #expect(optionalValue 5) // 解包并比较可选链 }#expect的一个强大之处在于它的表达式会在失败时被捕获并展示。例如如果array.count 3失败测试报告会显示array.count的实际值这比 XCTest 中某些断言需要手动填写“期望 3实际得到 \(array.count)”这样的信息要方便。6.2 #require致命性前置条件#require在语义上与#expect类似但它用于表示测试的前置条件。如果#require失败整个测试函数会立即停止执行并被标记为失败。这适用于那些如果条件不满足后续测试就毫无意义或无法安全执行的情况。Test func testFileProcessing() throws { let fileURL getTestFileURL() #require(FileManager.default.fileExists(atPath: fileURL.path)) // 文件必须存在才能继续 let data try Data(contentsOf: fileURL) // 如果#require失败这行不会执行 // ... 处理 data }在 XCTest 中我们通常用XCTSkipIf或提前return来处理#require提供了更标准化的方式。6.3 异步条件检查#expect与Task对于异步操作你可能需要等待某个条件在一段时间内成立。虽然可以直接用await和循环但新框架鼓励更清晰的模式。目前通常结合Task和循环检查来实现。Test func testAsyncStateUpdate() async { let viewModel MyViewModel() viewModel.performAsyncAction() // 等待最多2秒检查状态是否变为 .completed let timeout ContinuousClock().now .seconds(2) while ContinuousClock().now timeout viewModel.state ! .completed { await Task.yield() // 让出时间片避免忙等待 } #expect(viewModel.state .completed) }社区和未来版本的框架可能会提供更优雅的原生支持例如#expect(eventually: ...)之类的宏。6.4 与 XCTUnwrap 和 XCTest 断言的对比如果你有大量现有代码使用XCTAssertEqual,XCTUnwrap等迁移时可以继续在Test函数中使用它们因为XCTest模块仍然可用。但为了代码风格统一我建议在新测试中逐步转向#expect。对于解包#expect可以很自然地处理// 使用 #expect let optionalName: String? getUser()?.name #expect(optionalName “Alice”) // 如果 optionalName 为 nil断言失败信息会显示 nil // 如果需要强制解包以进行后续操作类似 XCTUnwrap可以这样做 guard let name optionalName else { Issue.record(“无法获取用户名”) // 使用 Issue API 记录问题 return } // 使用 name 继续测试新的IssueAPI 提供了比单纯使测试失败更灵活的问题记录方式。7. 测试生命周期与状态管理在 XCTest 中我们熟悉setUp()和tearDown()方法。在新的范式中虽然没有直接的对应物但有更灵活的模式来管理测试的初始化和清理。7.1 属性包装器与withTest函数常见的方法是使用属性包装器如TestState或调用withTest函数来管理具有生命周期的资源。不过截至我撰写本文时Swift Testing 框架的正式 API 中更推崇使用简单的局部变量和 Swift 的defer语句因为测试函数本身更轻量鼓励无状态设计。7.2 使用defer进行清理对于需要在测试后释放的资源如临时文件、数据库连接、网络模拟defer语句是绝佳选择。Test func testWritingToTempFile() throws { let tempDir FileManager.default.temporaryDirectory let tempFile tempDir.appendingPathComponent(UUID().uuidString) // 确保测试后删除临时文件 defer { try? FileManager.default.removeItem(at: tempFile) } let data “Test Data”.data(using: .utf8)! try data.write(to: tempFile) let readData try Data(contentsOf: tempFile) #expect(readData data) }defer保证了无论测试成功还是失败抛出错误清理代码都会执行。这种方式将资源的生命周期严格限制在函数作用域内非常清晰。7.3 共享的“夹具”如果多个测试需要相同的初始化数据例如一个配置好的数据库实例、一个登录的用户对象你可以将其提取到一个工厂函数或一个返回值的计算属性中。struct DatabaseTestFixture { static var sharedConnection: DatabaseConnection { let connection try! DatabaseConnection.makeTest() // 测试环境可以 force try // 可以在这里执行一些基础的数据库迁移或清理 return connection } } Suite struct UserPersistenceTests { Test func testUserInsert() throws { let connection DatabaseTestFixture.sharedConnection defer { connection.close() } // ... 使用 connection 进行测试 } Test func testUserQuery() throws { let connection DatabaseTestFixture.sharedConnection defer { connection.close() } // ... 使用 connection 进行另一个测试 } }注意这种方式下每个测试获取的是新的连接实例避免了测试间的状态干扰。如果确实需要昂贵的、可复用的全局资源你需要自己管理其生命周期并注意线程安全和重置状态。7.4 与 XCTest 生命周期的对比思考新的模式将状态管理的责任更多地交给了开发者。它放弃了 XCTest 中那种隐式的、基于类实例的生命周期转而采用显式的、基于作用域的模式。这要求开发者更谨慎地思考资源管理但同时也带来了更清晰的控制流和更少的“魔法”。对于从 XCTest 迁移过来的项目这是一个需要适应的思维转变。8. 迁移策略与混合使用指南对于已有大量 XCTest 测试的项目全盘迁移到新框架可能不现实。幸运的是两者可以在同一个项目中和平共处甚至可以在同一个测试目标内混合使用。这允许我们渐进式地迁移。8.1 共存与互不影响Xcode 的测试运行器能够同时识别 XCTest 测试用例XCTestCase子类和新的Test函数。它们会并列显示在测试导航器中。你可以同时运行它们。这意味着你可以在新编写的模块或功能时直接使用新的Test风格。逐步挑选旧的、逻辑清晰的 XCTest 类进行迁移。对于复杂的、重度依赖 XCTest 特定功能如XCTestExpectation用于复杂异步等待的测试可以暂时保留。8.2 迁移步骤建议我推荐的迁移路径是“由简到繁由新到旧”第一步配置环境。如第3节所述将新的 Testing 框架添加到你的测试目标中。第二步新测试用新风格。所有新增加的测试一律使用Test和Suite编写。第三步迁移工具函数。将 XCTest 类中的一些通用辅助函数不依赖self的提取为独立的工具函数供新旧测试共用。第四步迁移简单测试类。找到那些只包含简单、独立测试方法的 XCTestCase 子类。将其重写为一个或多个Suite。这个过程通常很直接主要是语法转换。第五步处理复杂依赖。对于有复杂setUp/tearDown或共享实例变量的测试类需要仔细设计。通常可以将共享状态重构为可注入的“夹具”对象然后在每个Test函数中独立创建或获取。8.3 混合使用时的注意事项导入语句新的测试文件需要import Testing旧的 XCTest 文件需要import XCTest。如果一个测试文件想同时使用两者不推荐但可能发生在过渡期需要同时导入。断言选择在同一个函数中混用#expect和XCTAssert可能会让代码风格不一致建议在一个函数内坚持使用一种风格。测试发现确保你的测试目标同时包含了两种类型的测试文件。Xcode 和swift test命令都能正确处理。8.4 一个迁移示例假设我们有一个旧的 XCTest 类// OldXCTest.swift import XCTest testable import MyApp class CalculatorTests: XCTestCase { var calculator: Calculator! override func setUp() { super.setUp() calculator Calculator() } override func tearDown() { calculator nil super.tearDown() } func testAddition() { let result calculator.add(2, 3) XCTAssertEqual(result, 5) } func testDivisionByZero() { XCTAssertThrowsError(try calculator.divide(10, by: 0)) } }可以迁移为// NewSwiftTesting.swift import Testing testable import MyApp Suite struct CalculatorTests { // 将共享的初始化提取为一个计算属性或工厂方法 private var calculator: Calculator { Calculator() } Test func addition() { let calc calculator // 每个测试获取新实例 let result calc.add(2, 3) #expect(result 5) } Test func divisionByZero() throws { let calc calculator // 注意错误检查方式的变化 #expect(throws: (any Error).self) { try calc.divide(10, by: 0) } } }注意错误检查使用了#expect(throws: ...)的变体这是新框架提供的更优雅的错误断言方式。9. 常见问题、调试技巧与避坑指南在实际采用新框架的过程中我遇到了一些典型问题和挑战。这里分享出来希望能帮你少走弯路。9.1 宏展开失败这是初期最常见的问题。错误信息可能比较晦涩例如 “Macro ‘Test’ can only be applied to a function declaration”。检查导入确保文件顶部有import Testing。检查函数签名Test只能应用于函数声明。确保它直接放在func关键字前中间不能有别的属性如objc。函数不能是泛型的目前限制。检查Swift版本和Xcode确保符合最低版本要求Swift 5.9, Xcode 15。清理构建尝试清理构建文件夹Product-Clean Build Folder并重启 Xcode。9.2 测试未被发现你写了Test函数但在测试导航器里看不到它或者运行测试时提示 “No tests were executed”。检查目标成员身份确保包含测试的 Swift 文件属于你的测试目标在 File Inspector 中勾选正确的测试 target。检查宏是否生效有时宏需要成功编译一次后Xcode 的测试发现机制才能识别。尝试编译整个项目CmdB。使用命令行在项目根目录运行swift test --list-tests对于 SPM 项目或xcodebuild test -scheme YourScheme -destination ‘platformiOS Simulator,...’ -list-tests对于 Xcode 项目来列出所有测试。这可以帮助你确定是 Xcode UI 的问题还是测试真的没被编译进去。9.3 异步测试超时或卡住新的测试框架对异步测试的支持是内建的但如果你错误地使用了同步等待可能会导致测试卡住。避免sleep不要用Thread.sleep或Task.sleep来等待异步结果这不可靠且低效。使用前面提到的循环检查#expect条件的方式。使用结构化并发确保你的异步测试函数正确地使用了await。如果测试函数内部启动了未等待的Task可能需要妥善处理它们的生命周期防止它们在测试结束后继续运行。9.4 与现有工具链的集成测试覆盖率新的测试框架生成的测试覆盖率数据可以与 Xcode 内置的代码覆盖率报告工具正常配合。确保在 Scheme 设置中启用了Gather coverage data。持续集成在 CI 脚本中你仍然可以使用xcodebuild test命令来运行测试它会自动运行新旧两种类型的测试。对于 SPM 项目使用swift test命令。测试计划Xcode 的测试计划Test Plans完全支持新的Test测试。你可以用标签来过滤、用条件来启用/禁用它们就像对待 XCTest 一样。9.5 性能考量目前由于宏展开和新的测试发现机制在包含大量Test函数的项目中我观察到初始编译和测试发现时间可能比同等的 XCTest 项目稍长。但随着框架的成熟和编译器优化这个问题预计会得到改善。对于超大型项目可以考虑将测试合理地分散到多个文件中而不是一个文件里塞进成百上千个测试。10. 实战构建一个完整的用户认证测试套件让我们通过一个更完整的例子将前面所学的知识串联起来。假设我们有一个UserAuthenticator类负责处理用户登录逻辑。// UserAuthenticator.swift (生产代码) import Foundation enum AuthError: Error { case invalidCredentials case networkError case accountLocked } class UserAuthenticator { private let networkService: NetworkService private let keychain: KeychainService init(networkService: NetworkService, keychain: KeychainService) { self.networkService networkService self.keychain keychain } func login(username: String, password: String) async throws - UserSession { // 模拟网络请求和业务逻辑 guard !username.isEmpty !password.isEmpty else { throw AuthError.invalidCredentials } // 这里假设 networkService 会真正发起请求 let token try await networkService.requestToken(username: username, password: password) let session UserSession(token: token, username: username) try keychain.store(session: session) return session } func logout() throws { try keychain.removeCurrentSession() } var currentSession: UserSession? { keychain.loadCurrentSession() } }现在我们为它编写新的测试// UserAuthenticatorTests.swift import Testing testable import MyApp // 模拟对象 class MockNetworkService: NetworkService { var shouldSucceed true var requestedUsername: String? func requestToken(username: String, password: String) async throws - String { requestedUsername username if shouldSucceed { return “mock-jwt-token-for-\(username)” } else { throw AuthError.networkError } } } class MockKeychainService: KeychainService { private var storedSession: UserSession? func store(session: UserSession) throws { storedSession session } func removeCurrentSession() throws { storedSession nil } func loadCurrentSession() - UserSession? { storedSession } } Suite(.tags(.unit, .critical)) struct UserAuthenticatorTests { // 每个测试都会得到一套新的、干净的模拟对象和认证器 private func makeSUT( networkSucceeds: Bool true ) - (authenticator: UserAuthenticator, mockNetwork: MockNetworkService, mockKeychain: MockKeychainService) { let mockNetwork MockNetworkService() mockNetwork.shouldSucceed networkSucceeds let mockKeychain MockKeychainService() let authenticator UserAuthenticator(networkService: mockNetwork, keychain: mockKeychain) return (authenticator, mockNetwork, mockKeychain) } Test func login_withValidCredentials_returnsSessionAndStoresInKeychain() async throws { let (sut, mockNetwork, mockKeychain) makeSUT() let session try await sut.login(username: “alice”, password: “securePassword123”) #expect(session.username “alice”) #expect(session.token “mock-jwt-token-for-alice”) #expect(mockNetwork.requestedUsername “alice”) #expect(mockKeychain.loadCurrentSession()?.username “alice”) } Test func login_withEmptyCredentials_throwsInvalidCredentialsError() async { let (sut, _, _) makeSUT() // 使用 #expect(throws: ...) 来断言抛出的错误类型 await #expect(throws: AuthError.invalidCredentials) { try await sut.login(username: “”, password: “”) } } Test(.tags(.network)) func login_whenNetworkFails_throwsNetworkError() async { let (sut, _, _) makeSUT(networkSucceeds: false) await #expect(throws: AuthError.networkError) { try await sut.login(username: “bob”, password: “password”) } } Test func logout_clearsCurrentSessionFromKeychain() throws { let (sut, _, mockKeychain) makeSUT() // 先模拟存储一个会话 let testSession UserSession(token: “old-token”, username: “old-user”) try mockKeychain.store(session: testSession) #expect(mockKeychain.loadCurrentSession() ! nil) // 前置检查 try sut.logout() #expect(mockKeychain.loadCurrentSession() nil) } Test func currentSession_initially_isNil() { let (sut, _, _) makeSUT() #expect(sut.currentSession nil) } Test(arguments: [“alice”, “bob”, “charlie”]) func login_withDifferentUsernames_includesUsernameInToken(_ username: String) async throws { let (sut, mockNetwork, _) makeSUT() let session try await sut.login(username: username, password: “pass”) // 检查 token 是否包含了用户名 #expect(session.token.contains(username)) #expect(mockNetwork.requestedUsername username) } }这个测试套件展示了多个关键实践依赖注入与模拟通过makeSUT工厂方法为每个测试提供独立的、可配置的模拟对象和被测系统实例确保测试隔离。清晰的测试命名使用[方法]_[条件]_[预期结果]的命名模式使测试意图一目了然。参数化测试login_withDifferentUsernames_includesUsernameInToken使用Test(arguments:)优雅地测试了多个输入。错误断言使用#expect(throws:)来验证是否抛出了特定的错误。标签使用用Suite(.tags(.unit, .critical))为整个套件打上标签方便在 CI 或大型项目中筛选运行关键单元测试。通过这个完整的例子你可以看到新的测试框架如何让测试代码变得更简洁、更富有表达力同时又不失结构和严谨性。它鼓励更好的测试设计模式最终帮助我们构建出更可靠、更易维护的应用程序。