skip to content

How do you deterministically test a Kotlin Flow, including hot flows like StateFlow that never complete?

level: seniorimportance: should knowfreq 44%

answer

  1. Cold completing flow: flow.toList() under runTest
  2. StateFlow.toList() hangs — never completes
  3. Turbine: test { awaitItem(); cancelAndIgnoreRemainingEvents() }
  4. awaitItem/awaitComplete/awaitError/expectNoEvents
  5. StateFlow.value after advanceUntilIdle for latest-state checks

basics

~20 s

For a normal flow you collect its values into a list inside runTest and check them. For a flow that never ends, like StateFlow, you use a tool such as Turbine to read items one at a time and then stop collecting.

solid answer

~40 s

Cold flows that complete can be tested by collecting inside `runTest`: `flow.toList() shouldBe listOf(...)`, since `runTest`'s virtual clock makes any internal `delay`s deterministic. Hot flows (`StateFlow`, `SharedFlow`) never complete, so `toList()` would hang; you launch collection in a separate coroutine and cancel it, or — idiomatically — use **Turbine** (`app.cash.turbine`): `flow.test { awaitItem() ; ... ; awaitComplete() / cancelAndIgnoreRemainingEvents() }`. Turbine gives `awaitItem`, `awaitError`, `awaitComplete`, and `expectNoEvents`, and fails if unconsumed events remain, catching missed emissions. For `StateFlow` you often assert on `.value` after advancing time, or collect with Turbine while driving the producer. Combine with `advanceUntilIdle()` to flush queued work before asserting. The key is always running under `runTest` so emission ordering and delays are virtual and deterministic.

go deeper

for a junior

Knows you collect a flow's emissions and compare against an expected list.

for a middle

Tests cold flows with toList() under runTest and knows StateFlow won't complete.

for a senior

Uses Turbine (or launch+cancel) for hot flows, drives virtual time, and asserts emission sequences precisely.

for a principal

Standardizes Flow-testing patterns (Turbine vs value assertions), backpressure/conflation awareness, and flake-free CI conventions.

## Cold vs Hot Flows - A **cold flow** (`flow { ... }`) runs its producer per collector and usually **completes**. - A **hot flow** (`StateFlow`, `SharedFlow`) is always active and **never completes** — collection only ends on cancellation. This distinction drives how you test them. ## Testing Cold, Completing Flows Collect into a list under `runTest`: ```kotlin @Test fun `emits 1,2,3`() = runTest { val flow = flow { emit(1); delay(100); emit(2); emit(3) } assertEquals(listOf(1, 2, 3), flow.toList()) } ``` `runTest`'s virtual `TestCoroutineScheduler` skips the `delay(100)`, so this is instant and deterministic. ## Testing Hot Flows (the trap) `stateFlow.toList()` **hangs forever** because the flow never completes. Two solutions: ### 1. Manual launch + cancel ```kotlin val results = mutableListOf<Int>() val job = launch { stateFlow.toList(results) } advanceUntilIdle() job.cancel() assertEquals(listOf(0, 1), results) ``` ### 2. Turbine (idiomatic) ```kotlin import app.cash.turbine.test @Test fun `state flow emits updates`() = runTest { viewModel.state.test { assertEquals(State.Loading, awaitItem()) viewModel.load() assertEquals(State.Loaded, awaitItem()) cancelAndIgnoreRemainingEvents() } } ``` Turbine API: `awaitItem()`, `awaitComplete()`, `awaitError()`, `expectNoEvents()`, `cancelAndIgnoreRemainingEvents()`. It **fails the test if events are left unconsumed**, catching extra or missed emissions. ## StateFlow value assertions Because `StateFlow` always holds a current value, you can also advance time then assert on `.value` directly — simpler when you only care about the latest state, not the sequence. ## Always Use runTest Wrapping in `runTest` makes the scheduler virtual so emission ordering and `delay`-driven timing are reproducible; pair with `advanceUntilIdle()` / `advanceTimeBy()` to flush producer work before reading.

  • Why does calling toList() on a StateFlow in a test hang?
    StateFlow is a hot flow that never completes, so toList() waits for a terminal event that never arrives. You must cancel collection or use Turbine.
  • What does Turbine do if your test leaves emissions unconsumed?
    It fails the test, ensuring you assert on every emission and catch unexpected extra events; cancelAndIgnoreRemainingEvents() opts out explicitly.

A cold flow is a song with an ending you can record fully; a hot flow is a live radio stream — you must tune in, capture a few moments, then switch off, which is what Turbine's await + cancel does.

saying these in an interview costs you the question

  • Calling toList() on a StateFlow/SharedFlow and wondering why the test hangs
  • Testing flows outside runTest, getting nondeterministic timing
  • Forgetting to cancel manual collection coroutines
  • Not advancing virtual time before asserting on emissions
  • Ignoring unconsumed-event failures Turbine raises

context