Asynchronous code is common in modern Kotlin applications. Android apps use coroutines to load data, call APIs, process user actions, and perform background tasks. Backend applications also use coroutines to handle concurrent operations without blocking threads unnecessarily.
However, testing asynchronous code can be difficult. A test might pass on one machine and fail on another because an operation takes longer than expected. Some tests rely on real delays, while others depend on network responses or the timing of background tasks.
These problems are often called flaky tests. A flaky test produces different results even when the application code has not changed.
Kotlin provides coroutine testing tools that help developers control execution, advance virtual time, and verify asynchronous behavior without waiting for real-world delays.
In this article, we will explore how to test Kotlin coroutines using kotlinx-coroutines-test, control delays, handle background work, and avoid common testing mistakes.
Why Coroutine Testing Needs Special Handling
Consider a function that waits for two seconds before returning a result.
import kotlinx.coroutines.delay
suspend fun loadMessage(): String {
delay(2000)
return "Data loaded"
}
The function uses delay() instead of blocking the current thread with Thread.sleep().
A simple test could call the function and check the result. However, if the test uses real time, it must wait for the two-second delay to finish.
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
class MessageTest {
@Test
fun loadsMessage() = runBlocking {
val result = loadMessage()
assertEquals("Data loaded", result)
}
}
This test works, but it waits for the actual delay. If a test suite contains many such tests, execution time can increase significantly.
Coroutine testing tools solve this problem by allowing supported coroutine delays to run using virtual time.
Virtual time lets the test advance the coroutine scheduler's clock without waiting for the same amount of real time.
Understanding kotlinx-coroutines-test
The kotlinx-coroutines-test library provides utilities for testing coroutine-based code.
Some important components include:
Component | Purpose |
|---|
runTest
| Runs a coroutine test using a test scheduler |
TestCoroutineScheduler
| Controls virtual time and scheduled coroutine execution |
StandardTestDispatcher
| Schedules coroutine work so the test can control when it executes |
UnconfinedTestDispatcher
| Starts coroutines eagerly in many test scenarios |
advanceTimeBy()
| Advances virtual time by a specified duration |
runCurrent()
| Runs tasks scheduled for the current virtual time |
advanceUntilIdle()
| Advances the scheduler until no relevant scheduled tasks remain |
These utilities are provided by the coroutine testing library. Their behavior depends on the dispatcher and scheduler configuration, so tests should use them deliberately rather than mixing them without understanding their effects.
Step 1: Add the Coroutine Testing Dependency
For a Gradle project using Kotlin DSL, add the test dependency to your module's build.gradle.kts file.
dependencies {
testImplementation(
"org.jetbrains.kotlinx:kotlinx-coroutines-test:<version>"
)
testImplementation(
"org.jetbrains.kotlin:kotlin-test:<version>"
)
}
Replace <version> with a compatible version for your project. In a real project, keep the coroutine testing library compatible with the version of kotlinx-coroutines-core used by your application.
If your project uses JUnit, include the appropriate JUnit dependency and test integration as well.
For Android projects, the dependency may belong in the relevant module's test configuration, depending on whether the tests run as local unit tests or instrumented tests.
Step 2: Replace Real Waiting With runTest
Let's test the earlier loadMessage() function without waiting for two real seconds.
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
suspend fun loadMessage(): String {
delay(2000)
return "Data loaded"
}
class MessageTest {
@Test
fun loadsMessage() = runTest {
val result = loadMessage()
assertEquals("Data loaded", result)
}
}
The test uses runTest instead of runBlocking.
When the code uses a test-aware dispatcher and scheduler, runTest can skip supported coroutine delays and advance virtual time as required.
This makes the test faster and more predictable.
There is an important limitation: runTest does not automatically control every kind of asynchronous operation. Blocking calls, external network requests, and work dispatched to an unrelated real dispatcher may still take real time.
Step 3: Control Virtual Time Explicitly
Sometimes, checking the final result is not enough. You may need to verify that an operation does not finish before a specific delay.
Consider a function that performs work after three seconds.
import kotlinx.coroutines.delay
suspend fun delayedMessage(): String {
delay(3000)
return "Completed"
}
You can use async and the test scheduler to check how the function behaves at different points in virtual time.
import kotlinx.coroutines.async
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DelayedMessageTest {
@Test
fun completesAfterDelay() = runTest {
val task = async {
delayedMessage()
}
runCurrent()
assertFalse(task.isCompleted)
advanceTimeBy(3000)
assertTrue(task.isCompleted)
}
}
The test starts the coroutine and runs tasks scheduled at the current virtual time. The delay has not yet elapsed, so the task remains incomplete.
Next, advanceTimeBy(3000) advances virtual time by three seconds and processes scheduled work according to the test scheduler's rules.
Coroutine test API behavior can vary by library version, particularly around tasks scheduled exactly at the new virtual-time boundary. If a test needs to assert the final state after that boundary, explicitly run the tasks scheduled at the current time:
advanceTimeBy(3000)
runCurrent()
assertTrue(task.isCompleted)
This makes the intended sequence clear: advance the clock, run the work now due, and then check the result.
Step 4: Understand runCurrent() and advanceUntilIdle()
Two commonly used scheduler methods are runCurrent() and advanceUntilIdle().
They solve different problems.
Using runCurrent()
runCurrent() executes tasks scheduled for the current virtual time.
For example:
@Test
fun runsCurrentTasks() = runTest {
var completed = false
launch {
completed = true
}
runCurrent()
assertTrue(completed)
}
With a standard test dispatcher, launching a coroutine does not necessarily execute its body immediately. runCurrent() allows the test to run the work scheduled at the current time.
Using advanceUntilIdle()
advanceUntilIdle() advances virtual time and runs scheduled tasks until the scheduler has no relevant work left to execute.
@Test
fun finishesScheduledWork() = runTest {
var message = ""
launch {
delay(1000)
message = "First task completed"
}
advanceUntilIdle()
assertEquals("First task completed", message)
}
The test does not wait for one real second. The scheduler advances virtual time until the coroutine completes.
Use advanceUntilIdle() when you want scheduled work to finish before making assertions.
Do not use it blindly when the code contains repeating timers, continuously scheduled work, or long-running jobs. Such work may prevent the scheduler from becoming idle.
Step 5: Test Coroutine-Based ViewModels
Android applications commonly use coroutines inside ViewModels to load data and update UI state.
Consider this example:
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class ProfileViewModel : ViewModel() {
private val _message = MutableStateFlow("Loading")
val message: StateFlow<String> = _message
fun loadProfile() {
viewModelScope.launch {
delay(1000)
_message.value = "Profile loaded"
}
}
}
The ViewModel starts with a loading message. After one second, it updates the state.
Testing this behavior requires the coroutine launched by viewModelScope to use a test-controlled dispatcher. Simply calling runTest does not automatically replace every dispatcher used by Android lifecycle components.
A common approach is to provide a test dispatcher through a MainDispatcherRule that replaces the main dispatcher during the test.
Here is a simplified JUnit 4 rule:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
private val dispatcher: TestDispatcher =
UnconfinedTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
This rule replaces Dispatchers.Main during the test and restores it afterward.
For tests that need precise control over execution order, a StandardTestDispatcher is often a better choice than an unconfined dispatcher. Whichever dispatcher you choose, share its scheduler with the test so that virtual time is coordinated.
A test for the ViewModel could look like this:
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import kotlin.test.Test
import kotlin.test.assertEquals
class ProfileViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun updatesProfileMessage() = runTest {
val viewModel = ProfileViewModel()
viewModel.loadProfile()
runCurrent()
assertEquals("Loading", viewModel.message.value)
advanceTimeBy(1000)
runCurrent()
assertEquals(
"Profile loaded",
viewModel.message.value
)
}
}
This example demonstrates the testing pattern. In a project with more complex dispatcher configuration, ensure the ViewModel and runTest use a coordinated test scheduler. If your application injects dispatchers, inject the test dispatcher explicitly rather than relying on global configuration.
Standard Test Dispatcher vs. Unconfined Test Dispatcher
Both dispatchers are useful, but they have different execution behavior.
Feature | StandardTestDispatcher | UnconfinedTestDispatcher |
|---|
Initial execution | Schedules work for the test scheduler | Starts many coroutines eagerly until they suspend |
Execution control | Provides explicit control over scheduled tasks | Provides less control over initial execution order |
Best use | Tests requiring predictable scheduling | Simple tests where eager execution is useful |
Common requirement | Use scheduler methods when necessary | Be careful when testing ordering and concurrency |
For most tests involving delays, ordering, or concurrency, StandardTestDispatcher makes execution easier to reason about.
UnconfinedTestDispatcher can simplify some tests, but its eager behavior may hide scheduling problems that would appear with a different dispatcher.
Best Practices for Reliable Coroutine Tests
1. Prefer runTest Over runBlocking for Unit Tests
runBlocking is useful for bridging blocking and suspending code, but it does not provide the same virtual-time control as runTest.
Use runTest when testing coroutine behavior that benefits from a test scheduler.
2. Inject Dispatchers
Avoid hardcoding dispatchers throughout your business logic.
For example, instead of directly using Dispatchers.IO inside every repository method, accept a dispatcher through the constructor.
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
class UserRepository(
private val ioDispatcher: CoroutineDispatcher
) {
suspend fun loadUser(): String =
withContext(ioDispatcher) {
"User loaded"
}
}
Production code can supply an appropriate I/O dispatcher, while tests can provide a test dispatcher.
This makes asynchronous behavior easier to control and reduces dependence on global state.
3. Avoid Real Delays in Unit Tests
Do not use Thread.sleep() to wait for coroutine work.
Real waiting slows tests and introduces timing uncertainty. Use virtual time when the operation uses a test-controlled coroutine scheduler.
4. Keep Related Work on the Same Test Scheduler
When multiple test dispatchers are involved, coordinate them through the same TestCoroutineScheduler.
Otherwise, advancing one scheduler may not advance tasks scheduled on another.
5. Test Cancellation and Errors
Reliable coroutine tests should cover more than successful results.
Verify how the application behaves when a coroutine is cancelled, an operation throws an exception, or a timeout occurs.
6. Keep External Dependencies Under Control
Virtual time does not replace a real HTTP server, database, or external service automatically.
Use mocks, fakes, or dedicated integration-test environments when testing interactions with external systems.
Common Mistakes and Troubleshooting
The Test Still Takes Several Seconds
The code may be using real blocking calls or a dispatcher that is not connected to the test scheduler.
Check whether the code calls Thread.sleep(), performs real network requests, or switches to an uncontrolled dispatcher.
A Coroutine Has Not Started When Expected
With StandardTestDispatcher, launched work may remain queued until the scheduler runs it.
Use runCurrent() or another appropriate scheduler operation before asserting the coroutine's result.
The State Has Not Updated After Advancing Time
Advancing virtual time and executing tasks scheduled at the new time are separate concerns in some test scenarios.
After advanceTimeBy(), use runCurrent() when you need to process work scheduled at the current virtual time before checking the result.
Tests Fail Because of Dispatcher Configuration
A ViewModel using viewModelScope normally launches work on Dispatchers.Main.
If a local unit test has not replaced the main dispatcher, the test may fail because the Android main dispatcher is unavailable.
Install a main-dispatcher test rule and ensure the test scheduler is shared appropriately.
The Test Never Becomes Idle
A coroutine may be running a repeating task or scheduling new work continuously.
Instead of calling advanceUntilIdle() without limits, advance time deliberately and test the specific behavior you need.
Advantages and Disadvantages of Virtual-Time Testing
Advantages
Tests run faster because supported delays do not require equivalent real waiting.
Timing-sensitive behavior becomes easier to reproduce.
Developers can test operations before and after a specific delay.
The scheduler provides more control over coroutine execution.
Tests are less dependent on machine speed and external timing conditions.
Disadvantages
Virtual time does not control every asynchronous operation.
Dispatcher configuration can be difficult in applications with many coroutine scopes.
Incorrect scheduler setup can make tests confusing or unreliable.
Tests using virtual time do not replace integration tests with real external systems.
Summary
Kotlin coroutine testing becomes more predictable when developers use kotlinx-coroutines-test instead of relying on real delays and uncontrolled execution.
runTest provides a test environment for suspending functions, while TestCoroutineScheduler allows you to advance virtual time and run scheduled work deliberately. Methods such as runCurrent(), advanceTimeBy(), and advanceUntilIdle() help verify asynchronous behavior without unnecessary waiting.
For reliable tests, use coordinated test dispatchers, inject dispatchers where appropriate, cleanly handle cancellation, and keep external dependencies under control.
The goal is not simply to make tests faster. It is to make asynchronous behavior easier to understand, reproduce, and verify.
Join the conversation! Your thoughts help the community grow.