测试夹具分离动机:代码耦合问题、测试重复、维护成本
在 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 维护成本的核心构成
- 变更连锁成本: 修改状态结构(如新增字段)时,必须同步更新所有直接引用该状态的 fixture。若 fixture 分散在 3 个测试类,则需修改 3 个文件。
- 测试误报成本: 高耦合 fixture 容易因外部组件版本升级而大面积失败,导致开发人员消耗大量时间区分“真缺陷”与“夹具过时”。
- 复用困难成本: 耦合 fixture 无法轻松迁移到 Compose Preview 或 Benchmark 环境,每次新场景都要重写类似代码。
经验值: 单一职责的 fixture 类(单文件 30~50 行)维护成本约为耦合多文件 fixture(每文件 80~120 行)的 40%。
2.4 问题关系图:从耦合到成本
2.5 可行解的启示
从上述分析可知,测试夹具分离的本质是:将状态创建逻辑提升为独立、可组合的 Fixture Factory,消除 UI / 数据源 / 生命周期的硬依赖。分离后的夹具具备以下特性:
- 纯状态构造: 只关注数据规则,不涉及 UI 组件和外部服务。
- 声明式组合: 通过 `stateIn(Snapshot) { ... }` 或 `mutableStateOf(value)` 快速构建期望状态。
- 环境无关: 在 JVM 单元测试、Compose Preview、Instrumented 测试中可完全复用。