带延迟()的 Kotlin runTest 不起作用

Rob*_*ert 4 unit-testing kotlin kotlin-coroutines

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

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)
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

我的测试:

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()
    }
}
Run Code Online (Sandbox Code Playgroud)

我将如何测试我的inc()函数?(界面是一成不变的,所以我无法将inc()变成挂起函数。)

zsm*_*b13 6

这里有两个问题:

  1. 您想要等待内部启动的协程中完成的工作viewModel.inc()
  2. 理想情况下,100ms 的延迟应该在测试期间快进,这样实际上就不会花费 100ms 来执行。

让我们首先从问题 #2 开始:为此,您需要能够修改MyViewModel(但不能inc)并更改类,以便Dispatchers.IO它接收 aCoroutineContext作为参数,而不是使用硬编码。这样,您可以传入TestDispatcher测试,这将使用虚拟时间来快进延迟。您可以在Android 文档的注入 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)
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

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

  • 制作MyViewModel包含CoroutineScope而不是实现接口,这是官方推荐的做法
  • 删除了coroutineContext传递给 的参数launch,因为在这种情况下它不执行任何操作 - 无论如何,相同的上下文都在范围内,因此它已经被使用

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

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

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

    override fun inc(): Deferred<Unit> {
        scope.async {
            delay(100)
            _number.tryEmit(1)
        }
    }
    
    // In the test...
    viewModel.inc().await()
    
    Run Code Online (Sandbox Code Playgroud)
  • 如果您无法修改方法或类,则无法避免调用delay()导致真正的 100 毫秒延迟。在这种情况下,您可以强制测试等待该时间然后再继续。由于它使用 a 作为它创建的协程,因此常规delay()内部runTest将被快进TestDispatcher,但您可以使用以下解决方案之一:

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

有关测试代码的一些最后说明:

  • 既然你正在做Dispatchers.setMain,你就不需要传入testSchedulerTestDispatchers创建的。Main如果他们找到那里,他们会自动获取调度程序,如其文档中TestDispatcher所述。
  • launchIn您可以简单地传入指向 的this接收者runTest,而不是创建一个新的作用域来传递给TestScope