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)

ToolPurpose
runTestTest coroutine code
TestDispatcherControl execution
TestScopeStructured 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...