使用delay()的Kotlin runTest不起作用



我正在测试一个阻塞的协程。这是我的生产代码:

interface Incrementer {
fun inc()
}
class MyViewModel : Incrementer, CoroutineScope {
override val coroutineContext: CoroutineContext
get() = Dispatchers.IO
private val _number = MutableStateFlow(0)
fun getNumber(): StateFlow<Int> = _number.asStateFlow()
override fun inc() {
launch(coroutineContext) {
delay(100)
_number.tryEmit(1)
}
}
}

还有我的测试:

class IncTest {
@BeforeEach
fun setup() {
Dispatchers.setMain(StandardTestDispatcher())
}
@AfterEach
fun teardown() {
Dispatchers.resetMain()
}
@Test
fun incrementOnce() = runTest {
val viewModel = MyViewModel()
val results = mutableListOf<Int>()
val resultJob = viewModel.getNumber()
.onEach(results::add)
.launchIn(CoroutineScope(UnconfinedTestDispatcher(testScheduler)))
launch(StandardTestDispatcher(testScheduler)) {
viewModel.inc()
}.join()
assertEquals(listOf(0, 1), results)
resultJob.cancel()
}
}

如何测试inc()函数?(接口是用石头刻的,所以我不能把inc()变成一个suspend函数。)

这里有两个问题:

  1. 您希望等待viewModel.inc()在内部启动的协同程序中完成的工作
  2. 理想情况下,100毫秒的延迟应该在测试过程中快进,这样实际上就不需要100毫秒来执行

让我们先从问题#2开始:为此,您需要能够修改MyViewModel(但不能修改inc),并更改类,以便它接收CoroutineContext作为参数,而不是使用硬编码的Dispatchers.IO。这样,您就可以在测试中传递TestDispatcher,这将使用虚拟时间来加快延迟。您可以在Android文档的Injecting TestDispatchers部分中看到这种模式。

class MyViewModel(coroutineContext: CoroutineContext) : Incrementer {
private val scope = CoroutineScope(coroutineContext)
private val _number = MutableStateFlow(0)
fun getNumber(): StateFlow<Int> = _number.asStateFlow()
override fun inc() {
scope.launch {
delay(100)
_number.tryEmit(1)
}
}
}

在这里,我还做了一些小的清理:

  • 使MyViewModel包含CoroutineScope,而不是实现接口,这是官方建议的做法
  • 删除了传递给launchcoroutineContext参数,因为在这种情况下它什么都不做——无论如何,相同的上下文都在作用域中,所以它已经被使用了

对于问题#1,等待工作完成,您有几个选项:

  • 如果您已经传入了TestDispatcher,则可以使用advanceUntilIdle等测试方法手动推进在inc内创建的协程。这并不理想,因为您在很大程度上依赖于实现细节,而这是您在生产中无法做到的。但如果你不能使用下面更好的解决方案,它会起作用。

    viewModel.inc()
    advanceUntilIdle() // Returns when all pending coroutines are done
    
  • 正确的解决方案是inc让其调用者知道它何时完成了工作。您可以将其作为一个挂起方法,而不是在内部启动一个新的协同程序,但您声明不能修改该方法使其挂起。如果您能够进行此更改,另一种选择是使用async构建器在inc中创建新的协同程序,返回创建的Deferred对象,然后在调用站点执行await()

    override fun inc(): Deferred<Unit> {
    scope.async {
    delay(100)
    _number.tryEmit(1)
    }
    }
    // In the test...
    viewModel.inc().await()
    
  • 如果不能修改方法或类,则无法避免delay()调用导致实际100ms延迟。在这种情况下,您可以强制测试等待该时间后再继续。runTest中的常规delay()将被快速转发,这要归功于它为创建的协同程序使用TestDispatcher,但您可以使用以下解决方案之一:

    // delay() on a different dispatcher
    viewModel.inc()
    withContext(Dispatchers.Default) { delay(100) }
    // Use blocking sleep
    viewModel.inc()
    Thread.sleep(100)
    

关于测试代码的一些最后注释:

  • 由于您正在执行Dispatchers.setMain,因此不需要将testScheduler传递到您创建的TestDispatchers中。如果他们在Main中找到一个TestDispatcher,他们会自动从那里获取调度程序,如文档中所述
  • 您可以简单地传入指向TestScoperunTest的接收器this,而不是创建一个新的作用域来传入launchIn

最新更新