Testing Coroutines & Flow Explained
TESTING › Unit
Coroutine code is full of delay(), launch {}, and dispatchers, all things that make a naive test slow or flaky if you let them run for real. runTest, from kotlinx-coroutines-test, is the fix: it's a coroutine builder for tests that runs your body inside a TestScope backed by a TestCoroutineScheduler, a **virtual clock**. Delays inside it don't actually wait wall-clock time, the scheduler just fast-forwards past them.
@Test
fun `computes total`() = runTest {
val result = withDelay() // delay(5000) inside, but this test is instant
assertEquals(42, result)
}
That one property, virtual time, is what turns coroutine tests from something you dread into something you can write dozens of without your test suite crawling.
By default, runTest uses StandardTestDispatcher, which **queues** newly launched coroutines instead of running them immediately. They only execute once you explicitly advance the virtual clock:
@Test
fun `launch is queued`() = runTest {
var saved = false
launch { saved = true }
assertFalse(saved) // not run yet!
advanceUntilIdle() // drains everything queued
assertTrue(saved)
}
The alternative is UnconfinedTestDispatcher, which runs a newly launched coroutine **eagerly**, immediately, on the current thread, up until its first suspension point. It's terser for simple cases but doesn't mirror how real scheduling interleaves work, so reach for StandardTestDispatcher when the exact order of concurrent operations is what you're testing.
When you're on StandardTestDispatcher, you drive the scheduler yourself with three tools. advanceUntilIdle() runs everything queued until nothing is left. runCurrent() runs only the work already scheduled at the **current** instant, without moving the clock forward. advanceTimeBy(n) moves the clock forward by n and runs anything scheduled strictly before that new time.
launch {
delay(1_000)
done = true
}
advanceTimeBy(1_000) // runs work scheduled BEFORE t=1000
assertFalse(done) // the resume AT exactly t=1000 hasn't run yet
runCurrent() // now runs it
assertTrue(done)
That boundary detail, advanceTimeBy covers strictly-before, not the exact instant, trips people up constantly. When in doubt and you just want everything to run, advanceUntilIdle() is the safe default.
A ViewModel's viewModelScope hardcodes Dispatchers.Main, and Dispatchers.Main doesn't exist on a plain JVM, there's no real Looper to back it, so a bare unit test would crash. The fix is Dispatchers.setMain(testDispatcher) before the test and Dispatchers.resetMain() after, almost always wrapped in a reusable JUnit rule:
class MainDispatcherRule : TestWatcher() {
private val testDispatcher = StandardTestDispatcher()
override fun starting(d: Description?) = Dispatchers.setMain(testDispatcher)
override fun finished(d: Description?) = Dispatchers.resetMain()
}
class MyViewModelTest {
@get:Rule val mainRule = MainDispatcherRule()
@Test
fun test() = runTest {
val vm = MyViewModel() // viewModelScope now runs on the test dispatcher
advanceUntilIdle()
}
}
Without this, any ViewModel test that touches viewModelScope fails immediately with an exception about a missing main dispatcher.
Asserting Flow emissions with plain collect {} and mutable lists works but is clunky and easy to get subtly wrong. **Turbine** makes it a one-liner per emission: flow.test { ... } collects the flow and gives you awaitItem() to pull the next value, awaitComplete() to assert clean completion, and awaitError() to assert it finished with a Throwable.
@Test
fun `emits loading then success`() = runTest {
flowOf(Loading, Success(data)).test {
assertEquals(Loading, awaitItem())
assertEquals(Success(data), awaitItem())
awaitComplete()
}
}
Turbine is strict on purpose: if the test {} block ends with emitted events still unread, it throws a TurbineAssertionError rather than letting you silently ignore them. Call cancelAndIgnoreRemainingEvents() when you deliberately don't care about what's left.
Some flows never complete on their own, a StateFlow collector, an infinite polling loop. Launching that kind of work in runTest's main scope would make the test hang forever waiting for it to join. TestScope.backgroundScope exists for exactly this: coroutines launched there are automatically cancelled the moment the test body returns, so runTest never waits on them.
@Test
fun `collects state changes`() = runTest {
val vm = MyViewModel()
val values = mutableListOf<UiState>()
backgroundScope.launch {
vm.uiState.toList(values) // never completes on its own, fine here
}
advanceUntilIdle()
assertEquals(UiState.Success, values.last())
}
Rule of thumb: main TestScope for work that's supposed to finish, backgroundScope for anything open-ended you're just observing.
Putting it together: testing a ViewModel end to end means combining everything so far. Inject a **fake** repository (not a real network call), wire a MainDispatcherRule so viewModelScope lands on your test dispatcher, run the body in runTest, trigger the action, then drive time and assert.
class MyViewModelTest {
@get:Rule val mainRule = MainDispatcherRule()
private val fakeRepo = FakeUserRepository()
@Test
fun `loads user into state`() = runTest {
val vm = MyViewModel(fakeRepo)
vm.load(userId = 1)
advanceUntilIdle()
assertEquals(UiState.Success(user), vm.state.value)
}
}
Notice what's absent: no real network, no Thread.sleep, no device. That combination, fakes plus an injected test dispatcher plus virtual time, is precisely what makes coroutine-heavy ViewModel logic fast and deterministic to test.