【Kotlin 协程修仙录 · 合体境 · 中阶】 | 天眼洞开:协程调试与 runTest 虚拟时间测试之道

0 阅读8分钟

image_11.png

前言

合体初成,你已将协程融入 Android 四大组件。CoroutineWorkerRoomRetrofitCompose 皆与协程浑然一体,你的架构如臂使指。然而,当你在生产环境面对崩溃日志时,当你在单元测试中苦等 delay 超时时,新的困惑悄然浮现:

  • 1、为什么协程的堆栈信息如此难以阅读?那些 ContinuationinvokeSuspend 是什么鬼?
  • 2、测试中用了 delay(5000),难道真要让测试跑 5 秒?那我几百个测试用例要跑到猴年马月?
  • 3、viewModelScope 启动的协程,在单元测试里根本不执行,怎么办?
  • 4、如何测试 StateFlow 发射的多个值?如何验证 SharedFlow 收到的事件序列?

这些问题指向协程开发中两个关键领域:调试测试。不了解协程的调试技巧,你将在堆栈迷宫中寸步难行;不掌握协程的测试工具,你的单元测试将缓慢且不可靠。

本讲是合体境的中阶修炼。你将开启“天眼”,看穿协程的堆栈迷雾,掌控虚拟时间的流动:

  • 理解协程堆栈为何难以阅读,以及如何使用 Debug 模式还原真实调用链。
  • 掌握 runTest 的核心魔法——虚拟时间调度器,让 delay 瞬间完成。
  • 学会测试 viewModelScope 中的协程,用 TestDispatcher 控制执行时机。
  • 掌握 StateFlowSharedFlow 的测试断言技巧。

准备好开启天眼,洞察协程调试与测试的玄机了吗?我们开始。

千曲而后晓声,观千剑而后识器。虐它千百遍方能通晓其真意


协程堆栈的迷雾与 Debug 模式的救赎

为什么协程堆栈如此难读?

协程的挂起与恢复机制,本质上是将函数切分为多个状态机片段。当协程挂起时,调用栈被清空,线程被释放;恢复时,状态机从断点继续执行。这意味着,传统的线程调用栈无法完整呈现协程的执行路径

看看下面这个崩溃堆栈:

java.lang.NullPointerException
    at com.example.UserViewModel$loadUser$1.invokeSuspend(UserViewModel.kt:24)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
    at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
    ...

你只能看到 invokeSuspendDispatchedTask,却不知道 loadUser 是在哪个协程中被调用的,也不知道调用链的上游是什么。这对于排查问题极为不利。

开启协程 Debug 模式

Kotlin 协程提供了 Debug 模式,通过在 JVM 启动参数中添加 -Dkotlinx.coroutines.debug,协程运行时会保留创建时的调用栈快照,并将其附加到协程名称中。

开启方式:在 app/build.gradle.kts 中:

android {
    // ...
    testOptions {
        unitTests.all {
            it.jvmArgs("-Dkotlinx.coroutines.debug")
        }
    }
}

或者在运行配置中添加 VM options:-Dkotlinx.coroutines.debug

开启后,协程的名称会包含创建时的堆栈信息,例如:

"coroutine#2":StandaloneCoroutine{Active}@1b6d3586, created at UserViewModel.loadUser(UserViewModel.kt:18)

堆栈追踪也会更加清晰,包含 CoroutineName 和创建位置。

flowchart LR
    subgraph WithoutDebug[无 Debug 模式]
        W1[崩溃堆栈] --> W2[invokeSuspend]
        W2 --> W3[BaseContinuationImpl]
        W3 --> W4[DispatchedTask]
    end
    
    subgraph WithDebug[有 Debug 模式]
        D1[崩溃堆栈] --> D2[协程名称含创建位置]
        D2 --> D3[完整挂起调用链]
        D3 --> D4[用户代码具体行号]
    end
    
    style WithoutDebug fill:#ffcdd2,stroke:#b71c1c,stroke-width:2px
    style WithDebug fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
    style W1 fill:#ef9a9a
    style D1 fill:#a5d6a7

最佳实践:在开发和测试阶段始终开启 Debug 模式;生产环境建议关闭以避免性能开销。


runTest:操纵时间的终极测试法器

传统测试的痛点:真实的 delay

假设你要测试一个倒计时功能:

fun countdown(seconds: Int): Flow<Int> = flow {
    for (i in seconds downTo 1) {
        delay(1000)
        emit(i)
    }
}

// 传统测试
@Test
fun testCountdown() = runBlocking {
    val results = mutableListOf<Int>()
    countdown(5).collect { results.add(it) }
    assertEquals(listOf(5, 4, 3, 2, 1), results)
}

这个测试将真实等待 5 秒!几百个这样的测试将让 CI 流程不堪重负。

runTest 与虚拟时间

runTestkotlinx-coroutines-test 库提供的测试协程构建器。它的核心魔法是:所有 delay 都会被虚拟时间调度器拦截,时间在测试代码中“瞬间”流逝,但 delay 的挂起和恢复语义完全保留。

import kotlinx.coroutines.test.runTest

@Test
fun testCountdownWithVirtualTime() = runTest {
    val results = mutableListOf<Int>()
    val job = launch {
        countdown(5).collect { results.add(it) }
    }
    
    // 虚拟时间前进 5 秒,所有 delay 瞬间完成
    advanceTimeBy(5000)
    
    assertEquals(listOf(5, 4, 3, 2, 1), results)
    job.cancel()
}

runTest 会自动跳过 delay 的等待时间,除非你需要精确控制时间推进(使用 advanceTimeByadvanceUntilIdle)。

sequenceDiagram
    participant Test as 测试协程
    participant Flow as countdown Flow
    participant Time as 虚拟时间调度器

    Test->>Flow: collect
    Flow->>Time: delay(1000)
    Time-->>Flow: 立即恢复(时间瞬间过 1 秒)
    Flow->>Test: emit(5)
    Flow->>Time: delay(1000)
    Time-->>Flow: 立即恢复
    Flow->>Test: emit(4)
    Note over Test: 所有 delay 瞬间完成,无需真实等待

runTest 的核心能力

功能说明
advanceTimeBy(ms)手动推进虚拟时间
advanceUntilIdle()推进时间直到没有待处理的挂起任务
currentTime获取当前虚拟时间戳
runCurrent()执行当前队列中所有已到期的任务
@Test
fun testDebounce() = runTest {
    val flow = MutableSharedFlow<Int>()
    val results = mutableListOf<Int>()
    
    flow.debounce(300).collect { results.add(it) }
    
    flow.emit(1)
    advanceTimeBy(200) // 未到 300ms
    flow.emit(2)       // 重置计时器
    advanceTimeBy(200) // 仍未到
    assertEquals(emptyList<Int>(), results)
    
    advanceTimeBy(100) // 总共 300ms,debounce 触发
    assertEquals(listOf(2), results)
}

测试 viewModelScopeTestDispatcher 与作用域替换

viewModelScope 在测试中的问题

viewModelScope 默认使用 Dispatchers.Main.immediate,而单元测试环境中没有主线程 Looper,导致 viewModelScope.launch 中的代码不会被执行。

使用 TestDispatcher 替换 Main 调度器

kotlinx-coroutines-test 提供了 TestDispatchersetMain 扩展函数,让你在测试前将 Dispatchers.Main 替换为测试调度器。

import kotlinx.coroutines.test.*

@Before
fun setUp() {
    Dispatchers.setMain(StandardTestDispatcher())
}

@After
fun tearDown() {
    Dispatchers.resetMain()
}

@Test
fun testViewModel() = runTest {
    val viewModel = MyViewModel()
    
    viewModel.loadData()
    
    // 执行所有已排队的任务
    advanceUntilIdle()
    
    assertEquals(UiState.Success, viewModel.uiState.value)
}

关键StandardTestDispatcher 需要手动调用 advanceUntilIdle()runCurrent() 来执行排队的协程任务。

flowchart LR
    subgraph TestEnv[测试环境]
        T[runTest] --> TD[TestDispatcher]
        TD --> Queue[任务队列]
    end
    
    subgraph ViewModel[ViewModel]
        VS[viewModelScope.launch]
    end
    
    VS -->|提交任务| Queue
    TD -->|advanceUntilIdle| Queue
    Queue -->|执行| Coroutine[协程体]
    
    style TestEnv fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
    style ViewModel fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style TD fill:#a5d6a7
    style Queue fill:#ffb74d

测试 StateFlowSharedFlow

测试 StateFlow

StateFlow 的测试重点在于:初始值、更新序列、去重。

@Test
fun testStateFlow() = runTest {
    val state = MutableStateFlow(0)
    val results = mutableListOf<Int>()
    
    val job = launch {
        state.collect { results.add(it) }
    }
    
    state.value = 1
    state.value = 1 // 去重,不会发射
    state.value = 2
    
    advanceUntilIdle()
    
    assertEquals(listOf(0, 1, 2), results)
    job.cancel()
}

测试 SharedFlow

SharedFlow 需要验证:重放、缓冲、多订阅者。

@Test
fun testSharedFlow() = runTest {
    val sharedFlow = MutableSharedFlow<Int>(replay = 1)
    
    // 预先发射一个值
    sharedFlow.emit(1)
    
    val results = mutableListOf<Int>()
    val job = launch {
        sharedFlow.collect { results.add(it) }
    }
    
    // 由于 replay=1,新订阅者立即收到 1
    advanceUntilIdle()
    assertEquals(listOf(1), results)
    
    sharedFlow.emit(2)
    advanceUntilIdle()
    assertEquals(listOf(1, 2), results)
    
    job.cancel()
}

实战:完整的 ViewModel + 协程测试

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.*
import org.junit.*

@OptIn(ExperimentalCoroutinesApi::class)
class UserViewModelTest {
    
    private lateinit var repository: FakeUserRepository
    private lateinit var viewModel: UserViewModel
    private val testDispatcher = StandardTestDispatcher()
    
    @Before
    fun setUp() {
        Dispatchers.setMain(testDispatcher)
        repository = FakeUserRepository()
        viewModel = UserViewModel(repository)
    }
    
    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }
    
    @Test
    fun `loadUser success updates uiState to Success`() = runTest {
        // Given
        val expectedUser = User("张三")
        repository.setUser(expectedUser)
        
        // When
        viewModel.loadUser("123")
        advanceUntilIdle() // 等待 viewModelScope 中的协程完成
        
        // Then
        assertTrue(viewModel.uiState.value is UiState.Success)
        assertEquals(expectedUser, (viewModel.uiState.value as UiState.Success).user)
    }
    
    @Test
    fun `loadUser error updates uiState to Error`() = runTest {
        // Given
        repository.setError(IOException("网络错误"))
        
        // When
        viewModel.loadUser("123")
        advanceUntilIdle()
        
        // Then
        assertTrue(viewModel.uiState.value is UiState.Error)
    }
    
    @Test
    fun `retry triggers reload`() = runTest {
        // Given
        repository.setError(IOException())
        viewModel.loadUser("123")
        advanceUntilIdle()
        assertTrue(viewModel.uiState.value is UiState.Error)
        
        // When
        repository.setUser(User("李四"))
        viewModel.retry()
        advanceUntilIdle()
        
        // Then
        assertTrue(viewModel.uiState.value is UiState.Success)
    }
}

// 假 Repository
class FakeUserRepository {
    private var user: User? = null
    private var error: Throwable? = null
    
    fun setUser(user: User) {
        this.user = user
        this.error = null
    }
    
    fun setError(error: Throwable) {
        this.error = error
        this.user = null
    }
    
    suspend fun getUser(id: String): User {
        delay(100) // 模拟网络延迟,runTest 中瞬间完成
        error?.let { throw it }
        return user ?: error("未设置数据")
    }
}
flowchart TD
    subgraph Setup[测试准备]
        S1[setMain TestDispatcher]
        S2[创建 FakeRepository]
        S3[创建 ViewModel]
    end
    
    subgraph Action[执行操作]
        A1[viewModel.loadUser]
        A2[advanceUntilIdle]
    end
    
    subgraph Assert[断言验证]
        AS1[验证 uiState 类型]
        AS2[验证数据内容]
    end
    
    Setup --> Action --> Assert
    
    style Setup fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style Action fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style Assert fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px

常见错误与避坑指南

错误 1:忘记 advanceUntilIdle,导致测试永远等待

@Test
fun test() = runTest {
    viewModel.loadData()
    // 没有 advanceUntilIdle,StateFlow 永远不会更新
    assertEquals(Loading, viewModel.uiState.value) // 失败或超时
}

正确:在 runTest 中,使用 StandardTestDispatcher 时必须手动推进时间或调用 advanceUntilIdle()

错误 2:在 runBlocking 中测试协程,导致真实延迟

@Test
fun test() = runBlocking {
    delay(5000) // 真实等待 5 秒!
}

正确:使用 runTest,其内部 delay 是虚拟的。

错误 3:未重置 Dispatchers.Main,导致测试间相互污染

@Before
fun setUp() {
    Dispatchers.setMain(testDispatcher)
}
// 忘记 @After 中 resetMain

正确:务必在 @After 中调用 Dispatchers.resetMain()


最佳实践

  1. 所有协程测试使用 runTest:避免真实延迟,加快测试速度。
  2. @Before 中替换 Dispatchers.Main@After 中重置:保证测试隔离。
  3. 使用 advanceUntilIdle() 等待协程完成:简单可靠。
  4. 为 ViewModel 的 StateFlow 编写状态转换测试:验证完整的状态序列。
  5. 使用 backgroundScope 管理测试中的后台协程:自动在测试结束时取消。

总结与下回预告

恭喜,你已开启天眼,掌握了协程调试与测试的核心奥义,合体境中阶修炼完成!

本讲核心收获

  • Debug 模式可还原协程创建位置,改善堆栈可读性。
  • runTest 使用虚拟时间,让 delay 瞬间完成。
  • TestDispatcher 替换 Dispatchers.Main,使 viewModelScope 可测试。
  • advanceUntilIdle 控制协程任务执行时机。
  • StateFlowSharedFlow 的测试断言技巧。

在下一讲 【合体境·后阶】 中,我们将深入协程的性能优化与线程池调优:Dispatchers.IO 的弹性机制、limitedParallelism 限流、以及如何避免常见的内存泄漏。届时你会明白:

  • 如何限制 Dispatchers.IO 的最大并发数?
  • viewModelScopelifecycleScope 的内部取消机制有何不同?
  • 如何用 WeakReference + 协程排查泄漏?

【当前境界修为面板】

当前境界修炼技能修炼进度修炼心得
合体境 · 中阶1、Debug 天眼术
2、runTest 时间操纵诀
3、TestDispatcher 作用域替换法
当前进度70%
修为700/1000
下一突破[合体境 · 后阶] (需领悟:协程性能调优、limitedParallelism、泄漏排查)
runTestdelay瞬间完成。虚拟时间操纵术是协程测试的终极法器。

【本讲思考题】

  1. 表象题:以下测试有什么问题?

    @Test
    fun test() = runBlocking {
        val result = mySuspendFunction()
        assertEquals(expected, result)
    }
    
  2. 场景题:你需要测试一个 ViewModel,它在 init 块中通过 viewModelScope.launch 启动了一个无限循环的协程(while(true) { delay(1000); updateState() })。如何在不超时的情况下测试 updateState 是否被正确调用?写出关键测试代码。

  3. 原理题runTest 的虚拟时间是如何实现的?delay 被替换成了什么?请从 TestCoroutineScheduler 的角度简述。


道友,合体境的最后一道关隘已在眼前。掌握了性能调优,你的协程应用将如臂使指、行云流水。合体境·后阶见。

欢迎一键四连关注 + 点赞 + 收藏 + 评论