使用delay进行Kotlin协程的单元测试

44
我想单元测试一个使用了delay()的Kotlin协程。对于单元测试来说,我不关心delay(),它只会使测试变慢。我想以某种方式运行测试,当调用delay()时实际上不会延迟。

我尝试使用自定义上下文来委托CommonPool运行协程:

class TestUiContext : CoroutineDispatcher(), Delay {
    suspend override fun delay(time: Long, unit: TimeUnit) {
        // I'd like it to call this
    }

    override fun scheduleResumeAfterDelay(time: Long, unit: TimeUnit, continuation: CancellableContinuation<Unit>) {
        // but instead it calls this
    }

    override fun dispatch(context: CoroutineContext, block: Runnable) {
        CommonPool.dispatch(context, block)
    }
}

我原本希望从我的上下文的delay()方法中返回,但实际上它正在调用我的scheduleResumeAfterDelay()方法,而我不知道如何委托给默认调度程序。


你不能将延迟超时设置为可配置的,这样在测试中就可以选择非常小的一个吗?! - s1m0nw1
1
@s1m0nw1 是的,但我宁愿有一个通用解决方案,而不必像那样修改我的代码。 - Erik Browne
@eoinmullan,你能分享一个最小的Git项目吗? - Tarun Lalwani
4个回答

30

如果您不想延迟,为什么不直接恢复计划中的续传呢?

class TestUiContext : CoroutineDispatcher(), Delay {
    override fun scheduleResumeAfterDelay(time: Long, unit: TimeUnit, continuation: CancellableContinuation<Unit>) {
        continuation.resume(Unit)
    }

    override fun dispatch(context: CoroutineContext, block: Runnable) {
        //CommonPool.dispatch(context, block)  // dispatch on CommonPool
        block.run()  // dispatch on calling thread
    }
}

这样delay()将会没有延迟地恢复。请注意,这仍然会在延迟时暂停,因此其他协程仍然可以运行(例如yield()

@Test
fun `test with delay`() {
    runBlocking(TestUiContext()) {
        launch { println("launched") }
        println("start")
        delay(5000)
        println("stop")
    }
}

不延迟地运行并打印:

start
launched
stop

编辑:

您可以通过定制 dispatch 函数来控制连续运行的位置。


dispatch() 可以直接使用 block.run(),这将使执行保持在主线程中。 - Erik Browne
True,这在测试时可能是有意义的,尽管他似乎想要使用CommonPool。 - bj0
我是楼主,我希望它使用同一个线程。如果你做出更改,我会接受你的答案。 - Erik Browne
2
Delay 接口现在是 KotlinX 协程库中的内部 API。这个类以及使用它的任何类都必须标记为 @InternalCoroutinesApi 注解。 - Erik Browne

28
在 kotlinx.coroutines v1.6.0 中更新了 kotlinx-coroutines-test 模块。它允许测试使用 runTest() 方法和 TestScope 来测试挂起代码,自动跳过延迟。
有关如何使用该模块的详细信息,请参见 文档

之前的回答

在 kotlinx.coroutines v1.2.1 中添加了 kotlinx-coroutines-test 模块。它包括 runBlockingTest 协程构建器,以及 TestCoroutineScopeTestCoroutineDispatcher。它们允许自动推进时间,并明确控制协程的时间以测试带有 delay 的协程。

1
@Erik Browne分享了很棒的资源!具体来说,您如何实现runBlockingTestTestCoroutineScopeTestCoroutineDispatcher来处理单元测试中的delay - AdamHurwitz
我已经在这个解决方案上进行了扩展,并提供了一个具体的实现在这里 - AdamHurwitz
1
在 kotlinx.coroutines v1.6.0 中,他们重构了测试库,用 TestScopeTestDispatcher 替换了 TestCoroutineScopeTestCoroutineDispatcher - Erik Browne
1
@ErikBrowne,我建议您编辑您的答案,以突出使用新版Kotlin协程(1.6.0)的最新建议。 - Amokrane Chentir

15

使用TestCoroutineDispatcher、TestCoroutineScope或Delay

在测试生产代码中创建的 Kotlin 协程中,可以使用 TestCoroutineDispatcher、TestCoroutineScope 或 Delay 来处理delay

实现

在本例中,正在测试 SomeViewModel 的视图状态。在 ERROR 状态下,发出一个带有错误值为 true 的视图状态。在定义的 Snackbar 时间长度过去后,使用 delay 发出一个新的视图状态,其中错误值设置为 false。

SomeViewModel.kt

private fun loadNetwork() {
    repository.getData(...).onEach {
        when (it.status) {
            LOADING -> ...
            SUCCESS ...
            ERROR -> {
                _viewState.value = FeedViewState.SomeFeedViewState(
                    isLoading = false,
                    feed = it.data,
                    isError = true
                )
                delay(SNACKBAR_LENGTH)
                _viewState.value = FeedViewState.SomeFeedViewState(
                    isLoading = false,
                    feed = it.data,
                    isError = false
                )
            }
        }
    }.launchIn(coroutineScope)
}

有许多方法可以处理delay。使用advanceUntilIdle是不错的选择,因为它不需要指定硬编码长度。而且,如果像 Craig Russell 所述在这里注入 TestCoroutineDispatcher,那么这将由 ViewModel 内部使用的相同调度程序处理。

SomeTest.kt

private val testDispatcher = TestCoroutineDispatcher()
private val testScope = TestCoroutineScope(testDispatcher)

// Code that initiates the ViewModel emission of the view state(s) here.

testDispatcher.advanceUntilIdle()

这些也可以工作:

  • testScope.advanceUntilIdle()
  • testDispatcher.delay(SNACKBAR_LENGTH)
  • delay(SNACKBAR_LENGTH)
  • testDispatcher.resumeDispatcher()
  • testScope.resumeDispatcher()
  • testDispatcher.advanceTimeBy(SNACKBAR_LENGTH)
  • testScope.advanceTimeBy(SNACKBAR_LENGTH)

没有处理延迟的错误

kotlinx.coroutines.test.UncompletedCoroutinesError: 在拆卸期间未完成协程。确保测试时所有协程都已完成或取消。

at kotlinx.coroutines.test.TestCoroutineDispatcher.cleanupTestCoroutines(TestCoroutineDispatcher.kt:178) at app.topcafes.FeedTest.cleanUpTest(FeedTest.kt:127) at app.topcafes.FeedTest.access$cleanUpTest(FeedTest.kt:28) at app.topcafes.FeedTest$topCafesTest$1.invokeSuspend(FeedTest.kt:106) at app.topcafes.FeedTest$topCafesTest$1.invoke(FeedTest.kt) at kotlinx.coroutines.test.TestBuildersKt$runBlockingTest$deferred$1.invokeSuspend(TestBuilders.kt:50) at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33) at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:56) at kotlinx.coroutines.test.TestCoroutineDispatcher.dispatch(TestCoroutineDispatcher.kt:50) at kotlinx.coroutines.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:288) at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:26) at kotlinx.coroutines.CoroutineStart.invoke(CoroutineStart.kt:109) at kotlinx.coroutines.AbstractCoroutine.start(AbstractCoroutine.kt:158) at kotlinx.coroutines.BuildersKt__Builders_commonKt.async(Builders.common.kt:91) at kotlinx.coroutines.BuildersKt.async(Unknown Source) at kotlinx.coroutines.BuildersKt__Builders_commonKt.async$default(Builders.common.kt:84) at kotlinx.coroutines.BuildersKt.async$default(Unknown Source) at kotlinx.coroutines.test.TestBuildersKt.runBlockingTest(TestBuilders.kt:49) at kotlinx.coroutines.test.TestBuildersKt.runBlockingTest(TestBuilders.kt:80) at app.topcafes.FeedTest.topCafesTest(FeedTest.kt:41) at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.lang.reflect.Method.invoke(Method.java:498) at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50) at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47) at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325) at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78) at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57) at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290) at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71) at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288) at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58) at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268) at org.junit.runners.ParentRunner.run(ParentRunner.java:363) at org.junit.runner.JUnitCore.run(JUnitCore.java:137) at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:68) at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:33) at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:230) at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter


2
如果我使用 testDispatcher.advanceTimeBy,我会得到未完成协程的错误,我不确定如何解决。不过,testDispatcher.advanceUntilIdle 运行得很好。 - hmac
很高兴听到advanceUntilIdle能够为您完成工作。在TestCoroutineDispatcher().runBlockingTest{...}中运行测试可能会解决未完成的协程错误,正如Craig Russell在此处所述 - AdamHurwitz

5
在kotlinx.coroutines v0.23.0中,他们引入了TestCoroutineContext
优点:它使得使用delay真正测试协程成为可能。您可以将CoroutineContext的虚拟时钟设置为某个时间点并验证预期行为。
缺点:如果您的协程代码不使用delay,并且您只想在调用线程上同步执行它,则与@bj0的答案中的TestUiContext相比,使用起来会稍微麻烦一些(您需要在TestCoroutineContext上调用triggerActions()才能执行协程)。
旁注: 从协程版本1.2.1开始,TestCoroutineContext现在位于kotlinx-coroutines-test模块中,并且在此版本以上的标准协程库中将被标记为已弃用或不存在。

一个快速的更新:TestCoroutineContext已经在协程库的后续版本中被弃用。 - Bwvolleyball
在v1.2.1中,他们添加了实验性的kotlinx-coroutines-test模块,其中包括runBlockingTestTestCoroutineScopeTestCoroutineDispatcher - Erik Browne

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接