21、测试进阶:异步测试与等待,自定义测试规则,MockK 模拟依赖
说实话,很多人在 Jetpack Compose 里写 UI 测试,最头疼的就是异步。你明明调了个 delay(),或者等网络返回,测试就跑飞了。我刚开始搞 Compose 测试时也踩过这个坑——测试代码跑完了,UI 还没更新完,结果断言全挂。
这一章,我们就来搞定三件事:异步测试怎么等、自定义测试规则怎么玩、MockK 怎么模拟依赖。说白了,就是让你的测试代码能真正“等”到该等的东西,而不是瞎等或者不等。
异步测试:别让时间成为你的敌人
Compose 里最常见的异步场景是什么?LaunchedEffect、delay()、网络请求、数据库操作。这些都会让 UI 在“未来某个时刻”更新。如果你用常规的 runTest 或者 runBlockingTest,很容易翻车。
我个人习惯用 ComposeTestRule 自带的 waitUntil 系列方法。它不像 Thread.sleep() 那样死等,而是轮询检查条件,条件满足就提前返回,效率高很多。
// 等待某个文本出现,最多等 5 秒
composeTestRule.waitUntil(
timeoutMillis = 5000,
condition = {
composeTestRule
.onNodeWithText("加载完成")
.isDisplayed()
}
)
嗯,这里要注意:waitUntil 的 condition 是一个 lambda,它会反复执行,直到返回 true 或者超时。我在项目中遇到过一个问题——condition 里用了 onAllNodes 但没加 fetchSemanticsNodes(),结果一直返回 false。后来发现是语义树没刷新。
waitForIdle()。它会等待 Compose 的 recomposition 完成。但注意,它只等 Compose 内部的任务,不等协程里的 delay。
自定义测试规则:把重复代码装进“盒子”里
每个测试类里都写一遍 createComposeRule()、初始化 ViewModel、注入依赖?太累了。我一般会写一个自定义的测试规则,把这些公共逻辑封装起来。
自定义规则其实很简单,继承 TestWatcher 或者实现 TestRule 接口就行。但 Compose 测试里,我们通常继承 AndroidComposeTestRule 或者直接组合 ComposeTestRule。
class MyComposeTestRule(
private val viewModel: MyViewModel = MyViewModel()
) : TestRule {
override fun apply(base: Statement, description: Description): Statement {
return object : Statement() {
override fun evaluate() {
// 初始化工作
println("测试开始:${description.methodName}")
base.evaluate()
// 清理工作
println("测试结束:${description.methodName}")
}
}
}
}
但说实话,我更推荐用 ComposeTestRule 的扩展函数来做。比如这样:
fun ComposeTestRule.setupWithViewModel(
viewModel: MyViewModel,
content: @Composable () -> Unit
) {
setContent {
CompositionLocalProvider(
LocalViewModel provides viewModel
) {
content()
}
}
}
这样每个测试用例里只需要一行 composeTestRule.setupWithViewModel(viewModel) { ... },清爽多了。
setContent,记得在 @After 或者 finish() 里清理掉,否则多个测试用例之间会互相污染。我曾经因为这个排查了半天,发现第二个测试用例里居然出现了第一个用例的 UI 元素。
MockK 模拟依赖:让测试“断网”也能跑
测试里最怕什么?网络请求、数据库、传感器这些外部依赖。你总不能每次跑测试都连服务器吧?所以我们需要 Mock(模拟)。
在 Kotlin 世界里,MockK 是首选。它比 Mockito 更懂 Kotlin 的特性,比如协程、扩展函数、空安全。
先看一个最简单的例子:
// 假设我们有一个 Repository
class UserRepository {
suspend fun getUser(): User = // 真实网络请求
}
// 测试里用 MockK 模拟它
val mockRepo = mockk<UserRepository>()
// 定义行为
coEvery { mockRepo.getUser() } returns User("测试用户", 18)
// 验证调用
coVerify { mockRepo.getUser() }
注意这里用的是 coEvery 和 coVerify,因为 getUser() 是挂起函数。如果你用普通的 every,编译能过,但运行时会报错。我刚开始就犯过这个错,还以为是 MockK 版本问题。
再来看一个更贴近 Compose 的场景——模拟 ViewModel 里的依赖:
class MyViewModelTest {
@get:Rule
val composeTestRule = createComposeRule()
private val mockRepo = mockk<UserRepository>()
@Before
fun setup() {
// 每次测试前重置 mock
clearMocks(mockRepo)
}
@Test
fun testUserDisplay() {
// 模拟返回数据
coEvery { mockRepo.getUser() } returns User("小明", 25)
val viewModel = MyViewModel(mockRepo)
composeTestRule.setContent {
UserScreen(viewModel)
}
// 等待异步加载完成
composeTestRule.waitUntil(
timeoutMillis = 3000,
condition = {
composeTestRule
.onNodeWithText("小明")
.isDisplayed()
}
)
// 验证调用了 getUser
coVerify(exactly = 1) { mockRepo.getUser() }
}
}
coEvery 和 coVerify 是专门为协程设计的。如果你的函数是挂起函数,一定要用 co 前缀的版本。普通函数用 every 和 verify。
知识体系总览
下面这张图把本章的核心逻辑串起来了。你可以看到,异步测试、自定义规则、MockK 模拟,这三者其实是互相配合的:
实战:把三者组合起来
光讲理论没意思。我们来看一个完整的例子——测试一个带网络请求的登录页面。
class LoginScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
private val mockAuthRepo = mockk<AuthRepository>()
@Before
fun setup() {
clearMocks(mockAuthRepo)
// 自定义规则:每次测试前注入 mock
composeTestRule.setContent {
CompositionLocalProvider(
LocalAuthRepository provides mockAuthRepo
) {
LoginScreen()
}
}
}
@Test
fun loginSuccess_showsWelcomeMessage() {
// 模拟登录成功
coEvery { mockAuthRepo.login("test@test.com", "123456") } returns Result.success(User("测试用户"))
// 执行 UI 操作
composeTestRule.onNodeWithText("登录").performClick()
// 等待异步结果
composeTestRule.waitUntil(
timeoutMillis = 5000,
condition = {
composeTestRule
.onNodeWithText("欢迎回来,测试用户")
.isDisplayed()
}
)
// 验证调用
coVerify(exactly = 1) { mockAuthRepo.login("test@test.com", "123456") }
}
@Test
fun loginFailure_showsError() {
coEvery { mockAuthRepo.login(any(), any()) } returns Result.failure(Exception("网络错误"))
composeTestRule.onNodeWithText("登录").performClick()
composeTestRule.waitUntil {
composeTestRule
.onNodeWithText("网络错误")
.isDisplayed()
}
}
}
你看,整个测试流程非常清晰:Mock 依赖 → 设置 UI → 执行操作 → 等待异步 → 验证结果。每一步都有对应的工具方法,不需要自己写轮询或者 sleep。
@Before 里统一调用 clearMocks(),避免 mock 状态在测试用例之间残留。另外,coEvery 的 returns 后面可以跟 many 关键字,让同一个 mock 方法多次调用返回不同结果,这在测试分页加载时特别有用。
避坑指南:我踩过的三个坑
- 坑一:用
Thread.sleep()等异步。千万别这么干!测试会变慢,而且不稳定。用waitUntil或者waitForIdle()才是正道。 - 坑二:MockK 的
every和coEvery混用。如果你的函数是挂起函数,必须用coEvery。普通函数用every。混用会导致 mock 不生效,测试静默通过但实际没测到。 - 坑三:自定义规则里忘记调用
super.apply()。如果你继承TestWatcher,一定要在finished()里调用super.finished(),否则规则链会断掉。
好了,这一章的内容就到这里。异步测试、自定义规则、MockK 模拟,这三板斧用好了,你的 Compose 测试就能跑得又快又稳。下次写测试时,记得先想想:我到底在等什么?怎么等?依赖怎么模拟?想清楚了再动手。