4. 创建测试用的 ViewModel:手动注入 FakeRepository、暴露 UiState、验证状态提升链

4.1 原理:为什么要在测试中手动注入 FakeRepository?

后台任务 UI 测试的核心挑战是消除异步副作用的不确定性。State Hoisting 将状态提升到 ViewModel,但 ViewModel 的真实依赖(如 Repository)通常会发起网络请求、数据库读写或协程调度。在单元测试中,这些依赖必须被替换为可预测的 Fake(假实现),才能独立验证状态提升链是否正确。

手动注入(Manual Injection)是最直接的控制方式——测试代码通过构造函数传入 FakeRepository,替代生产环境中的真实实现。这样 ViewModel 内部的状态提升逻辑就完全与外部副作用解耦,测试可以精确断言每个 UiState 的转换。

4.2 实现流程概览

  1. 定义 UiState 数据类:作为状态提升的单一信源。
  2. 创建 FakeRepository:实现接口,返回预定义数据或模拟延迟。
  3. 手动注入到 ViewModel:在测试中直接构造 ViewModel。
  4. 执行触发动作:调用 ViewModel 的公开方法。
  5. 断言 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 实现
class FakeWeatherRepository : WeatherRepository {
    var shouldSucceed = true
    var simulatedDelay = 0L
    override suspend fun fetchTemperature(city: String): Result<Double> {
        delay(simulatedDelay) // 用 delay 模拟异步,但测试中可设为 0
        return if (shouldSucceed) {
            Result.success(22.5)
        } else {
            Result.failure(Exception("Network error"))
        }
    }
}
2 手动注入 ViewModel
val fakeRepo = FakeWeatherRepository()
fakeRepo.shouldSucceed = true
val viewModel = WeatherViewModel(fakeRepo)
3 收集 UiState 并触发
val stateCollector = viewModel.uiState
    .drop(1) // 跳过初始状态
    .take(2) // 加载中 + 结果
    .toList()
viewModel.loadTemperature("Beijing")
4 断言状态链
assertEquals(WeatherUiState(isLoading = true), stateCollector[0])
assertEquals(
    WeatherUiState(isLoading = false, temperature = "22.5°C"),
    stateCollector[1]
)
提示:使用 kotlinx-coroutines-test 的 runTest 包裹测试主体,并调用 advanceUntilIdle() 确保所有协程执行完毕,避免异步竞态。

4.5 状态提升链的验证结构图

下图展示了 FakeRepository → ViewModel → UiState 的完整数据流,以及测试如何拦截每个跳变点。

状态提升链验证结构图 状态提升链验证: FakeRepository → ViewModel → UiState FakeRepository 预定义数据 / 延迟控制 ViewModel 状态提升逻辑 UiState Flow isLoading, data, error 测试断言① 初始 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 跳变链。
  • 识别并修复状态提升链测试中的常见陷阱。