关于 testing-coroutines-with-runtest
该技能面向 Kotlin 开发者,介绍使用 kotlinx.coroutines.test 的 runTest 测试挂起函数和 ViewModel 协程。内容涵盖虚拟时间、测试调度器、Main dispatcher 配置及从 runBlockingTest 迁移。
主要功能
- 01使用 runTest 测试挂起函数
- 02通过虚拟时钟控制协程时间
- 03配置 Standard 与 Unconfined 测试调度器
- 04为 ViewModel 设置 Main dispatcher
- 05从 runBlockingTest 迁移
适用场景
- 01测试延迟、重试或防抖逻辑
- 02断言 ViewModel 的中间状态变化
- 03避免热流收集导致测试挂起
- 04为 Kotlin 多平台测试返回 TestResult
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill testing-coroutines-with-runtest安装后在 Claude Code 中输入/testing-coroutines-with-runtest确认已加载。
详细介绍
使用 runTest 测试协程 — 无需等待实际时间的虚拟时钟
在 JVM 上测试挂起函数和由 viewModelScope 支撑的代码时,runTest 是唯一正确的入口。本技能明确了 TestScope 契约、两种 TestDispatcher 类型、共享的 TestCoroutineScheduler 虚拟时钟,以及用于防止 Dispatchers.setMain 在测试间泄漏的 MainDispatcherRule 配置。针对 Flow 的断言见 ../testing-flows-with-turbine/SKILL.md。
何时使用本技能
- 被测类公开了一个
suspend fun,或会在viewModelScope/lifecycleScope中启动协程。 - 开发者尝试使用
runBlocking { … },结果测试挂起;或者 10 分钟的delay让测试变得很慢。 - ViewModel 在协程中发出
Loading -> Success,测试需要断言每个中间状态。 - 构建输出弃用提示
runBlockingTest is deprecated. Use runTest,或报错runTest(... dispatchTimeoutMs = ...)。 - 测试失败并报错
IllegalStateException: Module with the Main dispatcher is missing,原因是viewModelScope通过Dispatchers.Main路由任务。 - 开发者需要推进虚拟时间(
advanceTimeBy(5.seconds)、advanceUntilIdle())来触发超时 / 重试 / 防抖。
何时不要使用本技能
- 测试需要断言随时间变化的
Flow发射结果。请使用../testing-flows-with-turbine/SKILL.md(Turbine 会处理取消、热流与冷流,以及awaitItem/awaitComplete)。 - 测试使用的是 Compose 的
MainTestClock,而不是kotlinx.coroutines.test。请使用../../../compose/synchronization/controlling-the-test-clock/SKILL.md。 - 测试通过 AndroidJUnit4 在模拟器 / 设备上运行。JUnit4 本身的配置见
../../runner/configuring-junit4-on-android/SKILL.md。
前置条件
- Gradle:
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.x")(docs/CORPUS.md§G.1)。必须仅使用testImplementation,绝不能使用implementation。该库会配置Dispatchers.setMain,不安全,不应随产品代码发布。 - 在测试文件中使用
@file:OptIn(ExperimentalCoroutinesApi::class)(或针对单次调用使用)。在 1.10.x 中,currentTime、advanceTimeBy、advanceUntilIdle、runCurrent和调度器构造函数仍标记为@ExperimentalCoroutinesApi(R5)。 - 对于 ViewModel 测试,应注入
CoroutineDispatcher(即“DispatcherProvider”),以便 SUT 能将任务路由到与测试主体推进的同一个TestCoroutineScheduler。硬编码的Dispatchers.IO/Dispatchers.Default无法使用虚拟时间。 - 对于编译为 JS / wasm 的 KMP 库,请使用单表达式形式
fun foo() = runTest { … },以便返回平台的TestResult(R5;见下文“TestResult 契约”)。
runTest 签名
public fun runTest(
context: CoroutineContext = EmptyCoroutineContext,
timeout: Duration = 60.seconds,
testBody: suspend TestScope.() -> Unit
): TestResult
(docs/CORPUS.md §G.4。)需要牢记三点:
timeout是整个测试的截止时间,而非每次调度静止时的超时时间。已弃用的dispatchTimeoutMs重载语义不同,详见下文的迁移说明。testBody返回后,runTest会等待所有在TestScope上启动的子协程完成。在TestScope.backgroundScope上启动的子协程则会在测试结束时自动取消。热流收集器应使用backgroundScope。- 子协程中未捕获的异常会被汇总,并在测试结束时作为单个失败重新抛出。
TestScope 成员
sealed interface TestScope : CoroutineScope {
val testScheduler: TestCoroutineScheduler // shared virtual clock
val backgroundScope: CoroutineScope // children auto-cancelled at test end
val currentTime: Long // virtual ms elapsed
val testTimeSource: TimeSource.WithComparableMarks
}
另外还有 TestScope 的库扩展:advanceTimeBy(delay)、advanceTimeBy(durationMillis)、advanceUntilIdle()、runCurrent()。(R5。)
TestDispatcher 真值表
| 调度器 | 行为 | 适用场景 |
|---|---|---|
StandardTestDispatcher | 在调度器上排队等待恢复的协程。直到调用 runCurrent()/advanceTimeBy(...)/advanceUntilIdle() 才会运行任何内容。runTest 的默认调度器。 | 断言中间状态(Loading -> Success);精确控制虚拟时间;无竞态地复现问题。 |
UnconfinedTestDispatcher | 即时执行:launch { } 会同步运行,直到遇到第一个真正的挂起点。 | 设置热 StateFlow/SharedFlow 收集器,以便无需显式调用 runCurrent() 就能观察到首次发射。 |
(docs/CORPUS.md §G.5。)
默认使用 StandardTestDispatcher。 只有在确实需要收集器即时执行,且能说明理由时才使用 UnconfinedTestDispatcher;它会掩盖生产代码在 Dispatchers.Default 下会暴露出来的顺序错误。
TestCoroutineScheduler — 虚拟时钟
TestCoroutineScheduler 是参与测试的所有 TestDispatcher 共享的唯一虚拟时间来源。
| API | 作用 |
|---|---|
runCurrent() | 执行当前 currentTime 时刻已到期的任务。不会推进时钟。 |
advanceTimeBy(delta) | 将 currentTime 推进 delta,并运行所有截止时间已到的任务。 |
advanceUntilIdle() | 循环调用 runCurrent 并推进时间,直到队列为空。也就是“运行所有任务直至完成”。 |
currentTime | 已经过的虚拟毫秒数,只读。 |
(R5。)
关键: Dispatchers.IO、Dispatchers.Default 和任何 newSingleThreadContext(...) 都是真实线程池,不参与虚拟时钟。在 withContext(Dispatchers.IO) 中调用的 delay() 使用真实墙上时钟。解决方法是依赖注入——传入一个 CoroutineDispatcher,并在测试中替换为 mainRule.dispatcher(或 StandardTestDispatcher(testScheduler))。
Dispatchers.setMain / resetMain — ViewModel 必需
Android 的 Dispatchers.Main 是基于主 Looper 的 HandlerContext,在 JVM 单元测试中并不存在。如果没有调用 Dispatchers.setMain,通过 viewModelScope、lifecycleScope 或 flowOn(Dispatchers.Main) 启动的任何内容都会抛出 IllegalStateException: Module with the Main dispatcher is missing。
规范 MainDispatcherRule(androidx)
摘自 androidx/testutils/testutils-ktx/src/jvmMain/kotlin/androidx/testutils/MainDispatcherRule.jvm.kt(docs/CORPUS.md §G.3):
class MainDispatcherRule(
private val dispatcher: CoroutineDispatcher,
) : TestRule {
@OptIn(ExperimentalCoroutinesApi::class)
override fun apply(base: Statement?, description: Description?) =
object : Statement() {
override fun evaluate() {
Dispatchers.setMain(dispatcher)
try { base!!.evaluate() } finally { Dispatchers.resetMain() }
}
}
}
TestWatcher 形式 — 需要公开调度器时使用
@ExperimentalCoroutinesApi
class MainCoroutineRule(
val dispatcher: TestDispatcher = StandardTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description?) {
super.starting(description); Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description?) {
super.finished(description); Dispatchers.resetMain()
}
}
用法
@OptIn(ExperimentalCoroutinesApi::class)
class MyVmTest {
@get:Rule val mainRule = MainCoroutineRule()
@Test fun loadsItems() = runTest(mainRule.dispatcher) {
val vm = MyViewModel(repo, mainRule.dispatcher)
vm.load()
runCurrent() // see Loading state
assertEquals(Loading, vm.state.value)
advanceUntilIdle() // run network/db
assertEquals(Success(items), vm.state.value)
}
}
TestResult 契约 — JVM 与 JS
public expect class TestResult
| 平台 | 实际类型 | 调用方义务 |
|---|---|---|
| JVM | Unit | 任意写法均可。 |
| Native | Unit | 同上。 |
| Kotlin/JS,wasm | Promise<Unit> | 测试函数必须 return runTest { … }。 |
(R5。)
对于仅支持 Android 的项目,可以写 @Test fun foo() { runTest { … } },因为 TestResult 会折叠为 Unit。对于 KMP 模块,这样会吞掉 Promise<Unit>,JS 框架无法等待测试完成——每次都应写成 @Test fun foo() = runTest { … }(单表达式形式)。**推荐:**始终使用单表达式形式,以免模块日后支持 KMP 目标平台时出现意外。
从 runBlockingTest 迁移
| 1.5.x 及更早版本 | 1.7+ |
|---|---|
runBlockingTest { … } | runTest { … } |
TestCoroutineScope | TestScope |
TestCoroutineDispatcher | StandardTestDispatcher / UnconfinedTestDispatcher |
pauseDispatcher { } / resumeDispatcher | 已移除——显式调用 runCurrent / advanceTimeBy |
cleanupTestCoroutines() | 不再需要——runTest 会处理清理工作 |
runTest(... dispatchTimeoutMs = X) { … } | runTest(... timeout = X.milliseconds) { … }(语义已变更) |
runBlockingTest 已弃用,级别为 WARNING;dispatchTimeoutMs 重载已弃用,级别为 ERROR(R5;docs/CORPUS.md §G.2)。迁移替换本身是机械性的,但超时语义并不完全相同:旧的 dispatchTimeoutMs 表示每次分发静止等待的时限,新的 timeout 则是整个测试的截止时间。
模式
错误模式:错误——用 runBlocking 测试挂起函数
// WRONG
@Test fun loadsItems() = runBlocking {
val vm = MyViewModel(repo)
vm.load()
Thread.sleep(2_000) // wait for the launch
assertEquals(Success(items), vm.state.value)
}
// WRONG because: runBlocking has no virtual time and no exception aggregation. The
// delay in load() runs in real time; the launch may not have completed; uncaught
// exceptions in children are silently swallowed.
// RIGHT
@Test fun loadsItems() = runTest(mainRule.dispatcher) {
val vm = MyViewModel(repo, mainRule.dispatcher)
vm.load()
advanceUntilIdle() // virtual time flushes everything
assertEquals(Success(items), vm.state.value)
}
错误模式:错误——推进前就断言
// WRONG
@Test fun loadsItems() = runTest(mainRule.dispatcher) {
val vm = MyViewModel(repo, mainRule.dispatcher)
vm.load()
assertEquals(emptyList(), vm.state.value.items) // "passes" trivially
}
// WRONG because: with StandardTestDispatcher (the default), nothing runs until you
// advance. The launch inside vm.load() is queued but has not executed. The test
// passes for the wrong reason — the items list is empty because load() never ran.
// RIGHT
@Test fun loadsItems() = runTest(mainRule.dispatcher) {
val vm = MyViewModel(repo, mainRule.dispatcher)
vm.load()
advanceUntilIdle()
assertEquals(items, vm.state.value.items)
}
错误模式:错误——在 runTest 下使用 withContext(Dispatchers.IO)
// WRONG: production code
class Repo {
suspend fun fetch() = withContext(Dispatchers.IO) {
delay(2_000) // real wall-clock time
api.fetch()
}
}
// WRONG because: Dispatchers.IO is a real thread pool. delay(2_000) takes 2 seconds
// of real time. advanceTimeBy(2_000) does NOT skip it. The whole test runs in real
// time and may exceed the 60s runTest timeout.
// RIGHT: production code
class Repo(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
suspend fun fetch() = withContext(ioDispatcher) {
delay(2_000)
api.fetch()
}
}
// RIGHT: test
@Test fun fetchesAfterTwoSeconds() = runTest(mainRule.dispatcher) {
val repo = Repo(ioDispatcher = mainRule.dispatcher) // shares scheduler
val deferred = async { repo.fetch() }
advanceTimeBy(2_001) // virtual; instant in real time
assertEquals(expected, deferred.await())
}
错误模式:错误——在 TestScope 上收集(测试会挂起)
// WRONG
@Test fun observes() = runTest {
val seen = mutableListOf<Int>()
vm.uiState.collect { seen += it } // never returns
assertEquals(listOf(0, 1), seen) // unreachable
}
// WRONG because: vm.uiState is a hot StateFlow. collect on the test's TestScope
// never completes; runTest waits for child completion and hangs until the 60s
// timeout fires. Use backgroundScope (auto-cancelled at end of test) — or Turbine.
// RIGHT — backgroundScope
@Test fun observes() = runTest(mainRule.dispatcher) {
val seen = mutableListOf<Int>()
vm.uiState
.onEach { seen += it }
.launchIn(backgroundScope)
advanceUntilIdle()
vm.refresh()
advanceUntilIdle()
assertEquals(listOf(0, 1), seen)
}
对于 Flow 断言,优先使用 Turbine——参见 ../testing-flows-with-turbine/SKILL.md。
错误模式:“两个调度器”陷阱
// WRONG
@get:Rule val mainRule = MainCoroutineRule() // scheduler A
@Test fun broken() = runTest { // scheduler B (auto-created)
val vm = MyViewModel(repo, mainRule.dispatcher) // posts to A
vm.load()
advanceUntilIdle() // flushes B only — A's work pending
assertEquals(Success(items), vm.state.value) // FAIL: still Loading
}
// WRONG because: by default runTest creates its own StandardTestDispatcher with a
// fresh TestCoroutineScheduler. mainRule's dispatcher has a different scheduler;
// advanceUntilIdle() only flushes the test body's scheduler. (Recent kotlinx-
// coroutines-test versions do try to reuse Main's scheduler when Main was replaced
// with a TestDispatcher BEFORE runTest starts, but the defensive contract — pass
// the dispatcher explicitly — never breaks across versions.)
// RIGHT — share the scheduler explicitly
@Test fun ok() = runTest(mainRule.dispatcher) {
val vm = MyViewModel(repo, mainRule.dispatcher)
vm.load()
advanceUntilIdle()
assertEquals(Success(items), vm.state.value)
}
(R5 易错点 #4。)
强制规则
- 必须使用
runTest { … },而不是runBlocking { … }或已弃用的runBlockingTest { … }。 - 必须仅通过
testImplementation依赖kotlinx-coroutines-test。不得将其放入implementation。 - 必须在使用
currentTime、advanceTimeBy、advanceUntilIdle、runCurrent、StandardTestDispatcher(...)或UnconfinedTestDispatcher(...)前应用@file:OptIn(ExperimentalCoroutinesApi::class)(或逐次调用)。 - 必须为任何 ViewModel/
viewModelScope测试安装Dispatchers.setMain(testDispatcher)(通过MainDispatcherRule/MainCoroutineRule)。在规则的 finally 块或@After中重置。 - 必须将
mainRule.dispatcher传入runTest(...),使TestScope和 Main 共用一个调度器。否则,advanceUntilIdle()只会清空其中一个。 - 必须注入生产代码用于 IO/Default 工作的任何
CoroutineDispatcher。硬编码的Dispatchers.IO即使在runTest下也会按实际时间运行。 - 必须在
TestScope.backgroundScope上启动热流收集器(或使用 Turbine)。绝不要直接在TestScope上调用collect——测试会一直挂起,直到 60 s 超时。 - 不得在未先调用
advanceUntilIdle()(或针对“此刻”的微任务队列调用runCurrent())时断言状态。使用StandardTestDispatcher时,排队的工作尚未运行。 - 不得依赖
runTest(... dispatchTimeoutMs = X)——该参数已弃用,且会报错。请使用timeout = X.milliseconds;请注意语义已变更(这是整个测试的截止时间,而非每次调度达到静止状态的时间)。 - **首选:**使用单表达式形式
@Test fun foo() = runTest { … },以确保 KMP 在 JS/wasm 上正确运行,此时TestResult为Promise<Unit>。 - **首选:**默认使用
StandardTestDispatcher。仅在需要急切执行的热流收集器初始化场景中使用UnconfinedTestDispatcher。 - **首选:**在测试类路径中添加
kotlinx-coroutines-debug,以便runTest超时时包含协程堆栈转储。
验证
-
./gradlew :module:test运行通过,且未出现Module with the Main dispatcher is missing异常。 - 测试源代码中不再有
runBlocking { … }或runBlockingTest { … }调用(grep -rn 'runBlocking[^T]' src/test;grep -rn 'runBlockingTest' src/test)。 - 不再有
dispatchTimeoutMs参数(grep -rn 'dispatchTimeoutMs' src/test)。 - 每个 ViewModel/
viewModelScope测试类都声明了@get:Rule MainCoroutineRule(或等效规则),并将其dispatcher传入runTest(...)。 - 测试中的每个热流收集器都使用
backgroundScope或flow.test { … }(Turbine),绝不在TestScope上直接调用collect。 - 使用
Dispatchers.IO/Dispatchers.Default的生产类都接受可注入的CoroutineDispatcher,且测试会将其替换为mainRule.dispatcher。 - 每个使用
advanceTimeBy/advanceUntilIdle/runCurrent/currentTime的测试文件都包含@file:OptIn(ExperimentalCoroutinesApi::class)。 - KMP 模块(commonTest)使用
fun foo() = runTest { … }单表达式形式。
参考资料
- JetBrains — kotlinx.coroutines.test:https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/
- JetBrains — runTest:https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/run-test.html
- JetBrains — TestDispatcher:https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-test-dispatcher/
- Android Developers — 在 Android 上测试 Kotlin 协程:https://developer.android.com/kotlin/coroutines/test
- Android Developers 博客 — Android 上的协程,第 III 部分(测试):https://medium.com/androiddevelopers/coroutines-on-android-part-iii-real-work-2ba8a2ec2f45
androidx/testutils/testutils-ktx/src/jvmMain/kotlin/androidx/testutils/MainDispatcherRule.jvm.kt— 标准的MainDispatcherRule(docs/CORPUS.md§G.3)。androidx/pdf/.../MainCoroutineRule.kt— 可公开调度器的TestWatcher变体。androidx/navigationevent/...NavigationEventDispatcherTest.kt—runTest(UnconfinedTestDispatcher())+launchIn(backgroundScope)模式。androidx/room3/.../InvalidationTrackerTest.kt— 将TestScope().coroutineContext注入 SUT。- 同类文档:
../testing-flows-with-turbine/SKILL.md— Flow 断言、awaitItem/awaitComplete、热流与冷流的取消。 - 同类文档:
../../mocking/mocking-with-mockito/SKILL.md— 使用 mockito-kotlin 模拟挂起函数。 - 同类文档:
../../mocking/mocking-with-mockk/SKILL.md— 针对挂起函数使用coEvery/coVerify。 - 同类文档:
../../runner/configuring-junit4-on-android/SKILL.md—runTest周边的 JUnit4 配置。 - 同类文档:
../../robolectric/using-robolectric-correctly/SKILL.md— SUT 需要在 JVM 上使用 Android 框架时。 - 跨类别:
../../../fundamentals/strategies/applying-testing-strategies/SKILL.md— 小型/中型/大型测试的规模划分。 - 跨类别:
../../../fundamentals/strategies/organizing-test-source-sets/SKILL.md— 协程测试的归属位置。 - 跨类别:
../../../compose/synchronization/controlling-the-test-clock/SKILL.md— Compose 的MainTestClock(与TestCoroutineScheduler相互独立)。 - 跨类别:
../../../instrumentation/runner/running-instrumented-tests-with-androidjunit4/SKILL.md— 同一个 SUT 在设备上运行时。 - 研究:
tasks/research/R5-coroutines-test-turbine.md。