异步与时间:测协程、Flow,和「等一下」
同步代码好测:调一下,拿结果,断言。异步代码是另一个物种 —— 你调了一下,结果不在当下,它在几十毫秒后、在另一个线程上、通过一个回调或一次 emit 才到。而你的测试代码在下一微秒就冲到了断言那一行,这时结果还没来。异步是测试脆弱(flaky)的头号来源。这一章讲现代 Kotlin 怎么优雅地解决它:用虚拟时钟把「延迟 5 秒」压缩成一瞬间、用 Turbine 逐帧检查 Flow、以及那条铁律 —— 测试里永远不要真的等。
为什么异步这么难测
看这个 suspend 函数,它里面有个 delay:
suspend fun fetchWithRetry(): Data {
repeat(3) { attempt ->
try { return api.fetch() }
catch (e: IOException) { delay(1000) } // 失败后等 1 秒再试
}
throw GiveUpException()
}
你想测「三次都失败后抛 GiveUpException」。天真的写法会真的等 3 秒(三次 delay)。而更根本的问题是:如果你不小心用了真线程,测试线程可能在异步结果回来之前就跑完了断言,导致时而绿时而红。这两个毛病 —— 慢 和 不确定 —— 正是异步测试的核心痛点。Kotlin 协程给了一整套针对性的工具。
runTest 与虚拟时钟
kotlinx-coroutines-test 提供的 runTest,是测协程的入口。它最神奇的能力是一台虚拟时钟:
在 runTest 里,delay(1000) 不会真的等 1 秒。它只是把虚拟时钟往前拨 1000 毫秒 —— 瞬间完成。于是一个逻辑上「等 3 秒」的测试,真实耗时几毫秒。
这解决了「慢」:你可以测超时、重试、防抖这些时间相关的逻辑,而不用真的付出那些时间。虚拟时钟还让你能精确控制时间的推进 —— 想让「刚好到超时那一刻」发生什么,拨到那一刻就行。
@Test fun `三次失败后放弃`() = runTest { // ← 虚拟时钟,不真的等
coEvery { api.fetch() } throws IOException()
assertFailsWith<GiveUpException> { fetchWithRetry() }
coVerify(exactly = 3) { api.fetch() } // 确实试了 3 次
}
@Test fun `超时前返回则不重试`() = runTest {
coEvery { api.fetch() } returns Data("ok")
assertEquals(Data("ok"), fetchWithRetry())
coVerify(exactly = 1) { api.fetch() }
}
需要手动推进时钟时,用 advanceTimeBy(1000) 或 advanceUntilIdle()。比如测一个「输入停顿 300ms 后才搜索」的防抖,你可以拨 299ms 断言「还没搜」,再拨 1ms 断言「搜了」。
测 Flow:Turbine 逐帧检查
Flow 是一串异步发射的值,测它比测单个 suspend 函数更麻烦 —— 你要检查它依次发了什么、什么时候完成、有没有报错。手写这个既繁琐又易错。Turbine 这个小库把它变得清爽:
@Test fun `登录状态流:加载中 → 成功`() = runTest {
viewModel.uiState.test { // Turbine 的 test { }
assertEquals(Idle, awaitItem()) // 初始态
viewModel.login("a@b.com", "12345678")
assertEquals(Loading, awaitItem()) // 点了登录 → 加载中
assertEquals(Success, awaitItem()) // 请求回来 → 成功
cancelAndIgnoreRemainingEvents()
}
}
awaitItem() 挂起直到下一次发射,拿到就断言;awaitComplete() 断言流正常结束;awaitError() 断言它抛了异常。这套「逐帧领取并断言」的模式,让 Flow 测试读起来像一份时序脚本 —— 每一帧该是什么,写得清清楚楚。(StateFlow 这类热流有个坑:它只保留最新值,所以要在触发动作之前就开始 test { } 收集,否则会漏掉中间态。)
铁律:测试里,永远不要真的等
第 7 章讲 UI 测试时点过 Thread.sleep 的名,这里把它上升为一条通则:
Thread.sleep / 真延迟 = 未来某天的 flaky任何形如「等 X 毫秒,然后断言结果应该到了」的测试,都是在赌 —— 赌异步操作能在 X 毫秒内完成。这个赌注:
- 下大了 → 每个测试白等,套件越来越慢;
- 下小了 → CI 机器一卡,结果没到,测试红。而它只是偶尔红,于是变成最难查的那种 flaky(第 17 章)。
正解永远是「等条件,不等时间」:虚拟时钟(runTest)让时间可控、Turbine 的 awaitItem 等到「下一帧真的来了」、Espresso 的 IdlingResource 等到「App 说它闲了」。没有一个是靠猜时长。
让异步变确定:注入 Dispatcher
虚拟时钟能生效,前提是你的协程跑在测试可控的调度器上。如果你的代码里硬编码了 Dispatchers.IO / Dispatchers.Main,测试就管不到它 —— 又是「可测性是设计出来的」(第 5 章)。所以把 Dispatcher 也注入进来:
// ✗ 硬编码,测试无法接管
class Repo {
suspend fun load() = withContext(Dispatchers.IO) { ... }
}
// ✓ 注入,测试塞一个 TestDispatcher 进来
class Repo(private val io: CoroutineDispatcher = Dispatchers.IO) {
suspend fun load() = withContext(io) { ... }
}
@Test fun test() = runTest {
val repo = Repo(io = StandardTestDispatcher(testScheduler)) // 归虚拟时钟管
...
}
另外,Android 的 Dispatchers.Main 在纯 JVM 测试里不存在,需要用 Dispatchers.setMain(testDispatcher) 在测试前替换掉(通常封装成一个 JUnit Rule,第 20 章会给)。
AI 与异步测试
让 AI 测一段异步代码,它常常给你两种坏版本:一种是 Thread.sleep(2000) 然后断言 —— 一个天生 flaky 的测试;另一种是漏掉了对异步完成的等待,让测试在结果到达前就断言,时绿时红。
它也常常不知道你项目里注入 Dispatcher 的约定,于是写出没法被虚拟时钟接管的测试。审查 AI 的异步测试,盯三点:有没有 sleep(有就打回)、有没有用 runTest 的虚拟时钟、Flow 有没有用 Turbine 逐帧断言。这三点,是异步测试从"偶尔骗过 CI"到"真的可靠"的分界。
虚拟时钟的真本事:精确拨到某一刻
虚拟时钟不只是「跳过 delay」,它还能让你精确控制时间推进 —— 这对测「防抖、节流、超时」这类时间敏感逻辑是决定性的。测一个「输入停顿 300ms 才搜索」的防抖:
@Test fun `防抖:连续输入只搜一次`() = runTest {
viewModel.onQueryChanged("a")
advanceTimeBy(100); viewModel.onQueryChanged("ab") // 100ms 后又输入
advanceTimeBy(100); viewModel.onQueryChanged("abc") // 又 100ms,还在打字
advanceTimeBy(299) // 停顿 299ms —— 还没到 300
coVerify(exactly = 0) { api.search(any()) } // 断言:还没搜
advanceTimeBy(1) // 再拨 1ms,凑满 300
coVerify(exactly = 1) { api.search("abc") } // 断言:只搜了一次,且是最新的词
}
看那个 advanceTimeBy(299) 再 advanceTimeBy(1) —— 你把时钟精确地停在边界的两侧,验证「差 1ms 时不搜、正好到点才搜」。这种测试用真实时钟根本没法可靠地写(你没法精确控制真实的毫秒),而虚拟时钟让它变得确定、且瞬间完成。这是第 9 章「测边界」和本章「控制时间」的合流。
两种 TestDispatcher,别选错
coroutines-test 有两个测试调度器,行为差别会让你的测试时红时绿,值得分清:
| StandardTestDispatcher | UnconfinedTestDispatcher | |
|---|---|---|
| 新协程 | 排队,不立即执行 | 立即执行到第一个挂起点 |
| 你需要 | 手动 advanceUntilIdle() 推进 | 大多自动往前跑 |
| 适合 | 要精确控制时序(如上面防抖) | 只想「跑完拿结果」的简单场景 |
常见的坑:用了 StandardTestDispatcher 却忘了 advanceUntilIdle(),于是被测的协程根本没跑,你断言了个初始状态还以为过了。默认的 runTest { } 用的是 Standard —— 记得在触发动作后推进时钟。
1. 在你项目里搜 Thread.sleep 和真 delay(在测试代码里)。每一处都是一颗待爆的 flaky。
2. 找一个测超时/重试的测试,如果它真的等了几秒,改成 runTest + 虚拟时钟,看它瞬间跑完。
3. 给一个 StateFlow/Flow 写一个 Turbine 测试,逐帧断言它的发射序列。体会「时序脚本」式的可读性。
手艺篇进行到这里,你已经能挑对输入、用对替身、测好异步。下一章是一次思维跃迁:与其自己费劲去想边界和刁钻输入,不如写下一条「对所有输入都成立的规律」,让机器灌一千组随机输入替你找反例 —— 而且它还会把反例收缩成人能一眼看懂的最小形态。