第1章 分离动机与价值
将 ViewModel 与 UI 夹具(Activity / Fragment / View)耦合在一起,是移动端测试脆性的主要根源。本章从测试维护痛点出发,揭示耦合导致的脆性本质,并量化分离后的可测试性收益。
1.1 测试维护痛点
Android 原生开发中,常见的耦合模式是 ViewModel 直接持有 Context、View 引用或 LiveData 绑定在 Activity 生命周期内。这种模式导致以下测试问题:
| 痛点 | 具体表现 | 频率 |
|---|---|---|
| 依赖环境模拟 | 测试需要启动 Activity / Fragment,依赖 ActivityScenarios,耗时长 | 高 |
| 异步状态同步 | LiveData / Flow 在 UI 线程同步,测试容易因主线程锁死而失败 | 中 |
| 夹具状态污染 | 一次测试中 Activity 重建后 ViewModel 被释放,不能隔离 | 中 |
| 低覆盖率 | 开发人员因测试成本高而放弃,核心业务逻辑缺乏测试 | 极高 |
典型反例: 在 ViewModel 的
init 块中直接调用 repository.fetchData() 并将结果绑定到 LiveData,但未对外部依赖做接口抽象。测试时需要真实网络后端或模拟器,导致集成测试而非单元测试。
1.2 UI 与 ViewModel 耦合导致的脆性
耦合不是简单的代码组织问题,而是一组结构性脆性因素的集合:
- 生命周期绑定: ViewModel 的
onCleared()与 UI 的销毁配对,测试中难以验证 ViewModel 自身状态。 - 隐式依赖链: ViewModel 通过
SavedStateHandle访问 Bundle,单元测试必须提供 Bundle 和Application上下文。 - UI 线程敏感:
LiveData.setValue()只能在主线程调用,导致普通单元测试框架(如 JUnit)无法直接测试。 - 重构阻力: 修改 ViewModel 定义时,UI 夹具的
observe代码也需要同步修改,增加回归成本。
核心观点: 脆性的本质是 ViewModel 承担了本不属于它的职责——管理 UI 订阅和生命周期感知。分离的目标是将 ViewModel 降级为纯业务逻辑容器,而将 UI 夹具降级为纯视图渲染器。
1.3 分离后的可测试性收益
当 ViewModel 不再依赖 Context、View 和 LiveData(改用 StateFlow 配合协程),UI 夹具只负责订阅和渲染时,测试体系发生质变:
| 收益方向 | 量化指标 | 改进幅度 |
|---|---|---|
| 单元测试覆盖率 | 业务逻辑类测试用例数 | 提升 200%+ |
| 运行速度 | 一次测试执行时间(毫秒) | 从 5000+ms 降至 <10ms |
| 开发反馈周期 | 从修改代码到第一次测试通过 | 缩短 80% |
| 回归保护 | 单个新功能引入的错误被发现概率 | 从 30% 提升至 95% |
| 协作冲突 | 同一文件同时被两人修改的频率 | 降低 60%(UI 与 ViewModel 分文件) |
分离后的典型测试结构如下(仅作示意,不需完整运行):
// ViewModel 不再继承任何 Android 类
data class MainUiState(val items: List<Item>, val isLoading: Boolean)
class MainViewModel(
private val repository: ItemRepository
) {
private val _state = MutableStateFlow(MainUiState())
val state: StateFlow<MainUiState> = _state.asStateFlow()
fun loadItems() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
val items = repository.fetchItems()
_state.update { it.copy(items = items, isLoading = false) }
}
}
}
// 单元测试直接构造 ViewModel,注入 FakeRepository
class MainViewModelTest {
private val fakeRepo = FakeItemRepository()
private val viewModel = MainViewModel(fakeRepo)
@Test
fun `loadItems changes state to loading then loaded`() = runTest {
val result = viewModel.state.toList()
assertEquals(result[0].isLoading, false)
viewModel.loadItems()
assertEquals(result[1].isLoading, true)
assertEquals(result[2].items.size, 3)
}
}
关键启发: 本课程后续所有章节将围绕如何构建“纯业务 ViewModel + 轻量 UI 夹具”的分离体系展开。本章的动机已证明:分离不是额外负担,而是测试质量与开发速度的杠杆。