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 展示测试策略全景

下面这张图,概括了密封类状态测试的完整策略。

密封类状态测试策略全景 密封类状态测试 分支全覆盖测试 when 穷举所有子类 验证 UI 可见性 + 数据 编译器保证无遗漏 参数化数据驱动 @MethodSource 批量测试 新增子类只需加数据 测试代码零改动 状态转换测试 Error→Loading→Success 正向流程 + 异常流程 验证中间态完整性 核心原则:每个子类一个测试用例,每个转换路径一个测试用例 测试覆盖率 = 子类覆盖率 × 转换路径覆盖率

25.6 实战中的避坑指南

测试密封类状态,有几个坑我反复踩过。分享出来,希望你别走弯路。

  • 不要只测「快乐路径」:Success 状态好测,但 Error、Empty、Loading 往往被忽略。我见过一个项目,线上崩溃是因为 Empty 状态没做 UI 处理。
  • 注意数据不可变性:密封类通常配合不可变数据。测试时如果修改了状态对象,可能会影响后续断言。建议每个测试用例创建新的状态实例。
  • 异步状态要加超时:如果状态切换涉及协程或回调,测试要加 awaitdelay。我习惯用 runTest + advanceUntilIdle 控制时间。
  • 嵌套密封类要递归测试:如果密封类里又套了密封类,测试要递归覆盖所有层级。比如 UiState.Success.DataUiState.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% 就报警。效果很好,推荐你也试试。