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)
Most of the problems and flakiness around coroutine testing stem from running them on different Dispatchers
. This is because the choice of Dispatcher can significantly impact the behavior of coroutines. This was also the most confusing1 part for me starting out — understanding the implications of using a Scope
, Context
or Dispatcher
.
I recommend Roman’s article if you want to brush up on the fundamentals. But in a nutshell: think of CoroutineContext
as a collection of elements that define the coroutine. It contains a Dispatcher
, Job
& a CoroutineName
. When you launch a coroutine, it inherits the parent’s CoroutineContext
(and Dispatcher), unless you specify it explicitly.
A CoroutineScope
on the other hand is just a way to manage and cancel (multiple) coroutines. It also defines a context and lifecycle for the coroutines launched within it (the context could be linked to yet another Dispatcher). Any coroutine when launched, runs within a CoroutineScope
.
Let’s take an example:
Notice how the current Dispatcher
of the coroutine shifts from StandardTestDispatcher
→ UnconfinedTestDispatcher
→ Dispatcher.IO
in the span of three innocuous lines based on the coroutine builder (runTest
) or scope used (TestScope
, turbineScope
from the 3rd party library, App scope
).
In my initial post I pointed out this flaky test:
The fix for this is as simple as explicitly injecting a TestScope
and making sure the same scope is used throughout.
Explicitly injecting the CoroutineScope
and substituting it with the TestScope
works really well and is my preferred strategy. This approach allows for more control over the Dispatcher used in tests . For reasons you’ll see later, these tests also run instantly (72ms vs 6s 82ms).
But the story doesn’t end there.
If you look at the official android docs, they recommend using the same Scheduler
. Not a Scope
or Dispatcher
.
What is a Scheduler?
This got me thinking… what Scheduler
do my tests use? Easy enough to check:
StandardTestDispatcher[
scheduler=TestCoroutineScheduler@1623134f
]
StandardTestDispatcher
& TestCoroutineScheduler
- the plot thickens. What even is a Scheduler
? Rx had this concept but with Coroutines, Dispatchers is the only thing we deal with.
Let’s dive deeper.
runTest
is the basic api we use for coroutine testing and like other coroutine builders (runBlocking , launch, async etc.) implicitly uses a CoroutineScope
. For testing, there is a special scope called TestScope
. And given that CoroutineScope
s get linked to a Dispatcher
2, for this special scope there exists a special TestDispatcher
(more on this later).
When testing coroutines, we often need precise control over their execution timing, especially when dealing with virtual time. TestCoroutineScheduler
is the construct that provides us this fine-grained control. This is also why you don’t see or use Schedulers in production or your app code, where there isn’t this need.
In a future post, I’ll show you a @Junit
test rule that makes sure the TestCoroutineScheduler
is shared across injected dispatchers.
But before we get into that, let’s look at that official android doc again:
Any number of TestDispatcher
s? How many are there?
TestDispatchers
In testing, instead of using one of the regular Dispatchers (Default, IO, Main, Unconfined) you need to use a special TestDispatcher
. Think of the TestCoroutineScheduler
as the virtual clock and TestDispatchers as special Dispatchers. These special Dispatchers use the virtual clock to schedule and manage the execution of coroutines in tests.
TestDispatcher
itself is an abstract class though and there exists two implementations that we can use - StandardTestDispatcher
& UnconfinedTestDispatcher
. Craig has an excellent post comparing the two but in a nutshell:
StandardTestDispatcher:
- does not execute tasks automatically3
- you have full control over what is executed and when
- you have to use methods like
runCurrent
,advanceTimeBy
&advanceUntilIdle
if not specified explicitly,
runTest
will use aStandardTestDispatcher
by default
UnconfinedTestDispatcher:
- starts new coroutines eagerly (which means you don’t need to manually advance the coroutines in your test)
- there’s no guarantee on the order of your coroutines launched
- far more convenient to use in tests (especially if you don’t care about concurrency in your tests).
Eager loading of coroutines in tests
The fact that StandardTestDispatcher
does not execute tasks automatically lands up being a pretty important difference in the world of concurrency. UnconfinedTestDispatcher
s are more convenient to use but you trade-off on a deterministic test run.
You’ll observe that the extremely convenient coroutine Flow
testing library Turbine uses an UnconfinedTestDispatcher
internally4.
My complaint in the initial post around why advanceTimeBy
wasn’t working, should now make sense. If you don’t use a StandardTestDispatcher
explicitly, then operators like runCurrent
, advanceTimeBy
etc. have no meaning5. This is because coroutines are loaded eagerly, especially with vanilla Turbine test usage.
I strongly recommend using
StandardTestDispatcher
when you care about concurrency.
Phew! You’re now primed with the necessary fundamentals for Coroutine testing. The following posts should be targeted and a breeze.
My thanks to Márton & Robert for reviewing this post. Next up: Never ending tests & backgroundscope
-
and arguably one of the ways coroutines is more complex than RxJava ↩︎
-
In an earlier version of this post I mentioned CoroutineScopes contain a Dispatcher. This is strictly not true. It can for example have an
EmptyCoroutineContext
which is the object that usually contains the Dispatcher, but the EmptyCoroutineContext doesn’t specify aDispatcher
at all. When youlaunch
though using a builder function, that function supplies the scope, since there’s nothing else to use. ↩︎ -
There’s a nuance here again. Most places mention the tasks do not execute automatically. That’s strictly not true. StandardTestDispatcher itself executes them for example if you manually yield the test thread. But the point still stands in terms of observed behavior/functionality. ↩︎
-
I can go into the nuances of Turbine in a future post. It’s more involved than a simple UnconfinedTestDispatcher replacement. ↩︎
-
Make sure to read my post on controlling time for more on this. ↩︎