4. 创建测试用的 ViewModel:手动注入 FakeRepository、暴露 UiState、验证状态提升链
4.1 原理:为什么要在测试中手动注入 FakeRepository?
后台任务 UI 测试的核心挑战是消除异步副作用的不确定性。State Hoisting 将状态提升到 ViewModel,但 ViewModel 的真实依赖(如 Repository)通常会发起网络请求、数据库读写或协程调度。在单元测试中,这些依赖必须被替换为可预测的 Fake(假实现),才能独立验证状态提升链是否正确。
手动注入(Manual Injection)是最直接的控制方式——测试代码通过构造函数传入 FakeRepository,替代生产环境中的真实实现。这样 ViewModel 内部的状态提升逻辑就完全与外部副作用解耦,测试可以精确断言每个 UiState 的转换。
4.2 实现流程概览
- 定义 UiState 数据类:作为状态提升的单一信源。
- 创建 FakeRepository:实现接口,返回预定义数据或模拟延迟。
- 手动注入到 ViewModel:在测试中直接构造 ViewModel。
- 执行触发动作:调用 ViewModel 的公开方法。
- 断言 UiState 字段:验证状态提升链的每个跳变。
关键原则:FakeRepository 必须模拟真实环境的所有状态分支(成功、失败、加载中),且不能引入真实的线程切换,确保测试在单线程中确定性运行。
4.3 代码示例:从 FakeRepository 到 ViewModel 注入
以下是一个生产环境 ViewModel 示例,它依赖 WeatherRepository 接口,并通过 StateFlow 暴露 UiState。
// UiState 定义
data class WeatherUiState(
val isLoading: Boolean = false,
val temperature: String? = null,
val errorMessage: String? = null
)
// Repository 接口
interface WeatherRepository {
suspend fun fetchTemperature(city: String): Result<Double>
}
// ViewModel 实现状态提升
class WeatherViewModel(
private val repository: WeatherRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(WeatherUiState())
val uiState: StateFlow<WeatherUiState> = _uiState.asStateFlow()
fun loadTemperature(city: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val result = repository.fetchTemperature(city)
_uiState.update {
it.copy(
isLoading = false,
temperature = result.getOrNull()?.let { "$it°C" },
errorMessage = result.exceptionOrNull()?.message
)
}
}
}
}
4.4 手动注入 FakeRepository 的步骤
| 步骤 | 操作 | 测试代码 |
|---|---|---|
| 1 | 创建 Fake 实现 | |
| 2 | 手动注入 ViewModel | |
| 3 | 收集 UiState 并触发 | |
| 4 | 断言状态链 | |
提示:使用
kotlinx-coroutines-test 的 runTest 包裹测试主体,并调用 advanceUntilIdle() 确保所有协程执行完毕,避免异步竞态。
4.5 状态提升链的验证结构图
下图展示了 FakeRepository → ViewModel → UiState 的完整数据流,以及测试如何拦截每个跳变点。
4.6 常见错误与调试
| 错误场景 | 原因 | 解决方案 |
|---|---|---|
| 测试中超时或永不结束 | FakeRepository 未设置 simulatedDelay=0,导致协程挂起 |
在 @Before 中显式设置 fakeRepo.simulatedDelay = 0 |
| UiState 断言失败,但日志显示正确 | 状态收集时序问题,drop(1) 或 take(N) 数量不匹配 |
使用 advanceUntilIdle() 确保协程执行完毕;打印收集到的完整列表 |
| ViewModel 使用了依赖注入框架,测试中自动注入失败 | 混淆了手动注入与框架注入 | 测试中只使用构造函数直接创建 ViewModel,不依赖 DI 容器 |
陷阱:不要在 FakeRepository 中真实执行
Thread.sleep(),这会导致测试变慢且不可控。使用 kotlinx.coroutines.delay 结合 TestDispatcher 才是正确做法。
4.7 性能与稳定性优化
- 重用 FakeRepository 实例:在测试类中定义
@Before方法统一初始化,避免每个测试方法重复创建。 - 使用
TestScope:为 ViewModel 传入TestScope而非viewModelScope,以便精确控制协程生命周期。 - 单线程运行:保证所有测试在
UnconfinedTestDispatcher上运行,消除线程切换开销。 - 批量断言:对于多字段 UiState,使用
assertEquals比较完整数据类实例,而非逐个字段比较,减少断言代码量。
完成第 4 章后,你将掌握:
- 手动注入 FakeRepository 到 ViewModel 的规范步骤。
- 使用 StateFlow 收集和断言 UiState 跳变链。
- 识别并修复状态提升链测试中的常见陷阱。