- 前端
- 移动开发
【免费下载链接】swift-composable-architecture
A library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind.
本文以 Swift Composable Architecture(TCA)的TestStore穷尽性(exhaustivity)机制为主线,系统讲解穷尽测试与非穷尽测试的适用场景、配置方式与底层实现。读完本文,你将掌握如何通过store.exhaustivity = .off与非穷尽断言快速编写大型功能集成测试,同时仍保留对关键状态的精确校验能力,并理解库内部为何在非穷尽模式下放宽断言要求。
什么是 TestStore 的穷尽性
TCA 的TestStore默认采用穷尽测试(exhaustive testing)模式:测试必须对每一次状态变化、每一个由 Effect 回传的 Action 进行断言,并且所有在途(in-flight)Effect 都必须在测试结束前被接收处理完毕。正如 TestStore.swift 文档注释所述:
But, if in the future a bug is introduced causing a search request to be executed even when the query is empty, you will get a test failure because a new effect is being created that is not being asserted on. This is the power of exhaustive testing.
穷尽测试的威力在于:任何未被断言的副作用都会直接导致测试失败,从而把"预期之外的代码行为"第一时间暴露出来。例如搜索功能中,只要未来某次改动让空查询也触发了网络请求,穷尽测试会立刻报错,因为该 Effect 没有被断言。
TestStore的穷尽性由公开属性exhaustivity控制,默认值为.on(见 TestStore.swift):
public var exhaustivity: Exhaustivity = .onExhaustivity 枚举的三种取值
穷尽性配置的完整定义位于 TestStore.swift:
/// The exhaustivity of assertions made by the test store. public enum Exhaustivity: Equatable, Sendable { /// Exhaustive assertions. case on /// Non-exhaustive assertions. case off(showSkippedAssertions: Bool) /// Non-exhaustive assertions. public static let off = Self.off(showSkippedAssertions: false) }三个取值对应的语义如下:
| 取值 | 语义 | 说明 |
|---|---|---|
.on | 穷尽断言 | 必须穷尽断言所有状态变化与 Effect 回传的 Action;测试结束前所有在途 Effect 必须被接收。需要手动跳过时使用skipReceivedActions(strict:)与skipInFlightEffects(strict:),需要部分匹配 Action 时使用receive(_:timeout:assert:)的变体。 |
.off | 非穷尽断言(等价于.off(showSkippedAssertions: false)) | 允许只断言任意子集的状态变化与 Action,未断言的变更静默通过,不产生任何提示。 |
.off(showSkippedAssertions: true) | 非穷尽断言 + 显示被跳过的断言 | 所有未断言的变更会以灰色信息框形式展示在对应断言旁,帮助你了解"正在忽略哪些信息",但不导致测试失败。 |
该枚举遵循Equatable与Sendable,可以安全地在测试中比较或跨并发上下文传递。
关闭穷尽性后的三大行为变化
根据 TestStore.swift,将exhaustivity设为.off后,TestStore的行为会发生三点关键变化:
- 断言可以只覆盖部分状态变化:
send与receive的尾随闭包不再要求覆盖全部状态变化,可以只断言任意子集;只有当闭包内做出错误的修改时,才会报告测试失败。 - 允许在存在未断言 Action 时继续发送/接收:即使 Effect 已经回传了尚未断言的 Action,也可以继续调用
send/receive,未断言的待处理 Action 会被自动清除。 - 允许带着未断言 Action 与在途 Effect 结束测试:测试结束时,即使存在未断言的已接收 Action 和尚未完成的在途 Effect,也不会报告任何失败。
这种"宽松"测试风格最适用于多特性集成测试:当你想聚焦于某一行为切片时,不必关心其他特性内部如何变化。
配置方式一:直接设置 exhaustivity 属性
最直接的方式是在创建TestStore后立即设置exhaustivity属性,例如文档中针对登录功能集成测试给出的示例(TestStore.swift):
let store = TestStore(App.State()) { App() } store.exhaustivity = .off // ⬅️ 关闭穷尽性 await store.send(\.login.submitButtonTapped) await store.receive(\.login.delegate.didLogin) { $0.selectedTab = .activity }这段测试只关心"点击提交按钮后,最终选中 Tab 切换到 activity",完全不理会登录特性内部的状态变化与 Effect 数据流。
配置方式二:withExhaustivity 临时作用域
当你不希望整个测试都处于非穷尽模式,而只想在某个操作片段内临时切换时,可以使用withExhaustivity(_:operation:)。TestStore 提供了同步与异步两个重载(TestStore.swift):
/// 同步版本 public func withExhaustivity<R>( _ exhaustivity: Exhaustivity, operation: () throws -> R ) rethrows -> R { let previous = self.exhaustivity defer { self.exhaustivity = previous } self.exhaustivity = exhaustivity return try operation() } /// 异步版本 public func withExhaustivity<R>( _ exhaustivity: Exhaustivity, operation: () async throws -> sending R ) async rethrows -> R { let previous = self.exhaustivity defer { self.exhaustivity = previous } self.exhaustivity = exhaustivity return try await operation() }两个版本都遵循"保存原值 → 设置新值 → 执行操作 → 恢复原值"的defer模式,因此作用域结束后穷尽性自动还原,非常适合在测试中部临时放宽约束。有趣的是,库内部在非穷尽模式产生断言差异时,也会通过self.withExhaustivity(.on) { ... }临时回到穷尽模式来生成精确的 diff 报告(见 TestStore.swift)。
完整实战:穷尽 vs 非穷尽编写登录集成测试
文档用"3 个 Tab 的应用,其中第 3 个 Tab 是登录页"作为典型案例(TestStore.swift)。用户点击登录后,登录特性内部会发生一连串事件:发起 API 请求、接收响应、发送 delegate Action 通知父级,最终第三个 Tab 从登录页切换到资料页,同时选中 Tab 切换到第一个(活动)Tab。
穷尽风格的写法
穷尽风格要求你完整模拟登录特性的每一次状态变化和 Effect 回传:
let store = TestStore(initialState: App.State()) { App() } // 1️⃣ 模拟用户点击提交按钮 // (可以使用 case key path 语法将 Action 发送到深层嵌套的特性) await store.send(\.login.submitButtonTapped) { // 2️⃣ 断言登录特性中的所有状态变化 $0.login?.isLoading = true … } // 3️⃣ 登录特性执行 API 请求,并将响应回传到系统中 await store.receive(\.login.loginResponse.success) { // 4️⃣ 断言登录特性中的所有状态变化 $0.login?.isLoading = false … } // 5️⃣ 登录特性发送 delegate Action,通知父级特性已成功登录 await store.receive(\.login.delegate.didLogin) { // 6️⃣ 断言因该 Action 引起的所有 App 状态变化 $0.authenticatedTab = .loggedIn( Profile.State(...) ) … // 7️⃣ *最终*断言选中 Tab 切换到 activity $0.selectedTab = .activity }文档明确指出这种写法存在三个痛点(TestStore.swift):
- 必须深入了解登录特性内部实现,才能逐个断言其状态变化与 Effect 数据流;
- 登录特性逻辑一旦调整,即使本测试关心的行为并未变化,也可能连锁失败;
- 测试冗长,遇到类似但略有差异的流程时容易复制粘贴,产生大量脆弱、重复的测试代码。
非穷尽风格的写法
非穷尽风格只关心"登录导致选中 Tab 切换"这个高层流程:
let store = TestStore(App.State()) { App() } store.exhaustivity = .off // ⬅️ await store.send(\.login.submitButtonTapped) await store.receive(\.login.delegate.didLogin) { $0.selectedTab = .activity }我们完全没有断言登录特性的状态变化和 Effect 数据流,只断言"点击 Submit 后最终收到didLogindelegate Action,并把选中 Tab 切换到 activity"。登录特性此后可以自由调整内部逻辑,完全不影响这条集成测试。
showSkippedAssertions:查看被忽略的变更
store.exhaustivity = .off会让所有未断言的变更静默通过。如果你希望了解"测试正在忽略什么、生产环境可能的 bug 藏在哪",可以改用.off(showSkippedAssertions: true)(TestStore.swift):
let store = TestStore(initialState: App.State()) { App() } store.exhaustivity = .off(showSkippedAssertions: true) // ⬅️ await store.send(\.login.submitButtonTapped) await store.receive(\.login.delegate.didLogin) { $0.selectedTab = .profile }运行后,每条未完整断言的断言旁会出现灰色信息框,展示被跳过的变更(文档示例输出,TestStore.swift):
◽️ Expected failure: A state change does not match expectation: …
App.State( authenticatedTab: .loggedOut( Login.State( - isLoading: false + isLoading: true, … ) ) )Skipped receiving .login(.loginResponse(.success))
A state change does not match expectation: …
App.State( - authenticatedTab: .loggedOut(…) + authenticatedTab: .loggedIn( + Profile.State(…) + ), … )(Expected: −, Actual: +)
这些提示不会导致测试失败,只是告知你哪些变更未被显式断言,对排查"生产中发生但测试未捕获"的 bug 很有帮助。
源码级解析:非穷尽模式在内部如何工作
send 方法中的分支处理
TestStore.send在发送 Action 前会根据当前exhaustivity处理积压的已接收 Action(TestStore.swift):
switch self.exhaustivity { case .on: break case .off(showSkippedAssertions: true): await self.skipReceivedActions(strict: false) case .off(showSkippedAssertions: false): self.reducer.receivedActions = [] }可以看到:穷尽模式(.on)下,若存在未处理的已接收 Action,send会先报告 "Must handle N received actions before sending an action" 错误(TestStore.swift);非穷尽模式则直接清理积压队列——showSkippedAssertions: true时通过skipReceivedActions(strict: false)以灰色提示方式记录被跳过的 Action,showSkippedAssertions: false时直接清空。
expectedStateShouldMatch 中的断言对比逻辑
断言对比核心方法expectedStateShouldMatch在非穷尽模式下将"期望状态"的基准从"发送前的状态"改为"实际状态"(TestStore.swift):
case .off: var expectedWhenGivenActualState = actual if let updateStateToExpectedResult { try Dependencies.withDependencies { $0 = self.reducer.dependencies } operation: { try self.sharedChangeTracker.assert { try updateStateToExpectedResult(&expectedWhenGivenActualState) } } } expected = expectedWhenGivenActualState if expectedWhenGivenActualState != actual { self.withExhaustivity(.on) { expectationFailure(expected: expectedWhenGivenActualState) } } else if self.exhaustivity == .off(showSkippedAssertions: true) && …这段实现揭示了两条关键规则:
- 只断言子集也能通过:断言闭包基于
actual(当前真实状态)进行修改,只要闭包做出的修改与真实状态一致,即便还有大量其他状态变化未被覆盖,测试也通过。 - 错误断言仍然失败:如果闭包修改后的状态与真实状态不一致(比如把
count改成错误值),会临时切换回.on并生成带 diff 的失败报告,保证"宽松但不放过错误"。另外,闭包在执行时通过XCTModifyLocals.$isExhaustive注入当前穷尽性,供状态包装类型(如@PresentationState)判断是否允许部分修改(TestStore.swift)。
测试用例佐证:TestStoreNonExhaustiveTests
仓库的 TestStoreNonExhaustiveTests.swift 用大量测试验证了上述行为,例如:
- 部分断言合法:
testNonExhaustiveSend_PartialExhaustive在store.exhaustivity = .off(showSkippedAssertions: true)下,三次send(.increment)分别只断言count、isEven等字段的一部分(注释明确写着// Ignoring state change: ...),测试照常通过(TestStoreNonExhaustiveTests.swift); - 错误断言仍失败:
testNonExhaustiveSend_PartialExhaustive_BadAssertion使用XCTExpectFailure断言当闭包做出错误修改时,仍会产出 "A state change does not match expectation" 的 diff 失败报告(TestStoreNonExhaustiveTests.swift); - 跳过已接收 Action 与在途 Effect:
testSkipReceivedActions_PartialExhaustive演示了.off(showSkippedAssertions: true)下skipReceivedActions(strict: false)的用法;testCancelInFlightEffects_NonStrict/Strict则验证了skipInFlightEffects(strict:)在无在途 Effect 时的严格失败行为(TestStoreNonExhaustiveTests.swift)。
非穷尽模式下常用的配套 API
非穷尽模式常与以下 TestStore API 搭配使用:
finish(timeout:):等待所有在途 Effect 执行完毕,让测试可以"跑完整个副作用流程后再断言最终状态";skipReceivedActions(strict:):跳过所有尚未断言的已接收 Action;strict: true时若没有可跳过的 Action 会报告失败;skipInFlightEffects(strict:):取消并跳过所有在途 Effect;assert(_:):直接断言 store 的当前状态,是"发送 → 跑完 → 跳过 → 断言最终态"这一非穷尽工作流的收尾步骤(TestStore.swift):
store.exhaustivity = .off await store.send(\.child.closeButtonTapped) await store.finish() await store.skipReceivedActions() store.assert { $0.child = nil }官方文档特别说明:assert只适用于非穷尽测试商店,穷尽模式下无需使用,因为所有断言已由之前的send/receive完成。
何时使用穷尽、何时使用非穷尽
综合文档建议与源码注释,可以总结出以下选择原则:
| 场景 | 推荐模式 | 原因 |
|---|---|---|
| 叶子特性(leaf node features)的单元测试 | .on(默认) | 希望穷尽断言特性内部发生的一切,精确锁定所有状态变化与 Effect 行为 |
| 多特性集成测试 | .off或.off(showSkippedAssertions: true) | 聚焦某一段行为切片,不关心其他特性的内部实现细节 |
| 排查"生产中发生但测试未捕获"的 bug | .off(showSkippedAssertions: true) | 灰色提示框会列出所有被跳过的断言,帮助定位遗漏的校验点 |
值得一提的是,"非穷尽测试商店"这一概念最早由 Krzysztof Zabłocki 在博客文章与会议演讲中提出,后来被整合进 TCA 核心库(见 TestStore.swift 的注释引用)。
结语
TestStore.exhaustivity是 TCA 测试体系中"精确性"与"灵活性"之间的调节旋钮:默认的.on为你提供最严格的穷尽校验,防止任何未被断言的副作用悄悄溜走;.off则让你在大型集成测试中聚焦高层行为,showSkippedAssertions参数更是在两者之间提供"保留可见性、不阻塞测试"的中间态。结合 TestingTCA.md 与 TestStore.md 的完整 API 文档,以及 TestStoreNonExhaustiveTests.swift 的测试用例,你可以为每个特性精准选择合适的测试策略。
- 前端
- 移动开发
【免费下载链接】swift-composable-architecture
A library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind.
相关推荐
docz-core 演进全解读:从 CLI 命令编排到 Gatsby 驱动的文档构建内核(v0.1 → v2.4)
docz core 演进全解读:从 CLI 命令编排到 Gatsby 驱动的文档构建内核(v0.1 → v2.4) docz core 是 docz 项目(一个
前端移动开发TestStore 弃用 API 迁移指南:swift-composable-architecture 中已废弃测试接口的识别与替换
TestStore 弃用 API 迁移指南:swift composable architecture 中已废弃测试接口的识别与替换 在 swift compo
前端移动开发Swift Composable Architecture 状态共享完全指南:从 @Shared 到持久化策略与穷举测试
Swift Composable Architecture 状态共享完全指南:从 @Shared 到持久化策略与穷举测试 本指南围绕 swift composa
前端移动开发
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考