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 集成测试时,有几个坑我反复踩过,分享出来大家少走弯路。
坑一:忘记处理协程作用域
ViewModel 的 viewModelScope 在测试中需要特殊处理。我曾经直接用 runTest 但没注入 TestScope,结果协程根本没执行。解决方案是使用 Dispatchers.setMain 设置测试调度器。
坑二:状态流收集泄漏
如果你用 toList() 收集状态流,记得在测试结束时取消协程。我见过同事的测试跑完不取消,结果 CI 一直挂在那。加个 job.cancel() 就能解决。
我的建议:写测试时先画个状态变化图。把 ViewModel 可能经历的所有状态列出来,然后针对每个状态写测试。这样覆盖率能到 90% 以上。
总结
ViewModel 集成测试说白了就三件事:测状态流、测 UI 响应、测异常处理。我个人习惯先写状态流测试,再写 UI 联合测试。因为状态流是基础,基础稳了 UI 才不会出幺蛾子。
记住一个原则:测试要模拟真实场景,但不要模拟真实复杂度。用 Mock 代替网络请求,用 runTest 控制协程,用 waitForIdle 等待 UI 更新。做到这三点,你的 ViewModel 集成测试就能稳定可靠。
公众号:蓝海资料掘金营,微信deep3321