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 uses StandardTestDispatcher 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

  1. Use runTest for All Coroutine Tests:

    • Ensures coroutines execute in a controlled environment with efficient handling of delays and virtual time.
  2. 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.