Tip for testing Kotlin Coroutines with Dispatchers

Andrei Riik
MobilePeople
Published in
2 min readSep 6, 2024

In modern Kotlin development, one of the popular approaches to testing coroutines is to pass a custom dispatcher to the class or function under the test.

In your class:

class Repository(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) ...

In your test:

@Test
fun someTest() = runTest {
val dispatcher = StandardTestDispatcher(...)
val repository = Repository(dispatcher)
...

But I always missed the way it was done in RxJava.

For example, we can do it in a few lines with no additional changes in the code that is tested:

RxJavaPlugins.setIoSchedulerHandler { 
Schedulers.trampoline()
}

Can we achieve the same simplicity with Coroutines? Can we not worry about passing the dispatcher as a parameter?

Well, there is a way for the main dispatcher:

@Test
fun settingMainDispatcher() = runTest {
val dispatcher = UnconfinedTestDispatcher(...)
Dispatchers.setMain(dispatcher)
...

Unfortunately, there is no way to do the same for other types of dispatchers such as Dispatchers.IO and Dispatchers.Default.

The good news is that we can do it ourselves. Here is an example:

import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import org.jetbrains.annotations.VisibleForTesting

/**
* Dispatchers that can be easily changed for testing.
* It's similar to "RxJavaPlugins.set..."
*/
object DispatchersHub {

var Main: CoroutineDispatcher = Dispatchers.Main
private set

var Default = Dispatchers.Default
private set

var IO = Dispatchers.IO
private set

var Unconfined = Dispatchers.Unconfined
private set

@VisibleForTesting
fun set(
main: CoroutineDispatcher = Main,
default: CoroutineDispatcher = Default,
io: CoroutineDispatcher = IO,
unconfined: CoroutineDispatcher = Unconfined,
) {
Main = main
Default = default
IO = io
Unconfined = unconfined
}

@VisibleForTesting
fun reset() {
Main = Dispatchers.Main
Default = Dispatchers.Default
IO = Dispatchers.IO
Unconfined = Dispatchers.Unconfined
}
}

Usage example:

// Code before
someFlow.flowOn(Dispatchers.IO)

// Code after
someFlow.flowOn(DispatchersHub.IO)

As you can see, no additional parameters must be passed as arguments. At the same time, you can change the dispatcher dynamically for testing.

In tests (you can do it even simpler via test rules):

@Before
fun setUp() {
DispatchersHub.set(
io = UnconfinedTestDispatcher()
)
}

@After
fun tearDown() {
DispatchersHub.reset()
}

That’s it. You can write your main code as usual.

Bonus point.

You can write handy extensions and not worry about passing test dispatchers.

An example:

fun <T> Flow<T>.fromIo() =
flowOn(DispatchersHub.IO)

Thanks for reading! Feel free to share your thoughts.

--

--