This is part of a series of posts on Coroutine Testing:
- Picking the right Dispatcher
- Never ending tests & backgroundscope
- Controlling time ←
- Helpful @Junit TestRule extension (coming soon)
- Full USF example for Android (coming soon)
My journey with coroutine testing started with this “simple” requirement — to control virtual time in concurrent logic.
From my previous post:
… If you don’t use a
StandardTestDispatcher
explicitly, then operators likerunCurrent
,advanceTimeBy
etc. have no meaning.
I have a confession to make. I was coy saying “have no meaning”. I didn’t say “won’t work” because in reality those apis will “work” just not in the way you’d expect.
Let’s dive deeper.
The 3 apis
There are 3 important apis in coroutine tests to play with time:
runCurrent
- execute tasks scheduled at the current moment of virtual timeadvanceTimeBy
- advance virtual time by a number of milliseconds and then execute tasks scheduled in the meantime.advanceUntilIdle
- similar to advanceTimeBy but instead of advancing by a specific number of milliseconds, it keeps advancing until no more scheduled tasks are found.
In the context of coroutine testing, when you think of “time”, it refers simply to the order that the TestCoroutineScheduler
decides to execute your scheduled coroutines. That’s why the definitions above stress on words “execute” vs “scheduled”.
In reality, these apis belong to the class TestCoroutineScheduler
(not StandardTestDispatcher as the coy comment might have indicated).
Let’s attempt to understand this all, with some code.
StandardTestDispatcher & the 3 apis
This animation should illustrate how the different apis are designed to work1.
StandardTestDispatcher
is good about respecting the 3 apis.
A basic test demonstrating the use of runCurrent
that shouldn’t be surprising:
@Test
fun test() = runTest(StandardTestDispatcher()) {
var result = "X"
launch {
result = "A"
delay(1.seconds)
result = "B"
result = "C"
delay(2.seconds)
result = "D"
}
assertThat(result)
.isEqualTo("X")
runCurrent()
assertThat(result)
.isEqualTo("A")
}
Before runCurrent
the coroutine isn’t launched so we expect the result “X”. On runCurrent
we execute any scheduled coroutines without advancing the virtual clock, so expected result emitted2 is A.
Let’s advanceTimeBy
exactly 1 second (the first delay):
// ...
launch {
result = "A"
delay(1.seconds)
result = "B"
result = "C"
delay(2.seconds)
result = "D"
}
advanceTimeBy(1.seconds)
assertThat(result)
.isEqualTo("A")
.isNotEqualTo("B")
.isNotEqualTo("C")
Notice that even though we advanced time by the delay, the result hasn’t updated or changed from A. This is because you’ve scheduled the next emit with the delay but haven’t executed it yet.
Throw in a runCurrent
like before and it’ll proceed to execute, giving you the result you expect:
// ...
launch {
result = "A"
delay(1.seconds)
result = "B"
result = "C"
delay(2.seconds)
result = "D"
}
advanceTimeBy(1.seconds)
assertThat(result)
.isEqualTo("A")
.isNotEqualTo("B")
.isNotEqualTo("C")
runCurrent()
assertThat(result)
.isEqualTo("C")
To really test your understanding - what happens if you advance time by 2 seconds (greater than the first delay of 1 second but less than the next one of 2 more seconds):
// ...
launch {
result = "A"
delay(1.seconds)
result = "B"
result = "C"
delay(2.seconds)
result = "D"
}
advanceTimeBy(2.seconds)
assertThat(result)
.isEqualTo("C")
Advancing it by 2 seconds means you’ve gone past the initial one second delay (where the job is scheduled) + an additional 1 second, nudges the job to execute (releasing B & C). We don’t need a runCurrent
here like the previous example because you’ve nudged the virtual clock past the scheduled time, well into execution. This demonstrates how going past the scheduled delay -even by a small amount- will automatically execute on the results.
This entire test should now make sense:
@Test
fun test() = runTest(StandardTestDispatcher()) {
var result = "X"
launch {
result = "A"
delay(1.seconds)
result = "B"
result = "C"
delay(2.seconds)
result = "D"
}
advanceTimeBy(1.seconds)
// result A executed
// schedule B & C
assertThat(result)
.isEqualTo("A")
runCurrent()
// execute B & C
assertThat(result)
.isEqualTo("C")
runCurrent()
// no-op (no time advancement)
assertThat(result)
.isEqualTo("C")
advanceTimeBy(2.seconds)
// schedule D
assertThat(result)
.isEqualTo("C")
runCurrent()
// execute D
assertThat(result)
.isEqualTo("D")
// 😉
}
The key point to take away with StandardTestDispatcher is that it respects the apis but you have to really understand the difference between scheduling a job and executing it.
UnconfinedTestDispatcher & the 3 apis
This is where things get complicated. Don’t take my word for it , even the docs say so:
“Unusual” is correct.
I want to first point you to some source code that will help build the necessary context. If you look at the implementation for both TestDispatchers, you’ll see that they both utilize a TestCoroutineScheduler
:
Remember TestCoroutineScheduler
is effectively your virtual clock and is the actual class that provides the 3 big apis. So it follows that UnconfinedTestDispatcher
has some effect from the apis.
There’s two methods of interest isDispatchNeeded
and dispatch
.
Looking at the implementation for the method dispatch
in both TestDispatcher classes. You can piece together some things:
// UnconfinedTestDispatcher
override fun isDispatchNeeded(context: CoroutineContext): Boolean = false
override fun dispatch(context: CoroutineContext, block: Runnable) {
checkSchedulerInContext(scheduler, context)
scheduler.sendDispatchEvent(context)
//...
}
// StandardTestDispatcher
// fun isDispatchNeeded not overridden so defaults to true
override fun dispatch(context: CoroutineContext, block: Runnable) {
scheduler.registerEvent(this, 0, block, context) { false }
}
UnconfinedTestDispatcher sends the dispatch event right away. It further overrides isDispatchNeeded
as false
meaning - execute the coroutine immediately, don’t first register (or schedule) the coroutine just send or dispatch.
StandardTestDispatcher on the other hand is “simpler” and merely registers (or schedules) the event to be dispatched. When does it get executed or dispatched then if right away? That’s where the 3 apis come in 💡. This is why it feels like the 3 apis to advance time have more meaning with StandardTestDispatcher.
Let’s look at test code for UnconfinedTestDispatcher
similar to our StandardTestDispatcher examples. This is a super basic test for UnconfinedTestDispatcher.
@Test
fun test() = runTest(UnconfinedTestDispatcher()) {
var result = "X"
launch {
result = "A"
delay(1.seconds)
result = "B"
result = "C"
delay(2.seconds)
result = "D"
}
assertThat(result).isEqualTo("A")
}
Already, we start with some unusual-ness. Notice that we didn’t runCurrent
but the very first value “A” was eagerly emitted 3. In the case of StandardTestDispatcher, the value would have been “X”.
The docs actually point this behavior out:
* Like [Dispatchers.Unconfined], this one does not provide guarantees about the execution order when several coroutines
* are queued in this dispatcher. However, we ensure that the [launch] and [async] blocks at the top level of [runTest]
* are entered eagerly. This allows launching child coroutines and not calling [runCurrent] for them to start executing.
The funny thing is UnconfinedTestDispatcher will actually behave very similarly to StandardTestDispatcher now in terms of advancing time (after it is eagerly launched):
launch {
result = "A"
delay(1.seconds)
result = "B"
result = "C"
delay(2.seconds)
result = "D"
}
assertThat(result).isEqualTo("A")
// now it'll behave like StandardTestDispatcher
advanceTimeBy(1.seconds)
runCurrent()
assertThat(result).isEqualTo("C")
advanceTimeBy(2.seconds.plus(1.milliseconds))
assertThat(result).isEqualTo("D")
So it does respect the 3 apis but only after eagerly launching. Hopefully that clarifies the original comment.
To be honest I’ve asked myself many times what the point of having both kinds of TestDispatchers is, if this remains the only big difference. One important reason is that seismic change in apis with 1.6.0 — older testing apis like runBlockingTest
(now deprecated) used to launch coroutines eagerly to make testing less tedious, so it was an easier migration path to swap in place with UnconfinedTestDispatcher
s.
Here’s my takeaway between the two dispatchers:
- UnconfinedTestDispatcher launches the coroutine eagerly (explained above) which makes it less tedious during testing
- Order is not guaranteed with UnconfinedTestDispatcher, so StandardTestDispatcher is more predictable especially in complicated concurrent scenarios
- UnconfinedTestDispatcher is good about collecting every single item (this doesn’t have to do with time) but maybe in a future post I’ll show how testing
StateFlow
s can benefit from UnconfinedTestDispatcher.
Just as manual and automatic transmission cars offer different levels of control and convenience in driving, the
UnconfinedTestDispatcher
andStandardTestDispatcher
in Kotlin coroutines provide distinct approaches for managing the execution of test code.
I personally do prefer using StandardTestDispatcher
because I’m not one to shy away from boilerplate, so I happily trade off tediousness with predictability but your mileage might vary.
My thanks to Márton for reviewing this post. Next up: A Helpful @Junit CoroutineTestRule extension (coming soon)
-
I spent way too much time making this animation, so I’m dropping it right at the start. Though if I’m being honest, it’s faster to look at the following code and understand. ↩︎
-
I’m using the terminology
emit
because the animation was meant to illustrate aMutableStateFlow
. For the tests shown here, think of emit as theresult
being assigned a value. ↩︎ -
Again I’m using the terminology
emit
as before, but think of emit as theresult
being assigned a value. ↩︎