Skip to content

协程测试

源:Testing Coroutines

测试异步代码的难点在于如何处理时间的流逝以及多线程调度的不确定性。Kotlin 官方提供的 kotlinx-coroutines-test 库通过虚拟时间控制和测试调度器,让异步测试变得确定且高效。

环境配置

runTest 等 API 位于专用的测试库中,需在 build.gradle 中添加依赖:

kotlin
dependencies {
    // 核心测试库,包含 runTest, TestDispatcher 等
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
}
groovy
dependencies {
    testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0'
}

核心环境:runTest 深度实战

runTest 是进行协程单元测试的标准化入口。它会自动创建一个 TestScope,并拦截所有在该环境内启动的协程。

1. 基础挂起函数测试

最简单的场景:验证一个挂起函数的返回值。

kotlin
@Test
fun testSimpleSuspendFunction() = runTest {
    val result = api.fetchData() // 挂起函数
    assertEquals("ExpectedData", result)
}

2. 虚拟时间跳跃 (Skip Delays)

runTest 最强大的地方在于它能让 delay 瞬间完成。

kotlin
@Test
fun testDelaySkipping() = runTest {
    val startTime = testScheduler.currentTime
    
    // 模拟一个耗时 10 秒的操作
    delay(10_000) 
    
    val endTime = testScheduler.currentTime
    // ⭐️ 验证虚拟时间确实推进了 10 秒,但测试是瞬间完成的
    assertEquals(10_000, endTime - startTime)
}

3. 并发任务执行与等待

当测试中启动了多个并发子协程时,runTest 会自动等待它们全部完成。

kotlin
@Test
fun testConcurrentJobs() = runTest {
    var count = 0
    
    // 启动两个并发任务
    launch {
        delay(1000)
        count += 1
    }
    launch {
        delay(2000)
        count += 2
    }
    
    // ⭐️ 此时 count 还是 0,因为时间还没推进
    assertEquals(0, count)
    
    // 推进 2000ms 
    advanceTimeBy(2000)
    
    // ⭐️ 两个任务都已完成
    assertEquals(3, count)
}

调度器:Standard vs. Unconfined

StandardTestDispatcher (默认)

任务会被放入队列,直到你显式调用 runCurrent() 或推进时间。

UnconfinedTestDispatcher

任务会立即启动,直到第一个挂起点。适合测试“调用即触发”的状态更新。

kotlin
@Test
fun testStandardDispatcher() = runTest {
    val dispatcher = StandardTestDispatcher(testScheduler)
    var executed = false
    
    launch(dispatcher) {
        executed = true
    }
    
    // ⭐️ 任务已排队,但未执行
    assertEquals(false, executed)
    
    // 驱动调度器执行当前所有待办任务
    runCurrent() 
    assertEquals(true, executed)
}
kotlin
@Test
fun testUnconfinedDispatcher() = runTest {
    // 模拟 ViewModel 状态更新
    val viewModel = MyViewModel(UnconfinedTestDispatcher(testScheduler))
    
    viewModel.load() // 内部调用了 launch
    
    // ⭐️ 无需 runCurrent,状态已立即更新
    assertEquals(State.Loaded, viewModel.state.value)
}

进阶实战:测试超时逻辑

验证业务代码中的 withTimeout 是否工作正常。

kotlin
@Test
fun testTimeoutLogic() = runTest {
    val result = try {
        withTimeout(1000) {
            delay(500)
            "Success"
        }
    } catch (e: TimeoutCancellationException) {
        "Timeout"
    }
    
    assertEquals("Success", result)
    
    // 测试超时情况
    assertFailsWith<TimeoutCancellationException> {
        withTimeout(1000) {
            delay(2000) // 超过 1000ms,触发超时
        }
    }
}

处理 Main 调度器 (Android 必备)

单元测试环境下没有 Looper,必须替换 Dispatchers.Main

kotlin
class MainDispatcherRule(
    private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }
    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

class MyViewModelTest {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun testViewModelRefresh() = runTest {
        viewModel.refresh() // 内部使用 Dispatchers.Main
        assertEquals(Data, viewModel.data.value)
    }
}

核心开发准则

  1. 测试代码必须包裹在 runTest 中:这是获取虚拟时间控制权的唯一途径。
  2. 区分调度器意图:需要验证任务执行顺序时用 Standard;只需要结果时用 Unconfined
  3. 注入而非硬编码:在业务代码中使用构造函数注入 CoroutineDispatcher,测试时传入 testDispatcher
  4. 清理遗留协程:如果启动了无限循环(如轮询),测试结束前务必 cancelChildren()