Raúl Maza
Android Software Engineer
Coroutine Testing in Kotlin: Best Practices and New APIs
Published Jun 23, 2024
Introduction
Kotlin’s coroutines have revolutionized asynchronous programming on Android, but testing them can be challenging. The introduction of kotlinx.coroutines
1.6 brought new testing APIs that simplify and enhance the testing process.
The Evolution of Coroutine Testing
Before kotlinx.coroutines
1.6, developers relied on runBlockingTest
and TestCoroutineDispatcher
. These tools, while functional, had limitations in handling time and concurrency effectively. The new APIs, including runTest
, TestDispatcher
, and TestScope
, address these issues, providing a more controlled and efficient testing environment.
Components of Coroutine Testing
Testing Coroutines Components
- runTest: A test coroutine builder that simplifies testing by managing coroutine execution and virtual time. It ensures efficient handling of delays without real-time passage and creates a
TestScope
. - TestDispatcher: Executes coroutines, providing delay skipping. It delegates work to
TestCoroutineScheduler
. - TestCoroutineScheduler: Acts as the source of truth during testing, tracking virtual time and running coroutines.
Test Dispatcher Implementations
StandardTestDispatcher
Features:
- This dispatcher queues coroutines.
- It’s necessary to manually advance the coroutines.
runCurrent
: Executes the current coroutine.advanceUntilIdle
: Executes all enqueued coroutines.advanceTimeBy
: Executes enqueued coroutines for a specific time.- Useful for precise control over the execution order and timing.
runTest
usesStandardTestDispatcher
by default.
Example:
@Test
fun exampleTest(): Unit = runTest {
// Test code with StandardTestDispatcher
advanceTimeBy(1000) // Advance time by 1000ms
// Assertions and test logic
}
UnconfinedTestDispatcher
Behavior:
- Starts coroutines eagerly without queuing.
- Suitable for simple tests where concurrency is not a concern. Important: Concurrency issues won’t be tested!.
- Similar to the behavior of the old
runBlockingTest
.
Example of using UnconfinedTestDispatcher:
@Test
fun exampleTest(): Unit = runTest(UnconfinedTestDispatcher) {
// Test code with UnconfinedTestDispatcher
// Assertions and test logic
}
Best Practices for Coroutine Testing
Use
runTest
for All Coroutine Tests:- Ensures coroutines execute in a controlled environment with efficient handling of delays and virtual time.
Inject Dispatchers:
- Avoid hardcoding coroutine dispatchers. This allows for more flexible and testable code. For example:
class Repository(
private val db: Database,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) {
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
fun initialize() {
scope.launch {
db.initialize()
}
}
suspend fun fetchData(): String = withContext(dispatcher) {
return@withContext db.read()
}
}
@Test
fun repositoryTest(): Unit = runTest {
val repository = Repository(FakeDatabase(), StandardTestDispatcher(testScheduler))
repository.initialize()
advanceUntilIdle()
val data = repository.fetchData()
assertEquals("Hello world", data)
}
Handling Issues with Uncontrolled Dispatchers
When a CoroutineDispatcher
is not injected into classes like Repository
, testing scenarios involving delays or concurrency may lead to unpredictability. For example:
@Test
fun repositoryTest(): Unit = runTest {
val repository = Repository(FakeDatabase()) // Without injecting a TestDispatcher
repository.initialize()
// Simulate a delay
delay(100) // This delay is unpredictable in tests without a controlled dispatcher
val data = repository.fetchData()
assertEquals("Hello world", data)
}
In this scenario, the delay(100) introduces potential flakiness due to uncontrolled timing, impacting test reliability. To resolve this, inject a TestDispatcher into Repository:
class Repository(
private val db: Database,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) {
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
fun initialize() {
scope.launch {
db.initialize()
}
}
suspend fun fetchData(): String = withContext(dispatcher) {
return@withContext db.read()
}
}
Now, when testing Repository
, use a TestDispatcher
to ensure controlled coroutine execution:
@Test
fun repositoryTest(): Unit = runTest {
val repository = Repository(FakeDatabase(), StandardTestDispatcher(testScheduler))
repository.initialize()
advanceUntilIdle()
val data = repository.fetchData()
assertEquals("Hello world", data)
}
This approach guarantees that coroutine behavior remains predictable, facilitating reliable and effective testing of coroutine-based functionality.