【问题标题】:Kotlin runTest with delay() is not working带有延迟()的 Kotlin runTest 不起作用
【发布时间】:2022-07-15 21:16:14
【问题描述】:

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

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() 变成一个挂起函数。)

【问题讨论】:

  • 它失败了,因为我相信你不会在这段代码的任何地方等待发射。 inc() 不会等待,所以join() 也不会等待,然后直接进入断言。但老实说,我很难理解您在这里尝试实现的目标。您尝试等待生产者完成,但在消费者端验证结果。即使生产者发出了一个项目,我们也不能保证消费者已经消费了它。我认为你应该等待消费者,而不是生产者,例如假设正好有 2 个项目要消费或在发射后关闭流程。
  • @broot 我想测试是否实际调用了生产者,并且在 resultJob 中收集的结果是否正确。我真的需要阻止测试,直到在 inc() 中启动的作业完成。我怀疑我需要通过测试调度程序,但我不知道如何。
  • 如果您需要阻止 inc() 直到它完成,那么在其中使用 runBlocking() 而不是 launch()。您在代码中使用了很多启动,这使得等待任何事情变得非常困难。尽管如此,我相信即使您等待inc() 完成,您也不能保证同时运行的收集器/消费者已经消耗了该项目。即使在模拟测试环境中运行时这是确定性的,它也可能在实际应用程序中失败。

标签: unit-testing kotlin kotlin-coroutines


【解决方案1】:

这里有两个问题:

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

让我们首先从问题 #2 开始:为此,您需要能够修改 MyViewModel(但不是 inc),并更改类,以便它接收一个而不是使用硬编码的 Dispatchers.IO CoroutineContext 作为参数。有了这个,您可以在测试中传递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而不是实现接口,即an officially recommended practice
  • 删除了传递给launchcoroutineContext 参数,因为在这种情况下它不做任何事情 - 无论如何,相同的上下文在范围内,所以它已经被使用了

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

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

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

    override fun inc(): Deferred<Unit> {
        scope.async {
            delay(100)
            _number.tryEmit(1)
        }
    }
    
    // In the test...
    viewModel.inc().await()
    
  • 如果您无法修改方法或类,则无法避免 delay() 调用导致真正的 100 毫秒延迟。在这种情况下,您可以强制您的测试在继续之前等待该时间。 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。如果他们在那里找到TestDispatcher,他们将自动从Main 获取调度程序,如in its docs 所述。
  • 您可以简单地传入this,即runTest 的接收者,它指向TestScope,而不是创建一个新的范围来传递给launchIn

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2011-05-31
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2020-02-03
    • 2013-08-20
    相关资源
    最近更新 更多