Testing Coroutines & Flow
Coroutines run asynchronously by design, and that's exactly what makes them awkward to test naively. A
test that calls viewModel.loadTrails() and immediately checks the result can finish and
report a pass — or a misleading failure — before the launched coroutine has actually done any work at
all, since the test function returns long before the background operation completes. This lesson
covers the tools built specifically to make coroutine and Flow code testable in a predictable,
deterministic way.
The Fix: runTest and Virtual Time
kotlinx-coroutines-test provides runTest { }, a special test builder that
replaces real waiting with virtual time. Inside a runTest block, any
delay or suspension is fast-forwarded automatically instead of actually pausing the test thread — the
test waits precisely as long as needed for every launched coroutine to finish, and no longer, without
ever really sleeping. Here it is testing TrailLog's TrailListViewModel.loadTrails() and
its resulting StateFlow:
@Test
fun loadTrails_updatesStateFlowWithRepositoryResult() = runTest {
val fakeRepository = FakeTrailRepository(
trails = listOf(Trail(id = 1, name = "Ridgeline Loop", notes = "",
photoUri = null, latitude = 0.0, longitude = 0.0,
distanceKm = 7.2, date = "2026-08-01")
)
)
val viewModel = TrailListViewModel(fakeRepository)
viewModel.loadTrails()
val result = viewModel.trails.value
assertEquals(1, result.size)
assertEquals("Ridgeline Loop", result[0].name)
}
Because the whole test body runs inside runTest, the coroutine that
loadTrails() launches through viewModelScope.launch { } (see
Flow vs LiveData) is guaranteed to have completed by the time
viewModel.trails.value is checked — no manual waiting, no flaky timing, no arbitrary
delay() calls sprinkled into the test itself.
Swapping Out Dispatchers.Main
viewModelScope.launch defaults to Dispatchers.Main, which under the hood
expects a real Android main thread with a working Looper. A plain JVM unit test has
neither — there's no actual Android UI thread running at all — so any code that touches
Dispatchers.Main directly crashes instantly in a unit test unless it's replaced first.
The standard fix runs once before each test and undoes itself after:
private val testDispatcher = StandardTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
Dispatchers.setMain(testDispatcher) swaps in a dispatcher that works fine on a plain JVM
and cooperates with runTest's virtual time. Dispatchers.resetMain() restores
the original dispatcher afterward, so this substitution never leaks into other, unrelated tests
running later in the same suite.
Asserting an Emission Sequence with Turbine
Testing a single final StateFlow value is one thing; testing that a Flow
emits values in a specific order is trickier to write by hand. Turbine is a
small testing library built specifically for this — it lets a test collect a Flow step by step and
assert exactly what comes out, in what order. Recall TrailLog's sealed Result pattern from
Error Handling with Sealed Results; here's a Turbine
test confirming a repository call emits Loading before it emits Success:
@Test
fun syncTrails_emitsLoadingThenSuccess() = runTest {
trailRepository.syncTrails().test {
assertEquals(Result.Loading, awaitItem())
val second = awaitItem()
assertTrue(second is Result.Success)
awaitComplete()
}
}
.test { } is Turbine's entry point — inside the block, awaitItem() suspends
until the next value is emitted and returns it, letting the test check emissions one at a time in the
exact order they occur. awaitComplete() confirms the Flow finished cleanly
afterward with nothing left unaccounted for. Without Turbine, writing this same order-sensitive
assertion by hand means manually collecting into a list and hoping timing lines up — Turbine makes the
sequence itself the thing under test.
Testing Cancellation
Coroutines are also expected to stop cleanly when cancelled — for example, if the user navigates away
from TrailLog's trail list mid-load. A focused test can launch the loading coroutine inside a scope,
cancel that scope, and confirm no further updates happen afterward (the StateFlow value
stays at whatever it was at the moment of cancellation, and no exception escapes). This is a narrower,
less common case than the emission-order testing above, but worth knowing runTest and
Turbine both cooperate correctly with cancellation rather than hanging or throwing unexpected errors.
kotlinx-coroutines-test and Turbine — but this website's own project doesn't run a live Android Studio build, so nothing here executes on the page itself.Dispatchers.setMain() call or a test that passes by accident rather than by design. Time spent getting comfortable with runTest here pays off across essentially every ViewModel and Repository test in a real, coroutine-heavy codebase.