测试夹具分离动机:代码耦合问题、测试重复、维护成本

在 State Hoisting 驱动架构中,测试夹具(Test Fixture)如果与状态逻辑紧密耦合,会导致三大核心问题:代码耦合、测试重复、维护成本膨胀。本章深入分析这些问题的具体表现,并为后续分离方案提供动机支撑。

2.1 耦合问题的典型表现

耦合形式具体现象影响范围
状态与 UI 混合Fixture 中直接引用 ViewModel/Activity 实例每个 UI 变动都需重写夹具
状态与数据源混合Fixture 内部硬编码网络/数据库调用单元测试退化为集成测试
状态与生命周期混合依赖 CoroutineScope / LifecycleOwner非 Android 环境无法复用
警示: 低耦合 fixture 的初次开发成本比耦合版高约15%,但后续每次需求变更时,耦合版的修改成本是分离版的3~5倍。长期项目累计成本差异显著。

2.2 测试重复的深层原因

  • 夹具模板重复: 每个 `@Test` 方法前都包含类似的 `createFixture()` 初始化代码,包括 mock 创建、状态初始化、协程设置。
  • 断言逻辑重复: 因状态分散在不同层级,验证相同业务规则需要在 UI 层、ViewModel 层、Repository 层分别写断言。
  • 状态路径重复: 同一个状态树的分支(loading / success / error)被多个测试文件反复建立和销毁。
// 耦合版本的 fixture 示例 — 重复无处不在
@Test
fun showLoading() {
    val fixture = MyViewModel() // 真实 ViewModel
    fixture.onStart()
    assertTrue(fixture.state.isLoading)
}

@Test
fun showError() {
    val mockRepo = mock(MyRepo::class.java)
    `when`(mockRepo.fetch()).thenThrow(IOException())
    val fixture = MyViewModel(mockRepo) // 需要手动传 mock
    fixture.onStart()
    assertTrue(fixture.state is MyUiState.Error)
}
重复指标: 在 100 个测试用例的耦合项目中,约 60% 的 fixture 设置代码是重复的。分离后可将 fixture 设置代码缩减 40%~60%。

2.3 维护成本的核心构成

  1. 变更连锁成本: 修改状态结构(如新增字段)时,必须同步更新所有直接引用该状态的 fixture。若 fixture 分散在 3 个测试类,则需修改 3 个文件。
  2. 测试误报成本: 高耦合 fixture 容易因外部组件版本升级而大面积失败,导致开发人员消耗大量时间区分“真缺陷”与“夹具过时”。
  3. 复用困难成本: 耦合 fixture 无法轻松迁移到 Compose Preview 或 Benchmark 环境,每次新场景都要重写类似代码。
经验值: 单一职责的 fixture 类(单文件 30~50 行)维护成本约为耦合多文件 fixture(每文件 80~120 行)的 40%。

2.4 问题关系图:从耦合到成本

测试夹具耦合 → 重复 → 维护成本增长链路 状态与UI耦合 Fixture依赖具体视图 状态与数据源耦合 Fixture硬编码网络/DB 状态与生命周期耦合 依赖Scope/Lifecycle 测试夹具重复 模板代码重复 · 断言重复 · 状态路径重复 维护成本膨胀 变更连锁 · 测试误报 · 复用困难 关键度量 耦合 → 重复率 60% 分离 → 维护成本减 40%

2.5 可行解的启示

从上述分析可知,测试夹具分离的本质是:将状态创建逻辑提升为独立、可组合的 Fixture Factory,消除 UI / 数据源 / 生命周期的硬依赖。分离后的夹具具备以下特性:

  • 纯状态构造: 只关注数据规则,不涉及 UI 组件和外部服务。
  • 声明式组合: 通过 `stateIn(Snapshot) { ... }` 或 `mutableStateOf(value)` 快速构建期望状态。
  • 环境无关: 在 JVM 单元测试、Compose Preview、Instrumented 测试中可完全复用。