23、Flow与测试进阶:Flow的虚拟时间测试、Flow的断言库、集成测试

说实话,很多Android开发者写Flow的时候挺爽的,一到测试就头疼。我早期带团队的时候,就见过不少人因为测试写不好,干脆不测了,结果线上出了几次数据竞争的问题。嗯,今天我们就来把这层窗户纸捅破。

Flow的测试,说白了就三块:虚拟时间控制专门的断言工具、以及集成测试的套路。我们一个一个来拆。

虚拟时间测试:让时间为你加速

你想想看,如果你的Flow里有个delay(5秒),测试真要等5秒?那肯定不行。Kotlin协程提供了TestCoroutineSchedulerStandardTestDispatcher,可以让我们手动控制时间。

我个人习惯在测试基类里这样配置:

class FlowTestBase {

    protected val testScheduler = TestCoroutineScheduler()
    protected val testDispatcher = StandardTestDispatcher(testScheduler)

    protected fun advanceTimeBy(ms: Long) {
        testScheduler.advanceTimeBy(ms)
    }

    protected fun advanceUntilIdle() {
        testScheduler.advanceUntilIdle()
    }
}

然后测试的时候,你可以像导演喊“卡”一样,让时间快进:

class MyViewModelTest : FlowTestBase() {

    @Test
    fun `debounce 应该延迟发射`() = runTest(testDispatcher) {
        val flow = flow {
            emit(1)
            delay(100)
            emit(2)
        }.debounce(50)

        val results = mutableListOf<Int>()
        launch {
            flow.collect { results.add(it) }
        }

        advanceTimeBy(60)  // 跳过第一个debounce窗口
        assertEquals(listOf(1), results)

        advanceTimeBy(60)  // 跳过第二个debounce窗口
        assertEquals(listOf(1, 2), results)
    }
}

我曾经在项目里遇到过一个坑:用runTest但不传testDispatcher,结果虚拟时间根本不起作用。记住,runTest默认用的是UnconfinedTestDispatcher,它不会自动推进虚拟时间。你需要显式传入StandardTestDispatcher

注意:虚拟时间只对同一个TestCoroutineScheduler下的协程有效。如果你的Flow内部用了Dispatchers.IO,那虚拟时间就管不到了。我建议在测试中统一替换调度器。

Flow的断言库:Turbine 真香

手动收集Flow然后断言,写多了真的很累。而且容易漏掉一些边界情况,比如Flow有没有正常结束,有没有抛异常。这时候就需要专门的断言库了。

我个人最常用的是 Turbine,它让Flow测试变得像写诗一样优雅。

先加依赖:

testImplementation("app.cash.turbine:turbine:1.0.0")

然后你就可以这样写测试:

class UserRepositoryTest {

    @Test
    fun `用户数据流应该按顺序发射`() = runTest {
        val repository = UserRepository()

        repository.userFlow.test {
            // 等待并断言第一个值
            assertEquals(User.Loading, awaitItem())

            // 等待并断言第二个值
            val user = awaitItem()
            assertTrue(user is User.Success)
            assertEquals("Alice", (user as User.Success).name)

            // 确认Flow正常结束
            awaitComplete()
        }
    }
}

你看,awaitItem()会阻塞直到下一个值发射,awaitComplete()等待Flow结束。如果Flow应该抛异常,可以用awaitError()

小技巧:Turbine 默认会超时,默认是1秒。如果你的Flow有延迟,记得用test(timeout = 5_000)来调整。我一般会在测试基类里统一设置。

为什么说Turbine香?因为它帮你处理了背压取消的问题。你自己写collect的时候,很容易忘记在测试结束后取消协程,导致泄漏。Turbine在test块结束后会自动取消。

集成测试:把Flow放进真实环境

单元测试只能测逻辑,集成测试才能暴露真实问题。比如Room的Flow、Retrofit的Flow、DataStore的Flow,这些都需要在集成测试中验证。

我建议用 Hilt 的测试支持 或者 Koin 的测试模块 来替换真实依赖。这里以Room为例:

@HiltAndroidTest
class UserDaoIntegrationTest {

    @Inject
    lateinit var userDao: UserDao

    @Inject
    lateinit var database: AppDatabase

    @Before
    fun setup() {
        // 使用内存数据库,每次测试后自动清理
        // Hilt 会注入测试用的数据库实例
    }

    @Test
    fun `插入用户后Flow应该发射新数据`() = runTest {
        val user = User(id = 1, name = "Bob")

        // 启动收集
        val job = launch {
            userDao.getAllUsers().test {
                // 初始状态应该是空列表
                assertEquals(emptyList(), awaitItem())

                // 插入数据
                userDao.insert(user)

                // 应该收到包含新用户的数据
                val users = awaitItem()
                assertEquals(1, users.size)
                assertEquals("Bob", users[0].name)

                awaitComplete()
            }
        }

        // 等待测试完成
        job.join()
    }

    @After
    fun teardown() {
        database.close()
    }
}

这里有个关键点:Room的Flow是冷流,每次有新的collector都会重新查询。所以在集成测试中,你要确保collect在insert之前就已经启动了,否则可能会漏掉初始值。

我曾经在集成测试中踩过一个坑:用runBlockingTest(已废弃)来测试Room Flow,结果因为调度器不匹配,Flow根本不发射。后来全部换成runTest + StandardTestDispatcher才解决。

核心知识体系

下面这张图总结了Flow测试的三个层次,以及它们之间的关系:

Flow 测试三层架构 第一层:虚拟时间测试 TestCoroutineScheduler + StandardTestDispatcher 控制 delay、debounce、sample 等时间操作 第二层:Flow 断言库 Turbine:awaitItem / awaitComplete / awaitError 自动处理背压、取消、超时 第三层:集成测试 Room Flow / Retrofit Flow / DataStore Flow Hilt/Koin 测试模块 + 内存数据库

常见问题与避坑指南

问题 原因 解决方案
虚拟时间不生效 使用了 UnconfinedTestDispatcher 或未传入调度器 显式使用 StandardTestDispatcher
Turbine 测试超时 Flow 有延迟但未设置超时时间 使用 test(timeout = ...) 调整
集成测试中 Flow 不发射 collect 在 insert 之后才启动 先启动 collect,再执行数据操作
测试结束后协程泄漏 未取消 collect 协程 使用 Turbine 的 test 块自动管理

核心原则:测试 Flow 时,永远记住 Flow 是冷流还是热流。冷流(如 Room Flow)每次 collect 都会重新执行,热流(如 StateFlow)只发射最新值。这个区别决定了你的测试策略。

嗯,Flow 测试其实没那么玄乎。掌握了虚拟时间控制、用好 Turbine、再加上集成测试的套路,你就能写出又快又稳的测试代码。我个人觉得,测试写得好不好,直接决定了你重构的时候敢不敢下手。所以,别偷懒,把测试写起来。

公众号:蓝海资料掘金营,微信deep3321