Skip to Content
Course content

104: Testing Coroutines with runTest

Click on the "Edit" button in the top corner of the screen to edit your slide content.

If you've tried testing coroutines using runBlocking, you've probably noticed that your test suite starts feeling sluggish the moment you add a delay(). It's frustrating because you're essentially forcing your CPU to actually sit there and wait for real-world time to pass just to verify a piece of logic. That's where runTest comes in.

Why can't I just keep using runBlocking?

The short answer is: runBlocking is for bridging the gap between non-coroutine code and coroutines; it isn't designed for testing. When you use runBlocking, a delay(1000) actually takes one full second. Now, one second doesn't seem like much, but once you have 500 tests each with a few delays, your CI pipeline becomes a nightmare.

runTest is different because it uses a TestCoroutineScheduler. It implements something called "virtual time." When the code hits a delay, runTest doesn't actually make the thread sleep. Instead, it just fast-forwards the virtual clock to the point where the delay ends. It's essentially a time machine for your tests.

// Imagine a service that fetches weather data with a simulated network lag
class WeatherService {
    suspend fun fetchTemperature(): String {
        delay(5000) // Simulate a slow API
        return "22°C"
    }
}

// Using runTest, this finishes in milliseconds, not 5 seconds
@Test
fun `test fetchTemperature returns correct value`() = runTest {
    val service = WeatherService()
    val result = service.fetchTemperature()
    assertEquals("22°C", result)
}


How do I test things that happen "eventually" without waiting?

Sometimes you aren't just waiting for a single return value; you're waiting for a background task to complete or a state to change. If you launch a coroutine inside your test, runTest will automatically wait for all children to finish before completing. But what if you need to check the state during that process?

I usually rely on advanceTimeBy(ms) or advanceUntilIdle(). These let you surgically move the clock forward to see if your code is behaving correctly at specific intervals. I'll show you with a simple "Debounce" example—like a search bar that only triggers after the user stops typing.

@Test
fun `search query should only trigger after debounce period`() = runTest {
    val searchViewModel = SearchViewModel() // assume it has a 300ms debounce
    
    searchViewModel.onQueryChanged("K")
    advanceTimeBy(200) 
    assertEquals(0, searchViewModel.requestCount) // Too early!
    
    advanceTimeBy(101) // Now we've hit 301ms
    assertEquals(1, searchViewModel.requestCount) // Triggered!
}


Why does my test crash with "Module with Main dispatcher had failed to initialize"?

This is the classic "Welcome to Kotlin Testing" error. Dispatchers.Main is usually tied to a platform-specific main thread (like the Android UI thread). In a JUnit test, that main thread doesn't exist. Even if your runTest block is working fine, if the code inside your class calls withContext(Dispatchers.Main), it'll blow up.

You have to tell Kotlin to replace the Main dispatcher with a test dispatcher. The cleanest way to do this is by creating a JUnit Rule or using a @Before method to set the main dispatcher. I personally prefer a Rule because it handles the cleanup automatically, but the manual way looks like this:

private val testDispatcher = StandardTestDispatcher()

@Before
fun setup() {
    Dispatchers.setMain(testDispatcher)
}

@After
fun tearDown() {
    Dispatchers.resetMain()
}

By doing this, whenever your production code asks for Dispatchers.Main, it gets your testDispatcher instead, which is fully compatible with the virtual time skipping of runTest.

What if my code uses a custom scope that I can't control?

If your class creates its own CoroutineScope(Dispatchers.IO) internally, runTest loses its power. Why? Because runTest only controls coroutines launched within its own context or using the provided test scheduler. A hardcoded Dispatchers.IO call escapes that sandbox and runs on a real thread with real time.

The fix is simple: Inject your dispatchers. Don't hardcode Dispatchers.IO inside your classes. Pass the dispatcher as a constructor argument. In production, you pass the real one; in tests, you pass your testDispatcher. Trust me, this is one of those "best practices" that actually saves you hours of debugging flaky tests later on.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.