Reputation: 1523
I'm trying to unit test a Kotlin coroutine that uses delay()
. For the unit test I don't care about the delay()
, it's just slowing the test down. I'd like to run the test in some way that doesn't actually delay when delay()
is called.
I tried running the coroutine using a custom context which delegates to 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)
}
}
I was hoping I could just return from my context's delay()
method, but instead it's calling my scheduleResumeAfterDelay()
method, and I don't know how to delegate that to the default scheduler.
Upvotes: 50
Views: 48116
Reputation: 1523
In kotlinx.coroutines v1.6.0 the kotlinx-coroutines-test module was updated. It allows tests to use the runTest()
method and TestScope
to test suspending code, automatically skipping delays.
See the documentation for details on how to use the module.
In kotlinx.coroutines v1.2.1 they added the kotlinx-coroutines-test module. It includes the runBlockingTest
coroutine builder, as well as a TestCoroutineScope
and TestCoroutineDispatcher
. They allow auto-advancing time, as well as explicitly controlling time for testing coroutines with delay
.
Upvotes: 32
Reputation: 10364
TestCoroutineDispatcher, TestCoroutineScope, or Delay can be used to handle a delay
in a Kotlin coroutine made in the production code tested.
In this case SomeViewModel's view state is being tested. In the ERROR
state a view state is emitted with the error value being true. After the defined Snackbar time length has passed using a delay
a new view state is emitted with the error value set to 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)
}
There are numerous ways to handle the delay
. advanceUntilIdle
is good because it doesn't require specifying a hardcoded length. Also, if injecting the TestCoroutineDispatcher, as outlined by Craig Russell, this will be handled by the same dispatcher used inside of the 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()
These will also work:
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: Unfinished coroutines during teardown. Ensure all coroutines are completed or cancelled by your test.
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.java:58)
Upvotes: 15
Reputation: 1523
In kotlinx.coroutines v0.23.0 they introduced a TestCoroutineContext.
Pro: it makes truly testing coroutines with delay
possible. You can set the CoroutineContext's virtual clock to a moment in time and verify the expected behavior.
Con: if your coroutine code doesn't use delay
, and you just want it to execute synchronously on the calling thread, it is slightly more cumbersome to use than the TestUiContext
from @bj0's answer (you need to call triggerActions()
on the TestCoroutineContext to get the coroutine to execute).
Sidenote:
The TestCoroutineContext
now lives in the kotlinx-coroutines-test
module starting with coroutines version 1.2.1, and will be marked deprecated or not exist in the standard coroutine library in versions above this version.
Upvotes: 5
Reputation: 8213
If you don't want any delay, why don't you simply resume the continuation in the schedule call?:
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
}
}
That way delay()
will resume with no delay. Note that this still suspends at delay, so other coroutines can still run (like yield()
)
@Test
fun `test with delay`() {
runBlocking(TestUiContext()) {
launch { println("launched") }
println("start")
delay(5000)
println("stop")
}
}
Runs without delay and prints:
start
launched
stop
EDIT:
You can control where the continuation is run by customizing the dispatch
function.
Upvotes: 31