Coroutine Testing (Kotlin)

Coroutine Testing (Kotlin) — Complete Guide

Testing coroutines correctly is essential to avoid flaky tests, real delays, and race conditions.
Kotlin provides kotlinx-coroutines-test to test coroutines deterministically.


1. Why Coroutine Testing Is Special

Problems with normal tests:

  • delay() slows tests

  • Dispatchers run on real threads

  • Race conditions

✅ Coroutine testing:

  • Controls virtual time

  • Runs instantly

  • Predictable results


2. Add Test Dependency

Gradle (Kotlin DSL)

testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")

3. Key Testing Tools (Must Know)

Tool Purpose
runTest Test coroutine code
TestDispatcher Control execution
TestScope Structured test scope
advanceTimeBy() Move virtual time
advanceUntilIdle() Finish pending tasks

4. Basic Coroutine Test

import kotlinx.coroutines.test.*
import kotlin.test.*

@Test
fun simpleCoroutineTest() = runTest {
launch {
delay(1000)
println("Done")
}

advanceTimeBy(1000)
}

✔ Test runs instantly
✔ No real delay


5. Testing Suspend Functions

suspend fun fetchData(): String {
delay(500)
return "Data"
}

@Test
fun testSuspendFunction() = runTest {
val result = fetchData()
assertEquals("Data", result)
}


6. Testing launch vs async

launch

@Test
fun testLaunch() = runTest {
var result = 0
launch {
delay(100)
result = 5
}

advanceUntilIdle()
assertEquals(5, result)
}


async

@Test
fun testAsync() = runTest {
val deferred = async {
delay(100)
10
}

advanceUntilIdle()
assertEquals(10, deferred.await())
}


7. Testing Dispatcher Switching (withContext)

@Test
fun testWithContext() = runTest {
val result = withContext(Dispatchers.Default) {
5
}
assertEquals(5, result)
}

runTest automatically replaces dispatchers


8. Testing Exceptions in Coroutines

@Test
fun testException() = runTest {
try {
launch {
throw Exception("Error")
}
advanceUntilIdle()
fail("Exception expected")
} catch (e: Exception) {
assertEquals("Error", e.message)
}
}

9. Testing SupervisorJob Behavior

@Test
fun testSupervisorJob() = runTest {
val scope = CoroutineScope(SupervisorJob())

var success = false

scope.launch {
delay(100)
success = true
}

scope.launch {
throw Exception("Fail")
}

advanceUntilIdle()
assertTrue(success)
}


10. Testing Flow (Important)

Basic Flow Test

@Test
fun testFlow() = runTest {
val flow = flow {
emit(1)
emit(2)
}

val list = flow.toList()
assertEquals(listOf(1, 2), list)
}


Testing Flow with Delay

@Test
fun testDelayedFlow() = runTest {
val flow = flow {
emit(1)
delay(1000)
emit(2)
}

val result = mutableListOf<Int>()
flow.collect { result.add(it) }

advanceTimeBy(1000)
assertEquals(listOf(1, 2), result)
}


11. Testing StateFlow

@Test
fun testStateFlow() = runTest {
val state = MutableStateFlow(0)
state.value = 1
state.value = 2

assertEquals(2, state.value)
}


12. Testing SharedFlow

@Test
fun testSharedFlow() = runTest {
val flow = MutableSharedFlow<Int>()

val result = mutableListOf<Int>()
launch {
flow.collect { result.add(it) }
}

flow.emit(1)
flow.emit(2)

advanceUntilIdle()
assertEquals(listOf(1, 2), result)
}


13. Common Mistakes (Avoid These)

❌ Using runBlocking instead of runTest
❌ Real delay() in tests
❌ GlobalScope in tests
❌ Ignoring virtual time


14. Best Practices

✔ Always use runTest
✔ Control time with advanceTimeBy
✔ Test Flows with toList()
✔ Use advanceUntilIdle()
✔ Keep tests deterministic


Final Summary

  • Coroutine testing needs special tools

  • runTest is the foundation

  • Virtual time makes tests fast

  • Essential for stable async code

You may also like...