如何在Kotlin协程测试中模拟isActive为false场景?
问题背景
需要覆盖如下挂起函数中while(isActive)循环退出后的异常抛出代码行,实现Jacoco全分支覆盖:
suspend fun doWork(): Result<Any> { while(isActive) { /* ... 执行业务逻辑 */ return something } throw Exception("...") }
目前已尝试两种方案,均未达到预期:
- 方案1:构造自定义的假Job实现。实现Job接口时发现部分方法带有
@InternalCoroutinesApi注解,属于协程内部非稳定API,方案可行性不足。 - 方案2:测试函数中创建内部协程主动取消后执行待测方法,测试代码如下:
@Test(expected = Exception::class) fun `test when job is not active`() = coroutineDispatcher.runBlockingTest { launch(coroutineContext) { cancel() val sut = doWork() } }
该方案存在测试误报问题,Jacoco无法统计到对应分支的覆盖率。
问题原因
第二种方案不生效的核心原因是协程取消是协作式的,cancel()调用后协程isActive状态的变更存在时序差:多数情况下doWork()执行时状态还未完成切换,要么走到了循环内的正常逻辑,要么协程因取消直接抛出CancellationException,根本不会执行到手动写的异常抛出代码行,Jacoco自然无法统计到该分支的覆盖。
可行解决方案
方案1:预构造已取消的协程作用域(最稳定无侵入)
不需要自行实现Job接口,直接通过公开稳定的Job API构造已取消的上下文,再在该上下文下执行待测方法即可:
@Test(expected = Exception::class) fun `test when job is not active`() = runTest { // 先创建一个已经完成取消的Job实例 val cancelledJob = Job().apply { cancel() } // 组合测试调度器构造测试作用域 val testScope = CoroutineScope(cancelledJob + testDispatcher) // 该作用域下isActive默认为false,执行doWork会直接跳过while循环走到异常分支 testScope.doWork() }
如果
doWork是某个业务类的方法,类内部持有自己的协程作用域,直接把业务类作用域的Job替换为上述预取消的Job实例即可,不需要修改业务代码,也不会用到内部API。
方案2:增加协程同步点修正时序问题(适配现有测试结构)
如果不想调整现有测试的整体结构,只需要在cancel和doWork调用之间增加一个协程挂起点,确保取消状态完成流转后再执行待测逻辑:
@Test(expected = Exception::class) fun `test when job is not active`() = runTest { val job = launch { cancel() // 主动让出执行权,等待协程取消状态完成同步 yield() doWork() } job.join() }
增加yield()后可以保证执行到doWork()时,协程的isActive状态已经更新为false,能够稳定走到循环外的异常分支,Jacoco可以正常采集覆盖率。
方案3:可测性优化(长期维护推荐)
如果isActive是业务类内部自定义的状态判断,而非协程Scope的自带属性,可以将判断逻辑抽为可注入的参数,从根源上避免协程上下文模拟的问题:
// 改造后的业务代码,将isActive判断抽为可配置的lambda,默认实现取协程Scope的isActive suspend fun doWork( isActive: () -> Boolean = { this.coroutineContext.isActive } ): Result<Any> { while(isActive()) { /* ... 执行业务逻辑 */ return something } throw Exception("...") }
测试时直接传入{ false }即可直接走到异常分支,没有任何时序不稳定问题,覆盖率可以100%被统计。
内容的提问来源于stack exchange,提问作者Gabriel Perez

