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)
If you’ve spent some time testing Coroutines this exception should look familiar:
After waiting for 1m,
the test coroutine is not completing,
there were active child jobs
This tends to happen when you have a coroutine job in your test, that fails to complete on its own. Let’s take a simple example.
We use a Channel
here which is the proverbial event bus for Coroutines.
Channels don’t terminate on their own. So when you run a simple test checking the emission, while the items might get collected correctly per the assert statement in the test, the test itself fails like so:
The test here is waiting for that coroutine job to complete, which in turn requires the Channel to, but that never happens and the test times out.
Where else would I run into this problem?
Channels aren’t the only case you’ll run into this problem. For example if you use a “hot” Flow like SharedFlow
or StateFlow
, they don’t terminate on their own, so the onus is on you to complete or cancel their Job
in tests.
Android developers can frequently run into this problem too if you use ViewModel
s and have an internal StateFlow
providing your “view level data” (what i personally like to call “view state”).
You typically use the viewModelScope
to launch internal coroutine jobs in a ViewModel. The OS then calls the lifecycle method onClear
when the Activity
or Fragment
no longer needs the ViewModel where all jobs started in the viewModelScope are canceled.
But when unit testing these ViewModels, you don’t have access to the viewModelScope
and shouldn’t need it anyway.
Solving this problem
There’s two ways to solve this problem:
1. Manually cancel the Job
If you have access to the Job
that spawns the never-ending coroutine, it’s trivial to cancel the Job. Here’s that first test, now passing:
A simple job.cancel
does the trick.
2. Use a backgroundScope
But you don’t always have access to the Job
directly or it requires a level of pass-through that makes your code cumbersome.
That’s where backgroundScope
comes in and is a pretty elegant solution to this problem.
Instead of getting access to the job directly, launch the desired coroutines within a backgroundScope
.
backgroundScope
is a special construct provided in coroutine tests alone that makes sure all child coroutines are explicitly canceled once the test body completes.
How does backgroundScope work?
The beauty of Kotlin is everything is open source. Let’s go find the code that that powers this magic:
The relevant code can be found as part of runTest
in TestBuilders.kt
.Here’s the interesting parts of the implementation:
public fun TestScope.runTest(
timeout: Duration = DEFAULT_TIMEOUT.getOrThrow(),
testBody: suspend TestScope.() -> Unit
) { scope ->
// ...
createTestResult {
val testBodyFinished = AtomicBoolean(false)
scope.start(CoroutineStart.UNDISPATCHED, scope) {
yield()
try {
testBody()
} finally {
testBodyFinished.value = true
}
}
// ...
try {
withTimeout(timeout) {
// ...
testBodyFinished.value && activeChildren.isNotEmpty() ->
"there were active child jobs: $activeChildren. " // ...
testBodyFinished.value ->
"the test completed, but only after the timeout"
else -> "the test body did not run to completion"
}
} catch (_: TimeoutCancellationException) {
// ...
} finally {
backgroundScope.cancel()
// ...
}
}
}
Make backgroundScope
a regular part of your testing arsenal. In most of my tests these days, I liberally use and inject backgroundScope
to make sure those tests don’t hang.
Next up: Controlling time