25. 状态管理中的测试策略:如何测试密封类状态
测试状态管理,说白了就是验证你的 UI 在不同状态下表现对不对。
用密封类管理状态,好处是类型安全、分支穷举。但测试起来,很多人会踩坑。
我见过不少项目,密封类定义得很漂亮,测试却只测了成功路径。一旦某个状态没覆盖到,线上就崩。
今天聊聊怎么系统地测试密封类状态。
25.1 密封类状态测试的核心挑战
先想一个问题:为什么密封类比普通枚举难测?
因为密封类的每个子类可以携带不同的数据。比如:
sealed class UiState {
object Loading : UiState()
data class Success(val data: List<Item>) : UiState()
data class Error(val message: String, val retry: Boolean) : UiState()
object Empty : UiState()
}
测试时,你不仅要验证状态类型,还要验证携带的数据对不对。
我早期犯过一个错:只测了 Success 和 Error,漏掉了 Empty。结果用户看到空白页面,没有任何提示。
核心原则:密封类有多少个子类,测试就应该覆盖多少个分支。
25.2 基础测试策略:分支全覆盖
最直接的做法,就是用 when 表达式穷举所有子类。
Kotlin 编译器会帮你检查——如果你漏掉某个分支,编译不过。这是密封类最大的优势。
fun testUiStateRendering() {
val viewModel = MyViewModel()
// 测试 Loading
viewModel.setState(UiState.Loading)
assertEquals(true, viewModel.isLoadingVisible)
assertEquals(false, viewModel.isErrorVisible)
// 测试 Success
viewModel.setState(UiState.Success(listOf("A", "B")))
assertEquals(2, viewModel.itemCount)
assertEquals(false, viewModel.isEmptyVisible)
// 测试 Error
viewModel.setState(UiState.Error("网络异常", true))
assertEquals("网络异常", viewModel.errorMessage)
assertEquals(true, viewModel.retryVisible)
// 测试 Empty
viewModel.setState(UiState.Empty)
assertEquals(true, viewModel.isEmptyVisible)
}
嗯,这里要注意:每个分支的断言要覆盖 UI 可见性和数据正确性两方面。
25.3 进阶策略:数据驱动的参数化测试
如果密封类子类很多,或者数据组合复杂,手动写测试用例会累死人。
我个人习惯用参数化测试。JUnit5 的 @MethodSource 配合密封类,效果很好。
@ParameterizedTest
@MethodSource("provideUiStates")
fun testStateHandling(state: UiState, expectedVisibility: Boolean) {
val viewModel = MyViewModel()
viewModel.setState(state)
assertEquals(expectedVisibility, viewModel.isContentVisible)
}
companion object {
@JvmStatic
fun provideUiStates(): List<Arguments> = listOf(
Arguments.of(UiState.Loading, false),
Arguments.of(UiState.Success(listOf("A")), true),
Arguments.of(UiState.Error("err", false), false),
Arguments.of(UiState.Empty, false)
)
}
这样做的好处是:新增一个子类,只需要在数据源里加一行。测试代码本身几乎不用改。
小技巧:把测试数据定义成密封类的伴生对象扩展函数,方便复用。
25.4 状态转换测试:验证流程正确性
单状态测试只是基础。真正的坑在状态转换。
比如:用户点击重试,状态从 Error 变成 Loading,再变成 Success。中间任何一个环节出错,UI 就会卡住。
fun testErrorToSuccessFlow() {
val viewModel = MyViewModel()
// 初始状态:Error
viewModel.setState(UiState.Error("网络异常", true))
assertEquals(true, viewModel.retryVisible)
// 点击重试 -> Loading
viewModel.onRetryClicked()
assertEquals(true, viewModel.isLoadingVisible)
assertEquals(false, viewModel.retryVisible)
// 加载成功 -> Success
viewModel.onDataLoaded(listOf("Result"))
assertEquals(false, viewModel.isLoadingVisible)
assertEquals(1, viewModel.itemCount)
}
我曾经遇到过一个 bug:Error 状态下点击重试,Loading 动画闪了一下就消失了。排查半天,发现是状态转换时漏掉了 Loading 的中间态。
注意:状态转换测试要覆盖「正向流程」和「异常流程」。比如 Loading 过程中再次触发加载,或者 Error 后重试又失败。
25.5 使用 SVG 展示测试策略全景
下面这张图,概括了密封类状态测试的完整策略。
25.6 实战中的避坑指南
测试密封类状态,有几个坑我反复踩过。分享出来,希望你别走弯路。
- 不要只测「快乐路径」:Success 状态好测,但 Error、Empty、Loading 往往被忽略。我见过一个项目,线上崩溃是因为 Empty 状态没做 UI 处理。
- 注意数据不可变性:密封类通常配合不可变数据。测试时如果修改了状态对象,可能会影响后续断言。建议每个测试用例创建新的状态实例。
- 异步状态要加超时:如果状态切换涉及协程或回调,测试要加
await或delay。我习惯用runTest+advanceUntilIdle控制时间。 - 嵌套密封类要递归测试:如果密封类里又套了密封类,测试要递归覆盖所有层级。比如
UiState.Success.Data和UiState.Success.Empty要分开测。
我的经验:写测试前,先列一张「状态 × 转换」矩阵。横轴是当前状态,纵轴是触发事件。每个交叉点写一个测试用例。这样不会漏。
25.7 测试工具与框架推荐
工欲善其事,必先利其器。下面是我常用的测试工具组合:
| 场景 | 推荐工具 | 说明 |
|---|---|---|
| 单元测试 | JUnit5 + MockK | MockK 对密封类支持好,可以 mock 子类 |
| 参数化测试 | JUnit5 @MethodSource | 配合密封类伴生对象,数据与逻辑分离 |
| 协程测试 | kotlinx-coroutines-test | runTest + advanceUntilIdle 控制异步 |
| UI 测试 | Compose Test / Espresso | 验证状态变化后的 UI 渲染 |
| 覆盖率 | JaCoCo + Kover | 检查密封类分支是否全部覆盖 |
提示:用 Kover 替代 JaCoCo 在 Kotlin 项目中效果更好。它能识别密封类的所有子类,并统计每个分支的覆盖率。
25.8 总结
测试密封类状态,说白了就是三件事:
- 每个子类写一个测试用例
- 每个转换路径写一个测试用例
- 用参数化测试减少重复代码
做到这三点,你的状态管理代码基本不会出大问题。
我个人习惯在 CI 流程里加一个「密封类覆盖率检查」,低于 90% 就报警。效果很好,推荐你也试试。