Turbine 测试 Kotlin Flow,让异步变同步

Turbine 测试 Kotlin Flow,让异步变同步

Turbine 测试 Kotlin Flow,让异步变同步


「Turbine 测试 Kotlin Flow,让异步变同步」


Flow 的测试一直是个让人头疼的事。Kotlin 协程把异步代码写得像同步一样顺手,可一到单元测试里,这种"顺手"就变成了"烫手"。runTest 确实解决了挂起函数的问题,但面对一个不断发射值的 Flow,你怎么断言中间状态?怎么验证时序?怎么处理永远不会完结的冷流?这些问题在 2021 年之前几乎没有一个让人满意的答案,直到 Cash App 开源了 Turbine。


我第一次认真用 Turbine 是在一个音视频项目的重构里。那个项目的核心是一个 MediaController 类,内部维护着播放状态机,对外暴露 StateFlow<PlaybackState>。状态转换极其复杂:从 Idle 到 Buffering 到 Playing,网络波动时回退到 Buffering,用户拖拽进度条进入 Seeking,错误时跳转到 Error。每个状态转换都有对应的业务逻辑,而旧的测试代码用 runBlockingtake(1) 的拼凑方式,覆盖率惨不忍睹。引入 Turbine 之后,测试代码的行数没减多少,但可读性和对时序的精确控制完全是两个世界。这篇文章想分享的,就是 Turbine 到底做了什么、怎么用好它、以及它那些文档里不会明说的坑。


`test` 块:把事件流变成顺序执行


Turbine 最核心的 API 就一个:Flow<T>.test { ... }。这个扩展函数接收一个挂起 lambda,在这个 lambda 内部,你可以按顺序"消费" Flow 发射的每一个值。听起来简单,但它解决了一个根本问题:在协程测试里,生产者和消费者的时间线是纠缠在一起的。


举个例子。假设你有一个这样的 ViewModel:


class TimerViewModel {
    private val _elapsed = MutableStateFlow(0)
    val elapsed: StateFlow<Int> = _elapsed.asStateFlow()
    
    fun start() {
        viewModelScope.launch {
            while (isActive) {
                delay(1000)
                _elapsed.value += 1
            }
        }
    }
}

要测试这个 start() 方法,旧的做法可能是:


@Test
fun testTimer() = runTest {
    val viewModel = TimerViewModel()
    viewModel.start()
    
    delay(2500)
    val value = viewModel.elapsed.value
    assertEquals(2, value)
}

这段代码的问题一眼可见:依赖真实时间(哪怕 runTest 会跳过延迟),断言的是"某个时刻的快照"而非"事件序列",而且如果状态机更复杂,你根本没法验证中间过程。


Turbine 的做法完全不同:


@Test
fun testTimerWithTurbine() = runTest {
    val viewModel = TimerViewModel()
    viewModel.start()
    
    viewModel.elapsed.test {
        assertEquals(0, awaitItem())  // 初始值
        delay(1100)
        assertEquals(1, awaitItem())  // 第一次递增
        delay(1100)
        assertEquals(2, awaitItem())  // 第二次递增
        cancelAndConsumeRemainingEvents()
    }
}

awaitItem() 是一个挂起函数,它会挂起直到 Flow 发射下一个值。这意味着测试代码和 Flow 的生产完全同步:消费者准备好之后,生产者才继续。这种"拉取"模型让时序控制变得极其精确。你可以在任何两个 awaitItem() 之间插入验证逻辑、模拟副作用、甚至启动另一个协程。


Turbine 的 test 块内部维护了一个无界通道(unbounded channel),Flow 的每个值都会被收集到这个通道里。awaitItem() 从通道头部取出一个值,如果通道空了就挂起。这个实现细节解释了为什么 Turbine 能处理快速发射的场景——它不会丢事件,但也不会背压,内存里会暂存所有未被消费的值。对于测试场景来说,这个 trade-off 完全合理,因为测试数据量通常很小。


`awaitItem()` 家族:精确控制与超时


awaitItem() 并不是唯一的消费函数。Turbine 提供了一整套 awaitXxx 方法,覆盖了各种边界情况:


awaitItem() 等待下一个值,返回该值。如果 Flow 已经完成或出错,会抛出 AssertionError


awaitError() 等待 Flow 异常终止,返回抛出的异常。如果你预期某个操作会导致 Flow 失败,用这个来断言具体的异常类型和消息。


awaitComplete() 等待 Flow 正常完成。对于有限流(比如 flow { emit(1); emit(2) }),在消费完所有值之后调用它来验证确实结束了。


awaitEvent() 是更底层的 API,返回一个 Event 密封类,包含 ItemErrorComplete 三种子类。当你需要统一处理三种情况时用它。


expectNoEvents() 验证当前没有待处理的事件,并且在未来一段时间内也不会有。它接受一个 duration 参数,默认是 1 秒(在 runTest 里会被自动跳过,所以实际不消耗真实时间)。


这些 API 的组合让测试可以非常精确。比如验证一个搜索框的 debounce 逻辑:


@Test
fun searchDebounce() = runTest {
    val viewModel = SearchViewModel()
    
    viewModel.results.test {
        // 初始空状态
        assertEquals("", awaitItem().query)
        
        viewModel.onQueryChange("k")
        viewModel.onQueryChange("ko")
        viewModel.onQueryChange("kot")
        
        // 300ms debounce 内不应该触发搜索
        expectNoEvents()
        
        // 超过 debounce 时间后,只应该收到最后一个值
        delay(400)
        assertEquals("kot", awaitItem().query)
        
        cancelAndConsumeRemainingEvents()
    }
}

这里的关键是 expectNoEvents()。没有这个断言,你的测试只能验证"最终收到了什么",而无法验证"中间没收到什么"。很多 bug 恰恰是时序错误导致的重复请求或过早触发。


Turbine 还提供了超时控制。awaitItem() 默认会无限挂起,但你可以用 withTimeout 包裹,或者使用 awaitItem(timeout = 1.seconds)(需要显式传入)。在 runTest 环境下,超时通常意味着测试逻辑有问题——要么漏掉了某个事件,要么 Flow 的行为和预期不符。


`testIn` 与共享流的特殊处理


StateFlowSharedFlow 有个特性:它们会缓存最后一个值(StateFlow 永远缓存,SharedFlow 取决于 replay 参数)。这意味着当你调用 .test { } 时,awaitItem() 会立即收到缓存值,而不是等待下一个新值。


这个行为有时候会让人困惑。比如:


val stateFlow = MutableStateFlow(0)

stateFlow.test {
    assertEquals(0, awaitItem())  // 立即收到缓存的 0
    stateFlow.value = 1
    assertEquals(1, awaitItem())  // 收到新值 1
}

看起来没问题,但如果你的测试需要验证"从某个操作开始之后的新事件",这个缓存值就成了干扰。Turbine 0.12.0 之后引入了 testIn 来解决这类场景。


testIn(scope) 返回一个 TurbineTest 对象,它不会自动开始消费事件。你可以先执行一些准备操作,然后再开始断言:


@Test
fun testWithTestIn() = runTest {
    val viewModel = ViewModel()
    val turbine = viewModel.state.testIn(backgroundScope)
    
    // 先执行操作
    viewModel.loadData()
    
    // 现在开始消费,跳过缓存值,只关心 loadData 之后的事件
    // 注意:这里需要手动处理缓存值
    assertEquals(Loading, turbine.awaitItem())
    assertEquals(Success(data), turbine.awaitItem())
    
    turbine.cancel()
}

testIn 的另一个重要用途是和 backgroundScope 配合。runTest 的默认测试作用域会在测试结束时取消所有子协程,但 test { } 块会在 lambda 结束时自动取消订阅。如果你需要让 Flow 的收集在测试期间一直运行(比如验证多个独立操作的累积效果),testIn 更灵活。


不过 testIn 有个坑:它不会自动验证 Flow 是否完成或是否有剩余事件。你必须显式调用 cancel()cancelAndConsumeRemainingEvents(),否则测试可能通过但留下未消费的事件,掩盖真正的 bug。


热流测试的陷阱:起始时间点


StateFlow 的测试还有一个更隐蔽的问题:订阅时机。StateFlow 的值更新是同步的,如果你在 test 块外部修改了值,再进入 test 块,你可能收不到那个值——因为它已经被"错过"了。


看这个例子:


val stateFlow = MutableStateFlow(0)

// 危险:在 test 块外部修改
stateFlow.value = 1

stateFlow.test {
    // 这里收到的是 1,但如果上面修改得更快呢?
    assertEquals(1, awaitItem())
}

在单线程的 runTest 里这通常没问题,但一旦涉及真正的并发(比如 Dispatchers.Default 上的计算),订阅和修改的时序就变得不确定。Turbine 的文档建议:对于 StateFlow,总是在 test 块内部或通过 testIn 确保订阅建立后再触发操作。


我个人踩过的一个坑是在测试 ViewModel 的 init 块。很多 ViewModel 在 init { } 里就开始加载数据,如果 test 订阅建立得慢,可能错过 Loading 状态,直接收到 Success。解决方案是用 testIn 提前建立订阅,或者重构 ViewModel 暴露一个显式的 initialize() 方法。


与 `runTest` 的协作:虚拟时间不是万能药


Kotlin 协程测试库提供的 runTest 和 Turbine 是天作之合,但它们的协作方式需要理解清楚。


runTest 会劫持 delay 调用,让虚拟时间立即前进。这意味着 delay(1000) 在测试里不会真的等一秒。Turbine 的 expectNoEvents() 也利用了这一点——它的默认超时在 runTest 里会被压缩到几乎瞬间完成。


但这带来一个限制:如果你的 Flow 内部使用了非挂起的阻塞操作(比如 Thread.sleep),runTest 无能为力,Turbine 也会卡住。更常见的问题是 Dispatchers.DefaultDispatchers.IO 上的操作。runTest 默认使用 StandardTestDispatcher,它的调度器是单线程的,不会并行执行。如果你的 Flow 在 flowOn(Dispatchers.Default) 上运行,而测试代码在 StandardTestDispatcher 上等待,死锁很容易发生。


解决方案是注入调度器。不要硬编码 Dispatchers.IO,而是用构造函数参数或依赖注入提供。测试时传入 StandardTestDispatcherUnconfinedTestDispatcher


class Repository(
    private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) {
    fun fetchData(): Flow<Data> = flow {
        // 模拟网络请求
        emit(api.getData())
    }.flowOn(dispatcher)
}

@Test
fun testRepository() = runTest {
    val repository = Repository(StandardTestDispatcher(testScheduler))
    
    repository.fetchData().test {
        assertEquals(mockData, awaitItem())
        awaitComplete()
    }
}

Turbine 本身不处理调度器问题,但它假设你能控制 Flow 的执行上下文。如果你的测试时不时挂死,先检查调度器配置,而不是怀疑 Turbine。


版本演进与 API 变迁


Turbine 的 API 经历过一次比较重大的重构。0.x 版本时期,test 块返回的是 FlowTurbine,消费方法直接挂在外层。1.0.0 之后改成了接收器模式,test 块的 receiver 就是 ReceiveTurbine,所有 awaitXxx 方法都在这个 receiver 上调用。


这个改动打破了二进制兼容,但提升了类型安全。旧代码长这样:


// Turbine 0.x
val turbine = flow.test()
assertEquals(1, turbine.awaitItem())
turbine.cancel()

新代码:


// Turbine 1.x
flow.test {
    assertEquals(1, awaitItem())
    // 自动取消
}

我升级的时候花了不少时间改测试。最烦人的是 cancelAndIgnoreRemainingEvents() 被移除了,取而代之的是 cancelAndConsumeRemainingEvents(),后者会返回剩余事件列表,让你可以断言"确实没有遗漏重要事件"。


当前最新版本是 1.1.0(Maven Central 可查),支持 Kotlin 1.9+ 和 coroutines 1.7+。如果你还在用 0.x,升级的收益除了 API 更清爽,还有对 kotlinx-coroutines-test 新版本的更好兼容。Cash App 的维护节奏不算快,但关键 bug 修得及时。GitHub 仓库是 cashapp/turbine,issue 区有很多实战讨论。


局限:它不能测什么


Turbine 很强,但不是万能药。有几个场景它要么不支持,要么不是最佳选择。


第一是性能测试。Turbine 内部用通道缓存所有事件,这引入了额外的内存分配和调度开销。测量 Flow 的吞吐量或延迟时,Turbine 会扭曲结果。应该用原生的 measureTimeMillis 或 JMH。


第二是与其他异步框架的集成。Turbine 只支持 Kotlin Flow。如果你在用 RxJava 的 Observable,需要先用 asFlow() 转换,但转换本身可能改变行为(比如背压策略)。Cash App 自己也有 Rx 版本的测试工具,但那是另一个库了。


第三是跨平台的限制。Turbine 1.x 支持 JVM 和 Android,但 Kotlin/Native 和 Kotlin/JS 的支持在 1.0 之后才逐步完善。如果你在做 KMP 项目,需要确认目标平台是否包含在支持的列表里。官方文档的 README 里有明确的平台矩阵。


第四,也是我最想强调的:Turbine 测试的是 Flow 的"外部行为",不是内部实现。你可以验证 emit(1) 之后 awaitItem() 收到 1,但无法直接断言"这个值是在哪个协程上下文发射的"。有时候这很重要——比如验证 flowOn 是否生效——但 Turbine 不提供这种内省能力。需要配合 TestCoroutineScheduler 的调试日志或自定义拦截器。


还有一个实际使用中的痛点:错误信息不够友好。当 awaitItem() 超时或收到意外值时,异常堆栈经常指向 Turbine 内部,而不是你的测试代码。1.0 之后有所改善,但相比 AssertJ 或 Kotest 的错误提示,仍然有差距。我通常会包装一个辅助函数,在断言失败时打印当前已收到的事件历史,方便定位问题。


一个完整的实战模式


把上面的内容串起来,我想分享一个我在项目中反复使用的测试模式。场景是:一个 Repository 暴露 Flow<Result<Data>>,ViewModel 把它转换成 UiState,UI 层消费。


测试 ViewModel 时,我需要验证:

1. 初始状态正确

2. 调用 load 后进入 Loading

3. Repository 返回成功数据后进入 Success

4. 过程中没有多余的状态跳转


用 Turbine 写出来:


@Test
fun loadData_success() = runTest {
    val fakeRepository = FakeRepository()
    val viewModel = MyViewModel(fakeRepository)
    
    viewModel.uiState.test {
        // 验证初始状态
        assertEquals(UiState.Idle, awaitItem())
        
        // 触发加载
        viewModel.load()
        
        // 验证 Loading
        assertEquals(UiState.Loading, awaitItem())
        
        // 让 Repository 发射成功数据
        fakeRepository.emit(Result.Success(mockData))
        
        // 验证 Success
        val success = awaitItem() as UiState.Success
        assertEquals(mockData, success.data)
        
        // 验证没有更多事件
        expectNoEvents()
        
        cancelAndConsumeRemainingEvents()
    }
}

这里的关键是 expectNoEvents() 的位置。放在最后,确认状态机没有意外跳转。FakeRepository 是一个可控的测试替身,内部用 MutableSharedFlow 让测试代码能精确控制发射时机。


如果 Repository 的实现有并发(比如同时从网络和数据库取数据),我会用 testIn 替代 test,在 backgroundScope 中收集,然后在主测试逻辑中操作 FakeRepository 并断言。这种模式覆盖了项目中 90% 的 Flow 测试场景。


与其他测试工具的对比


最后想提一下 Turbine 在生态中的位置,不用表格,纯文字描述。


kotlinx-coroutines-test 提供的 runTest 是基础设施,解决的是"协程代码怎么在测试里跑起来"。它提供了 TestDispatcherTestScope 等概念,但没有针对 Flow 的专用断言工具。你可以用 toList() 收集 Flow 的所有值,但那是终端操作,会挂起直到 Flow 完成,对无限流或需要中间断言的场景无能为力。


Kotest 的 kotest-assertions-coroutines 模块提供了一些 Flow 的断言扩展,比如 flow.shouldEmit(1, 2, 3)。它的风格更函数式,链式调用写起来很爽,但时序控制能力不如 Turbine。特别是需要验证"某个事件不应该发生"时,Kotest 的 API 比较别扭。


AppCash 自己还有一个 molecule 库,专门测试 Compose 的 StateFlow 衍生状态。它和 Turbine 有重叠,但定位更窄。如果你在写 Compose 的 MVI 架构,可以看看 molecule 的 testMolecule 函数,内部其实用了 Turbine。


我的建议是:runTest 打底,Turbine 处理所有 Flow 断言,Kotest 或 AssertJ 处理普通断言。三者不冲突,各司其职。


写在最后


Turbine 把 Flow 测试从"异步黑箱"变成了"同步白盒",这个转变的价值怎么强调都不为过。它不是一个复杂的库,核心代码不过几千行,但设计非常精准——只解决 Flow 测试的痛点,不越界。


它的坑主要集中在热流的缓存行为、testIn 的手动生命周期管理、以及与调度器配置的交互。这些不是设计缺陷,而是异步编程固有的复杂性。理解之后,写出来的测试既可靠又易读。


如果你还没试过,可以从一个现有的 runBlocking 测试开始迁移。把 toList()first() 换成 test { awaitItem() },感受下时序控制带来的确定性。这个投入绝对值得。


Turbine 在 Maven Central 的坐标是 app.cash.turbine:turbine,当前版本 1.1.0,Apache 2.0 协议,免费使用。GitHub 仓库 cashapp/turbine 有完整文档和示例。

Google 的架构组件示例仓库,Learning Path 2026-07-13
Layout Inspector 的进阶:3D 视图和属性检查 2026-07-13

评论区