用途
本技能用于在 JVM 上测试挂起函数(suspend fun)以及内部使用协程的类,依赖 kotlinx-coroutines-test。它聚焦于 runTest 这一入口点,讲解 TestScope 契约、两种 TestDispatcher、共享的 TestCoroutineScheduler 虚拟时钟,以及让 Dispatchers.setMain 不在测试之间泄漏的 MainDispatcherRule 接线方式。Flow 相关的断言不在本技能范围内,被指向 ../testing-flows-with-turbine/SKILL.md。
适用情形包括:被测类暴露 suspend fun 或向 viewModelScope / lifecycleScope 启动协程;使用 runBlocking { … } 导致测试挂起、或 delay 让测试变慢;需要断言 Loading -> Success 等中间状态;构建报出 runBlockingTest 已废弃的提示;测试抛出 IllegalStateException: Module with the Main dispatcher is missing;需要用 advanceTimeBy(5.seconds)、advanceUntilIdle() 推进虚拟时间以触发超时、重试或 debounce。
不适用情形:断言 Flow 跨时间的发射(改用 Turbine);使用 Compose 的 MainTestClock;在模拟器或设备上通过 AndroidJUnit4 运行测试。
前置条件
在 Gradle 中通过 testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.x") 引入,必须是 testImplementation,不能是 implementation,因为该库会安装不适合发布到生产环境的 Dispatchers.setMain 接线。
测试文件中需要 @file:OptIn(ExperimentalCoroutinesApi::class)(或按调用点标注),因为 currentTime、advanceTimeBy、advanceUntilIdle、runCurrent 以及调度器构造函数在 1.10.x 上仍是 @ExperimentalCoroutinesApi。
ViewModel 测试需要可注入的 CoroutineDispatcher(DispatcherProvider),使被测对象把工作路由到测试体所推进的同一个 TestCoroutineScheduler;硬编码的 Dispatchers.IO / Dispatchers.Default 无法感知虚拟时间。
对于编译到 JS / wasm 的 KMP 库,应使用单表达式形式 fun foo() = runTest { … } 以便返回平台的 TestResult。
runTest 签名
public fun runTest(context: CoroutineContext = EmptyCoroutineContext, timeout: Duration = 60.seconds, testBody: suspend TestScope.() -> Unit): TestResult
要点:timeout 是整场测试的截止时间,不是单次调度的静默超时(已废弃的 dispatchTimeoutMs 重载语义不同);testBody 返回后,runTest 会等待所有在 TestScope 上启动的子协程完成,而 backgroundScope 上的子协程会在测试结束时自动取消,适用于热流收集器;子协程中未捕获的异常会被聚合,在测试结束时作为单个失败重新抛出。
TestScope 成员
sealed interface TestScope : CoroutineScope 提供 testScheduler(共享虚拟时钟)、backgroundScope(测试结束时自动取消子协程)、currentTime(已流逝的虚拟毫秒)、testTimeSource;以及扩展方法 advanceTimeBy(delay)、advanceTimeBy(durationMillis)、advanceUntilIdle()、runCurrent()。
两种 TestDispatcher
StandardTestDispatcher:把续体排队到调度器上,在调用 runCurrent() / advanceTimeBy(...) / advanceUntilIdle() 之前什么都不运行;它是 runTest 的默认选择,适用于断言中间状态、精确控制虚拟时间、无竞态复现。UnconfinedTestDispatcher:立即执行,launch { } 会同步运行到第一个真正的挂起点;适合需要第一次发射即被观察到的热 StateFlow / SharedFlow 收集器搭建。默认应使用 StandardTestDispatcher,仅在收集器即时性确实重要时使用 UnconfinedTestDispatcher。
虚拟时钟 API
TestCoroutineScheduler 是测试中所有 TestDispatcher 共享的虚拟时间来源:runCurrent() 排空当前时间已到期的任务但不推进时钟;advanceTimeBy(delta) 把 currentTime 推进 delta 并运行所有到期任务;advanceUntilIdle() 循环 runCurrent 与 advance 直到队列为空;currentTime 为只读的已流逝虚拟毫秒。特别提醒:Dispatchers.IO、Dispatchers.Default 以及 newSingleThreadContext(...) 是真实线程池,不参与虚拟时钟,其中的 delay() 使用真实时间,解决办法是依赖注入 CoroutineDispatcher。
Dispatchers.setMain / resetMain
Android 的 Dispatchers.Main 基于主 Looper 的 HandlerContext,在 JVM 单元测试中不存在,任何经 viewModelScope、lifecycleScope 或 flowOn(Dispatchers.Main) 启动的工作若未调用 Dispatchers.setMain,都会抛出 Main dispatcher is missing 异常。技能给出了 androidx 的 MainDispatcherRule 原文实现(在 try/finally 中 setMain 与 resetMain),以及基于 TestWatcher 的 MainCoroutineRule(可在 starting/finished 中 setMain/resetMain 并对外暴露 dispatcher)。使用示例中,测试类声明 @get:Rule val mainRule = MainCoroutineRule(),并以 runTest(mainRule.dispatcher) 作为测试体,先 runCurrent() 断言 Loading,再 advanceUntilIdle() 断言 Success。
TestResult 契约
TestResult 在 JVM 与 Native 上为 Unit,在 Kotlin/JS 与 wasm 上为 Promise<Unit>。因此纯 Android 项目可以写 @Test fun foo() { runTest { … } },而 KMP 模块必须写单表达式形式 @Test fun foo() = runTest { … },否则 JS 框架无法等待测试。推荐始终使用单表达式形式。
从 runBlockingTest 迁移
runBlockingTest 替换为 runTest;TestCoroutineScope 替换为 TestScope;TestCoroutineDispatcher 替换为 StandardTestDispatcher / UnconfinedTestDispatcher;pauseDispatcher/resumeDispatcher 改为显式 runCurrent / advanceTimeBy;cleanupTestCoroutines() 不再需要;runTest(... dispatchTimeoutMs = X) 改为 runTest(... timeout = X.milliseconds) 且语义已变。runBlockingTest 以 WARNING 废弃,dispatchTimeoutMs 重载以 ERROR 废弃。
典型反模式
技能给出多组错误与正确写法对照:用 runBlocking 测试挂起函数(无虚拟时间、无异常聚合、用 Thread.sleep 等待);推进虚拟时间之前就断言,导致测试因错误原因通过;生产代码在 withContext(Dispatchers.IO) 中 delay,导致真实等待 2 秒且 advanceTimeBy 无法跳过,应改为注入 CoroutineDispatcher;在 TestScope 上直接 collect 热 StateFlow 导致永不返回并挂到 60 秒超时,应使用 backgroundScope 的 launchIn 或 Turbine;以及「两个 scheduler」陷阱——runTest 默认自建 StandardTestDispatcher 与新的 TestCoroutineScheduler,而 mainRule 的调度器属于另一个 scheduler,advanceUntilIdle() 只排空测试体的 scheduler,解决办法是显式把 mainRule.dispatcher 传入 runTest。
强制规则与验证
必须使用 runTest,不得使用 runBlocking 或已废弃的 runBlockingTest;kotlinx-coroutines-test 只能放在 testImplementation;使用 currentTime、advanceTimeBy、advanceUntilIdle、runCurrent 或调度器构造函数前必须应用 @file:OptIn(ExperimentalCoroutinesApi::class);ViewModel/viewModelScope 测试必须安装 Dispatchers.setMain 并在 finally / @After 中重置;必须把 mainRule.dispatcher 传入 runTest 以共享同一 scheduler;生产代码中的 IO/Default 调度器必须可注入;热流收集器必须启动在 TestScope.backgroundScope 或使用 Turbine;在调用 advanceUntilIdle()(或 runCurrent())之前不得断言状态;不得依赖已废弃的 dispatchTimeoutMs;推荐使用单表达式形式、默认 StandardTestDispatcher、并在测试类路径加入 kotlinx-coroutines-debug 以便超时时输出协程栈。验证方式包括:模块测试通过且无 Main dispatcher 缺失异常;测试源码中不再有 runBlocking[^T]、runBlockingTest、dispatchTimeoutMs;每个 ViewModel 测试类都声明 @get:Rule MainCoroutineRule 并把其 dispatcher 传入 runTest;所有热流收集器都使用 backgroundScope 或 Turbine;使用 IO/Default 的生产类接受可注入的 CoroutineDispatcher。