news 2026/10/2 8:02:31

Swift Composable Architecture 测试指南:深入解析 TestStore 的穷尽性(Exhaustivity)配置

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Swift Composable Architecture 测试指南:深入解析 TestStore 的穷尽性(Exhaustivity)配置
  • 前端
  • 移动开发

【免费下载链接】swift-composable-architecture

A library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind.

项目地址:https://gitcode.com/GitHub_Trending/sw/swift-composable-architecture
点击查看免费下载

本文以 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 = .on

Exhaustivity 枚举的三种取值

穷尽性配置的完整定义位于 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的行为会发生三点关键变化:

  1. 断言可以只覆盖部分状态变化:send与receive的尾随闭包不再要求覆盖全部状态变化,可以只断言任意子集;只有当闭包内做出错误的修改时,才会报告测试失败。
  2. 允许在存在未断言 Action 时继续发送/接收:即使 Effect 已经回传了尚未断言的 Action,也可以继续调用send/receive,未断言的待处理 Action 会被自动清除。
  3. 允许带着未断言 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) && …

这段实现揭示了两条关键规则:

  1. 只断言子集也能通过:断言闭包基于actual(当前真实状态)进行修改,只要闭包做出的修改与真实状态一致,即便还有大量其他状态变化未被覆盖,测试也通过。
  2. 错误断言仍然失败:如果闭包修改后的状态与真实状态不一致(比如把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.

项目地址:https://gitcode.com/GitHub_Trending/sw/swift-composable-architecture
点击查看免费下载
上一篇:终极Dio并发请求优化指南:如何控制最大并发数提升性能
下一篇:VoltAgent 遥测导出实战:基于 with-voltagent-exporter 示例接入 VoltOps 可观测性平台

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 7:59:00

Pixelle-Video AI视频生成指南:一句话主题生成3分钟完整短视频

Pixelle-Video AI视频生成指南&#xff1a;一句话主题生成3分钟完整短视频 【免费下载链接】Pixelle-Video &#x1f680; AI 全自动短视频引擎 | AI Fully Automated Short Video Engine 项目地址: https://gitcode.com/GitHub_Trending/pi/Pixelle-Video Pixelle-Vide…

作者头像 李华
网站建设 2026/10/2 7:55:33

CSP-J初赛高频考点:int范围、进制转换、格雷码与栈的出栈序列解析

简介&#xff1a;面向参与CSP-J组初赛的考生和信息学竞赛指导教师&#xff0c;这份文档收录了二零二四年CSP-J组初赛的部分试题与答案解析&#xff0c;内容组织紧凑&#xff0c;便于考前快速浏览。主要分为两个模块&#xff1a;一是单选题部分&#xff0c;覆盖三十二位整数存储…

作者头像 李华
网站建设 2026/10/2 7:55:23

OpenCV工业缺陷检测实战:solvePnP姿态估计与intersectConvexConvex几何判断

1. 从一个“抓缺陷”的需求说起1.1 这个实例到底在做什么“抓出三个缺陷”这个标题听起来像是工厂质检线上的活儿&#xff0c;实际上它确实是。这个 OpenCV 实例要解决的问题很具体&#xff1a;在一张工业零件或者产品的图像里&#xff0c;自动找出三个预先定义好的缺陷区域&am…

作者头像 李华