21、测试进阶:异步测试与等待,自定义测试规则,MockK 模拟依赖

说实话,很多人在 Jetpack Compose 里写 UI 测试,最头疼的就是异步。你明明调了个 delay(),或者等网络返回,测试就跑飞了。我刚开始搞 Compose 测试时也踩过这个坑——测试代码跑完了,UI 还没更新完,结果断言全挂。

这一章,我们就来搞定三件事:异步测试怎么等自定义测试规则怎么玩MockK 怎么模拟依赖。说白了,就是让你的测试代码能真正“等”到该等的东西,而不是瞎等或者不等。

异步测试:别让时间成为你的敌人

Compose 里最常见的异步场景是什么?LaunchedEffectdelay()、网络请求、数据库操作。这些都会让 UI 在“未来某个时刻”更新。如果你用常规的 runTest 或者 runBlockingTest,很容易翻车。

我个人习惯用 ComposeTestRule 自带的 waitUntil 系列方法。它不像 Thread.sleep() 那样死等,而是轮询检查条件,条件满足就提前返回,效率高很多。

// 等待某个文本出现,最多等 5 秒
composeTestRule.waitUntil(
    timeoutMillis = 5000,
    condition = {
        composeTestRule
            .onNodeWithText("加载完成")
            .isDisplayed()
    }
)

嗯,这里要注意:waitUntil 的 condition 是一个 lambda,它会反复执行,直到返回 true 或者超时。我在项目中遇到过一个问题——condition 里用了 onAllNodes 但没加 fetchSemanticsNodes(),结果一直返回 false。后来发现是语义树没刷新。

小技巧:如果你要等的是一个异步状态变化,比如 ViewModel 里的 StateFlow 更新,可以用 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() }

注意这里用的是 coEverycoVerify,因为 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() }
    }
}
核心要点:MockK 的 coEverycoVerify 是专门为协程设计的。如果你的函数是挂起函数,一定要用 co 前缀的版本。普通函数用 everyverify

知识体系总览

下面这张图把本章的核心逻辑串起来了。你可以看到,异步测试、自定义规则、MockK 模拟,这三者其实是互相配合的:

测试进阶:三大核心模块 异步测试与等待 自定义测试规则 MockK 模拟依赖 waitUntil / waitForIdle 协程与 recomposition 同步 TestWatcher / TestRule 封装公共初始化逻辑 coEvery / coVerify 模拟挂起函数与依赖 三者配合:自定义规则初始化 MockK 对象 异步等待验证 Mock 行为后的 UI 更新

实战:把三者组合起来

光讲理论没意思。我们来看一个完整的例子——测试一个带网络请求的登录页面。

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 状态在测试用例之间残留。另外,coEveryreturns 后面可以跟 many 关键字,让同一个 mock 方法多次调用返回不同结果,这在测试分页加载时特别有用。

避坑指南:我踩过的三个坑

  • 坑一:Thread.sleep() 等异步。千万别这么干!测试会变慢,而且不稳定。用 waitUntil 或者 waitForIdle() 才是正道。
  • 坑二:MockK 的 everycoEvery 混用。如果你的函数是挂起函数,必须用 coEvery。普通函数用 every。混用会导致 mock 不生效,测试静默通过但实际没测到。
  • 坑三:自定义规则里忘记调用 super.apply()。如果你继承 TestWatcher,一定要在 finished() 里调用 super.finished(),否则规则链会断掉。

好了,这一章的内容就到这里。异步测试、自定义规则、MockK 模拟,这三板斧用好了,你的 Compose 测试就能跑得又快又稳。下次写测试时,记得先想想:我到底在等什么?怎么等?依赖怎么模拟?想清楚了再动手。


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