第2章 协程测试基础

2.1 协程测试的核心挑战

在 JVM 平台上测试协程代码面临两个关键问题:时间维度(真实 delay 拖慢测试)和作用域维度(结构化并发导致泄漏)。Kotlin 协程测试库提供了 runTest、TestCoroutineDispatcher 和 TestScope 来统一解决这些问题。本章将逐一拆解这些组件的工作原理与最佳实践。

2.2 runBlocking 与 runTest 对比

特性runBlockingrunTest
调度器当前线程阻塞虚拟时间调度器 (StandardTestDispatcher)
delay 行为真实等待(挂起线程)跳过等待,虚拟时间前进
协程控制仅阻塞当前线程自动控制,可手动推进时间
测试隔离依赖外部协程作用域提供内建 TestScope
超时支持需手动编写超时逻辑内置超时,可配置
推荐用途普通单元测试,不涉及协程时间所有协程单元测试

2.3 TestCoroutineDispatcher 与虚拟时间控制

TestCoroutineDispatcher(在 kotlinx-coroutines-test 1.6+ 中更名为 StandardTestDispatcher)是虚拟时间调度的核心。它不会真实挂起线程,而是将 delay 等操作记录为“虚拟时间点”,通过以下 API 精确控制进度:

// 创建调度器
val dispatcher = StandardTestDispatcher()
val scope = TestScope(dispatcher)

// 启动协程
scope.launch {
    println("开始: ${currentTime}")
    delay(1000)
    println("1秒后: ${currentTime}")
    delay(2000)
    println("3秒后: ${currentTime}")
}

// 手动推进时间
dispatcher.scheduler.advanceTimeBy(1000) // 只前进1秒
println("当前虚拟时间: ${dispatcher.scheduler.currentTime}")

// 推进所有待执行协程直到空闲
dispatcher.scheduler.advanceUntilIdle()
println("最终时间: ${dispatcher.scheduler.currentTime}")

常用虚拟时间 API 一览:

API作用性能影响
advanceTimeBy(timeMillis)将虚拟时间向前推进指定毫秒,执行期间可调度的所有协程低(仅处理已入队任务)
advanceUntilIdle()不断推进时间直到没有更多挂起任务(模拟“自然流逝”)中等(可能处理大量链式任务)
currentTime获取当前虚拟时间戳瞬间
runCurrent()立即执行当前时间点所有可运行的任务,不推进时间低

2.4 runTest 内部工作原理

runTest 实际上是一个内联函数,它自动创建 TestScope 并附加 StandardTestDispatcher,在 lambda 结束后自动执行 advanceUntilIdle() 并清理作用域。其等效实现如下:

suspend fun runTest(block: suspend TestScope.() -> Unit) {
    // 内部省略了异常处理和超时逻辑
    val scope = TestScope()
    val testResult = scope.testScheduler.advanceUntilIdle()
    // 如果协程未完成则抛出超时异常
    scope.backgroundScope.cancel()
}

2.5 协程作用域搭建

对于 runTest 内部已提供了 this 作为 TestScope,可以直接使用。需要自定义作用域时,建议遵循结构化并发原则:

// 好的做法:使用 TestScope 并手动控制生命周期
class MyViewModelTest {
    private val testDispatcher = StandardTestDispatcher()
    private val testScope = TestScope(testDispatcher)

    @After
    fun tearDown() {
        testScope.cancel() // 清理协程
    }

    @Test
    fun testWithCustomScope() = testScope.runTest {
        // 这里使用 testScope 的调度器
        launch {
            delay(100)
            // ...
        }
        advanceUntilIdle()
    }
}
陷阱提示:不要在 runTest 内部创建新的 CoroutineScope,否则会导致泄漏。应始终使用 runTest 提供的 TestScope 或通过构造函数注入调度器。

2.6 runBlocking vs runTest 执行流程图

下图对比了两种方式的协程执行路径与时间消耗:

runBlocking vs runTest 执行对比 runBlocking 测试启动 delay(300) 真实等待 协程完成 测试结束(耗时≥300ms) runTest 测试启动 delay(300) 虚拟跳过 协程完成 测试结束(耗时≈0ms) 说明:runTest 通过虚拟调度器将所有 delay 压缩为逻辑时间,避免真实等待

2.7 调试技巧

  • 查看当前虚拟时间:在测试中调用 println("当前时间: ${this.testScheduler.currentTime}") 观察调度进度。
  • 超时定位:当 runTest 抛出 TestTimeoutException 时,说明某个协程未在默认 60s(虚拟时间)内完成。可使用 withTimeout 缩小排查范围。
  • 使用 debug 日志:在协程启动处添加 println("协程 ${coroutineContext[CoroutineName]} 启动,时间=${currentTime}") 理清执行顺序。

2.8 常见错误与解决方案

错误场景原因解决方案
测试耗时过长使用 runBlocking 测试带 delay 的协程替换为 runTest
虚拟时间无变化忘记调用 advanceUntilIdle 或 advanceTimeBy在协程启动后手动推进时间
协程泄漏创建了自定义 CoroutineScope 但未取消使用 TestScope 并在 @After 中 cancel()
测试结果不确定依赖不同调度器的协程顺序注入 StandardTestDispatcher 统一调度
deadline 超时协程内部有无限循环或未处理的挂起使用 advanceUntilIdle 前确保有终止条件

2.9 性能与稳定性优化

  • 最小化真实延迟:所有协程测试必须使用 runTest + StandardTestDispatcher,避免 I/O 或网络调用真实等待。
  • 批量推进:当测试多个独立 delay 时,使用 advanceTimeBy 精确控制,避免 advanceUntilIdle 做全量推进影响性能。
  • 超时配置:对于复杂场景,使用 runTest(timeout = 30_000) 设置合理的虚拟时间超时。
  • 作用域复用:在同一个测试类的多个测试中复用 TestScope 时,务必在 @After 中 cancel() 并重新创建,避免残留协程影响后续测试。
最佳实践:在每个测试类的顶部定义全局测试调度器,并通过构造函数注入给被测试对象,这样既能控制时间又便于维护。