Kotlin Unit Testing guide — part 3 — coroutines

Kacper Wojciechowski
6 min readAug 21, 2024

--

Photo by Erik Mclean on Unsplash

In modern Kotlin applications, coroutines are a common tool for managing concurrency. In the previous part, we discussed mocking dependencies that use coroutines. However, when your dependencies use coroutines, your test subject will likely need to support coroutines as well. Testing coroutines in frameworks like JUnit 4 requires some additional setup, as the suspend keyword isn't directly supported in test functions. This guide will walk you through the necessary setup and cover advanced concepts to keep in mind when writing unit tests for coroutines.

The setup

To begin testing coroutines, ensure you have the appropriate dependencies. If your project already uses coroutines, you should already have the core coroutine dependency. To test coroutines, you’ll need to add the coroutines testing package:

[versions]
coroutines = "1.9.0-RC"

[libraries]
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
implementation(libs.coroutines.core)
testImplementation(libs.coroutines.test)

The basics

Let’s start with a basic example. Suppose you have the following interface and use case:

interface ExampleDependency {
suspend fun fetch(): Result<Unit>
}

class ExampleUseCase(
private val exampleDependency: ExampleDependency,
) {
suspend operator fun invoke() = exampleDependency.fetch()
}

To test the invoke function, you'll need to run it within a coroutine context. Since JUnit 4 test functions can't be marked as suspend, you use the runTest function:

@Test
fun `invoke should fetch data from dependency`() = runTest {
coEvery { exampleDependency.fetch() } returns Result.success(Unit)

val result = exampleUseCase()

assertTrue(result.isSuccess)
coVerifySequence {
exampleDependency.fetch()
}
}

The runTest function blocks the test thread until the coroutine completes, manages timeouts, and skips delays for optimisation.

TestDispatcher and TestScheduler

Coroutine tests run on a special dispatcher designed for testing. The TestCoroutineScheduler is used to skip and manage delays for optimisation. Additionally, there are two types of test dispatchers:

  • StandardTestDispatcherGives you full control over when coroutines are dispatched. Useful for setting up preconditions before launching coroutines. This is your go-to dispatcher for your unit tests.
  • UnconfinedTestDispatcherLaunches all coroutines eagerly, which is helpful when testing flows or similar constructs.

It’s a good practice to inject some background dispatcher into your use case:

class ExampleUseCase(
private val exampleDependency: ExampleDependency,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
suspend operator fun invoke() = withContext(dispatcher) {
exampleDependency.fetch()
}
}

Using a dispatcher different from the test dispatcher during testing can lead to inconsistent test results. The test block may run independently and might not wait for all background coroutines to complete their work, resulting in unreliable assertions and verifications. To prevent this, the test dispatcher must be passed to the use case. This can be done by instantiating it with either the StandardTestDispatcher() or UnconfinedTestDispatcher() functions.

private val dispatcher = StandardTestDispatcher()

private val exampleUseCase = ExampleUseCase(
exampleDependency = exampleDependency,
dispatcher = dispatcher,
)

@Test
fun `invoke should fetch data from dependency`() = runTest(dispatcher) {
coEvery { exampleDependency.fetch() } returns Result.success(Unit)

val result = exampleUseCase()

assertTrue(result.isSuccess)
coVerifySequence {
exampleDependency.fetch()
}
}

This can be refined even further. The runTest function checks whether the Main dispatcher is a TestCoroutineScheduler (the class that handles all the testing operations). If it is, runTest will reuse it, which is particularly helpful in scenarios where coroutines are launched with Dispatchers.Main by default (such as in the viewModelScope of an Android ViewModel). For these cases, the coroutine testing framework offers two support functions:

  • Dispatchers.setMain(...): Temporarily overrides the platform's Main dispatcher with your test dispatcher.
  • Dispatchers.resetMain(): Resets the Main dispatcher back to the real platform's main thread dispatcher.

The first function should be used before test execution, and the second one after test execution. In JUnit4, this can be achieved using functions annotated with @Before and @After. Since this pattern will be frequently used throughout your project, it's a good practice to create a reusable component for convenience. In JUnit4, you can extend the TestWatcher class and override its functions to create easily reusable test logic. You can create a TestCoroutineRule in a test utility module or package.

class TestCoroutineRule(
val dispatcher: TestDispatcher = StandardTestDispatcher(),
) : TestWatcher() {

override fun starting(description: Description) {
Dispatchers.setMain(dispatcher)
}

override fun finished(description: Description) {
Dispatchers.resetMain()
}
}

We initialize our test dispatcher and keep it as a public property so it can be easily accessed in the test subject constructor. In the starting function, which runs before each test case, we call Dispatchers.setMain(dispatcher). Similarly, in the finished function, which runs after each test case (regardless of its outcome), we call Dispatchers.resetMain().

With this utility in place, our test will look like this:

@get:Rule
val coroutineRule = TestCoroutineRule()

private val exampleDependency = mockk<ExampleDependency>()

private val exampleUseCase = ExampleUseCase(
exampleDependency = exampleDependency,
dispatcher = coroutineRule.dispatcher,
)

@Test
fun `invoke should fetch data from dependency`() = runTest {
coEvery { exampleDependency.fetch() } returns Result.success(Unit)

val result = exampleUseCase()

assertTrue(result.isSuccess)
coVerifySequence {
exampleDependency.fetch()
}
}

Note that we’ve removed the dispatcher parameter from runTest since the scheduler will now be provided by the Main dispatcher override.

Controlling Coroutine Execution

The StandardTestDispatcher gives you control over when coroutines are executed. For example, if you have a ViewModel that launches a coroutine:

class ExampleViewModel(
private val exampleUseCase: ExampleUseCase,
) {
private val scope = CoroutineScope(Dispatchers.Main)

fun runSomething() {
scope.launch {
exampleUseCase()
}
}
}

With current knowledge we can write such a unit test:

@get:Rule
val coroutineRule = TestCoroutineRule()

private val exampleUseCase = mockk<ExampleUseCase>()

private val viewModel = ExampleViewModel(exampleUseCase)

@Test
fun `runSomething invokes ExampleUseCase`() = runTest {
coEvery { exampleUseCase() } returns Result.success(Unit)

viewModel.runSomething()

coVerifySequence {
exampleUseCase()
}
}

Notice that we are not passing the dispatcher through dependency injection. It is not needed in such case, as the test dispatcher is provided by Main dispatcher override by TestCoroutineRule.

If we run this test case, it will fail. That is because the StandardTestDispatcher is blocking the coroutine launched by launch function. In order to execute the coroutine we need to use TestScope.runCurrent() extension function:

@Test
fun `runSomething invokes ExampleUseCase`() = runTest {
coEvery { exampleUseCase() } returns Result.success(Unit)

viewModel.runSomething()
runCurrent()

coVerifySequence {
exampleUseCase()
}
}

This function will access the TestCoroutineScheduler through CoroutineContext and execute all pending coroutines that are scheduled for now (scheduling will be covered in the next section).

This way you have full control over the coroutine execution. You can perform some setup or verify some preconditions before the coroutine execution. This lets you write a unit test for previously impossible case where you launch some coroutine in the init block.

class ExampleViewModel(
private val exampleUseCase: ExampleUseCase,
) {
private val scope = CoroutineScope(Dispatchers.Main)

init {
scope.launch {
exampleUseCase()
}
}
}

Now you can create an instance of this view model, perform some setup, attach some listeners, and then actually execute the coroutine.

Manipulating Scheduler’s Clock— handle delays in tests

There are times when we need to use the delay(...) function in our code, which suspends the coroutine for a specified duration. As mentioned earlier, the TestCoroutineScheduler automatically skips these delays during tests for optimization purposes. This prevents our tests from being unnecessarily blocked for several seconds. However, if a coroutine is launched with the StandardTestDispatcher, its execution will be paused until we manually advance the scheduler's clock to the desired time.

This approach is particularly useful because it allows us to avoid blocking the test thread while still providing the opportunity to perform setup and verification before the coroutine resumes.

Let’s update our previous example by adding a delay before executing the use case:

class ExampleViewModel(
private val exampleUseCase: ExampleUseCase,
) {
private val scope = CoroutineScope(Dispatchers.Main)

fun runSomething() {
scope.launch {
delay(3.seconds)
exampleUseCase()
}
}
}

Our test case, before the modification, looked like this:

@Test
fun `runSomething invokes ExampleUseCase`() = runTest {
coEvery { exampleUseCase() } returns Result.success(Unit)

viewModel.runSomething()
runCurrent()

coVerifySequence {
exampleUseCase()
}
}

In this scenario, the test case will now fail because the ExampleUseCase was not invoked. This happens because we didn’t adjust the scheduler's clock to account for the delay. To fix this, we need to use the TestScope.advanceTimeBy(...) function to fast-forward the clock by 3 seconds, and then call TestScope.runCurrent() to resume the suspended coroutine.

@Test
fun `runSomething invokes ExampleUseCase`() = runTest {
coEvery { exampleUseCase() } returns Result.success(Unit)

viewModel.runSomething()
runCurrent()
advanceTimeBy(3.seconds)
runCurrent()

coVerifySequence {
exampleUseCase()
}
}

That covers everything you need to know to get started with testing coroutines!

While flows are also an integral part of the Coroutines framework, they are a broad topic that warrants a dedicated section. I’ll dive into that in the next part of this guide. Stay tuned!

--

--