mcpField
T
testing-coroutines-with-runtest

testing-coroutines-with-runtest

github.com/skydoves测试与质量33980 次安装22 周前更新
代码仓库

关于 testing-coroutines-with-runtest

该技能面向 Kotlin 开发者,介绍使用 kotlinx.coroutines.test 的 runTest 测试挂起函数和 ViewModel 协程。内容涵盖虚拟时间、测试调度器、Main dispatcher 配置及从 runBlockingTest 迁移。

主要功能

  1. 01使用 runTest 测试挂起函数
  2. 02通过虚拟时钟控制协程时间
  3. 03配置 Standard 与 Unconfined 测试调度器
  4. 04为 ViewModel 设置 Main dispatcher
  5. 05从 runBlockingTest 迁移

适用场景

  1. 01测试延迟、重试或防抖逻辑
  2. 02断言 ViewModel 的中间状态变化
  3. 03避免热流收集导致测试挂起
  4. 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。)需要牢记三点:

  1. timeout 是整个测试的截止时间,而非每次调度静止时的超时时间。已弃用的 dispatchTimeoutMs 重载语义不同,详见下文的迁移说明。
  2. testBody 返回后,runTest 会等待所有在 TestScope 上启动的子协程完成。在 TestScope.backgroundScope 上启动的子协程则会在测试结束时自动取消。热流收集器应使用 backgroundScope。
  3. 子协程中未捕获的异常会被汇总,并在测试结束时作为单个失败重新抛出。

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
平台实际类型调用方义务
JVMUnit任意写法均可。
NativeUnit同上。
Kotlin/JS,wasmPromise<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 { … }
TestCoroutineScopeTestScope
TestCoroutineDispatcherStandardTestDispatcher / 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。

评论

支持 Markdown