12、ViewModel集成测试:ViewModel与Compose测试、状态流测试

ViewModel 的集成测试,说白了就是验证 ViewModel 和 Compose UI 能不能好好配合。我刚开始做 Compose 测试时,总觉得 ViewModel 测过了、UI 也测过了,集成起来应该没问题。结果呢?状态流没对齐、生命周期没处理好,各种小毛病冒出来。嗯,今天咱们就把这块彻底理清楚。

为什么需要 ViewModel 集成测试?

单元测试只能保证 ViewModel 内部逻辑正确,但 ViewModel 和 Compose 之间的「桥梁」——状态流,才是最容易出问题的地方。我个人习惯把集成测试分成两层:

  • ViewModel 内部状态流测试:验证 StateFlow 或 LiveData 的数据变化是否符合预期
  • ViewModel + Compose 联合测试:验证 UI 能否正确响应 ViewModel 的状态变化

说白了,就是既要测「数据对不对」,也要测「UI 对不对」。

ViewModel 状态流测试实战

先看一个典型的 ViewModel 实现。我在项目中经常用 StateFlow 来管理 UI 状态:

class UserViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(UserUiState())
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()

    fun loadUser(userId: String) {
        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(isLoading = true)
            try {
                val user = userRepository.getUser(userId)
                _uiState.value = UserUiState(user = user)
            } catch (e: Exception) {
                _uiState.value = UserUiState(error = e.message)
            }
        }
    }
}

data class UserUiState(
    val isLoading: Boolean = false,
    val user: User? = null,
    val error: String? = null
)

测试这个 ViewModel 的状态流,我推荐用 kotlinx-coroutines-test 库。为什么?因为它能精确控制协程的执行时机。

核心测试模式:使用 runTest + TestScope 来模拟协程环境,然后用 toList() 收集状态流的变化。

@Test
fun `loadUser should emit loading then success state`() = runTest {
    val viewModel = UserViewModel(mockUserRepository)
    
    // 收集状态流
    val states = mutableListOf<UserUiState>()
    val job = launch {
        viewModel.uiState.toList(states)
    }
    
    viewModel.loadUser("123")
    
    // 等待协程完成
    advanceUntilIdle()
    
    // 验证状态变化顺序
    assertEquals(3, states.size)
    assertTrue(states[0].isLoading)  // 初始状态
    assertTrue(states[1].isLoading)  // 加载中
    assertFalse(states[2].isLoading) // 加载完成
    assertEquals("Alice", states[2].user?.name)
    
    job.cancel()
}

我的经验:记得在测试结束后取消收集协程,否则测试会一直挂起。我曾经因为这个原因排查了半天,最后发现是协程泄漏了。

Compose 与 ViewModel 联合测试

接下来是重头戏——把 ViewModel 和 Compose UI 串起来测。这里要用到 Compose 测试框架提供的 createComposeRule

先看一个简单的 Compose 界面:

@Composable
fun UserScreen(viewModel: UserViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsState()
    
    when {
        uiState.isLoading -> CircularProgressIndicator()
        uiState.error != null -> Text("Error: ${uiState.error}")
        uiState.user != null -> Text("Hello, ${uiState.user.name}")
    }
}

测试这个界面,我们需要注入一个真实的 ViewModel 实例:

@RunWith(AndroidJUnit4::class)
class UserScreenTest {
    @get:Rule
    val composeTestRule = createComposeRule()
    
    @Test
    fun `should show user name when loaded`() {
        val viewModel = UserViewModel(mockUserRepository)
        `when`(mockUserRepository.getUser("123"))
            .thenReturn(User("Alice"))
        
        composeTestRule.setContent {
            UserScreen(viewModel = viewModel)
        }
        
        // 触发加载
        viewModel.loadUser("123")
        
        // 等待 UI 更新
        composeTestRule.waitForIdle()
        
        // 验证 UI
        composeTestRule
            .onNodeWithText("Hello, Alice")
            .assertExists()
    }
}

注意waitForIdle() 是 Compose 测试的关键。它等待所有重组完成。如果你发现测试不稳定,多半是没等够时间。

状态流测试的进阶技巧

在实际项目中,状态流往往比上面复杂得多。我遇到过最头疼的情况是:多个状态流合并、去重、防抖。这里分享几个实用模式。

模式一:测试 StateFlow 的初始值

@Test
fun `initial state should be loading`() = runTest {
    val viewModel = UserViewModel(mockUserRepository)
    
    // 直接读取初始值
    assertEquals(true, viewModel.uiState.value.isLoading)
}

模式二:测试状态流的异常情况

@Test
fun `should handle network error gracefully`() = runTest {
    val viewModel = UserViewModel(mockUserRepository)
    `when`(mockUserRepository.getUser(any()))
        .thenThrow(IOException("Network error"))
    
    viewModel.loadUser("123")
    advanceUntilIdle()
    
    // 验证错误状态
    assertNotNull(viewModel.uiState.value.error)
    assertNull(viewModel.uiState.value.user)
}

模式三:测试状态流的时序

有些场景需要验证状态变化的顺序。比如「先显示加载,再显示数据」:

@Test
fun `state flow should emit loading before data`() = runTest {
    val viewModel = UserViewModel(mockUserRepository)
    val emissions = mutableListOf<UserUiState>()
    
    val job = launch {
        viewModel.uiState.collect { emissions.add(it) }
    }
    
    viewModel.loadUser("123")
    advanceUntilIdle()
    
    // 验证时序
    assertTrue(emissions[0].isLoading)  // 初始
    assertTrue(emissions[1].isLoading)  // 加载中
    assertFalse(emissions[2].isLoading) // 完成
    
    job.cancel()
}

ViewModel 集成测试的架构图

下面这张图展示了 ViewModel 集成测试的完整流程:

ViewModel 集成测试架构 测试用例 ComposeTestRule / runTest 状态流测试 StateFlow / LiveData UI 联合测试 Compose + ViewModel 异常场景测试 错误 / 超时 / 空数据 状态变化顺序 UI 元素存在 异常状态处理 ✅ 测试通过 / ❌ 测试失败

常见坑与避坑指南

做 ViewModel 集成测试时,有几个坑我反复踩过,分享出来大家少走弯路。

坑一:忘记处理协程作用域

ViewModel 的 viewModelScope 在测试中需要特殊处理。我曾经直接用 runTest 但没注入 TestScope,结果协程根本没执行。解决方案是使用 Dispatchers.setMain 设置测试调度器。

坑二:状态流收集泄漏

如果你用 toList() 收集状态流,记得在测试结束时取消协程。我见过同事的测试跑完不取消,结果 CI 一直挂在那。加个 job.cancel() 就能解决。

我的建议:写测试时先画个状态变化图。把 ViewModel 可能经历的所有状态列出来,然后针对每个状态写测试。这样覆盖率能到 90% 以上。

总结

ViewModel 集成测试说白了就三件事:测状态流、测 UI 响应、测异常处理。我个人习惯先写状态流测试,再写 UI 联合测试。因为状态流是基础,基础稳了 UI 才不会出幺蛾子。

记住一个原则:测试要模拟真实场景,但不要模拟真实复杂度。用 Mock 代替网络请求,用 runTest 控制协程,用 waitForIdle 等待 UI 更新。做到这三点,你的 ViewModel 集成测试就能稳定可靠。


公众号:蓝海资料掘金营,微信deep3321