skip to content

A test collects a StateFlow into a list and expects three intermediate values, but only sees the first and last. Why, and how do you make the intermediate updates observable?

level: seniorimportance: should knowfreq 55%

answer

  1. StateFlow keeps only the latest value
  2. Slow collector skips intermediate states
  3. StandardTestDispatcher: collector runs only after advance
  4. UnconfinedTestDispatcher + runCurrent between updates
  5. Need all values? Use buffered SharedFlow

basics

~10 s

StateFlow conflates: if several updates happen before the collector runs under virtual time, it only delivers the newest. Let the collector run between updates by advancing time, or assert only on the final value.

solid answer

~40 s

StateFlow is conflated — it stores only the latest value and a slow collector skips intermediate values it never observed. Under virtual time with StandardTestDispatcher, the collector coroutine does not run between your synchronous .value = ... updates, so all intermediate states collapse to the last one. To observe each step you must give the collector a chance to run between emissions: use UnconfinedTestDispatcher(testScheduler) so the collector resumes eagerly, or interleave advanceUntilIdle()/runCurrent() (or real suspension/delay) between updates so each value is actually emitted while subscribed. If you genuinely need every intermediate value regardless of timing, model the stream as a SharedFlow with sufficient buffer/extraBufferCapacity, or test the producer logic directly. Conflation is a feature of StateFlow, not a bug — tests must respect it.

code

kotlin · 12 lines
kotlin
@Test
fun observesEachStep() = runTest {
    val flow = MutableStateFlow(0)
    val seen = mutableListOf<Int>()
    backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
        flow.collect { seen.add(it) }
    }
    flow.value = 1; runCurrent()
    flow.value = 2; runCurrent()
    flow.value = 3; runCurrent()
    assertEquals(listOf(0, 1, 2, 3), seen)
}

go deeper

for a junior

Recognizes that StateFlow only holds the latest value.

for a middle

Connects missed values to conflation plus StandardTestDispatcher not running the collector between updates.

for a senior

Knows multiple fixes (UnconfinedTestDispatcher + runCurrent, buffered SharedFlow, assert-final) and their trade-offs.

for a principal

Decides at design time whether intermediate emissions are part of the contract and picks StateFlow vs SharedFlow accordingly to keep tests deterministic.

## What conflation means `StateFlow` (and conflated `SharedFlow`) is **conflated**: it keeps only the **most recent** value. A collector that is slower than the producer is **not** guaranteed to see every intermediate value — it sees the latest available each time it is ready. From the StateFlow docs: *"the collector always sees the most recent state, but slow collectors can skip intermediate states."* ## Why the test sees only first and last ```kotlin @Test fun missesMiddle() = runTest { val flow = MutableStateFlow(0) val seen = mutableListOf<Int>() backgroundScope.launch { flow.collect { seen.add(it) } } // StandardTestDispatcher flow.value = 1 flow.value = 2 flow.value = 3 advanceUntilIdle() // seen == [0, 3] -- 1 and 2 conflated away } ``` With the default **`StandardTestDispatcher`**, the launched collector does **not** run until the scheduler advances. The three `flow.value = ...` assignments execute synchronously first; by the time the collector finally runs, the stored value is already `3`. The initial `0` was captured at subscription, then `3` — `1` and `2` are conflated away. ## Fix 1 — eager collector so it runs between updates Use `UnconfinedTestDispatcher(testScheduler)` so the collector resumes immediately after each emission: ```kotlin backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { flow.collect { seen.add(it) } } ``` Even so, conflation can still drop values if multiple assignments happen with no suspension point in between. To be safe, interleave a yield/advance: ```kotlin flow.value = 1; runCurrent() flow.value = 2; runCurrent() flow.value = 3; runCurrent() ``` Now the collector observes each value because it is given a turn between updates. ## Fix 2 — use SharedFlow with a buffer when every value matters If the **sequence** is the contract, a `StateFlow` is the wrong tool. A `MutableSharedFlow(replay = 0, extraBufferCapacity = N)` with `BufferOverflow.SUSPEND` preserves intermediate emissions without conflation. Then a buffered collector sees them all. ## Fix 3 — assert on final state only If intermediates are not part of the contract, just assert `flow.value == 3` (or the last captured emission). This is the most robust test. ## Key takeaway Missed intermediate StateFlow values are **conflation**, not a test framework bug. Either (a) let the collector run between emissions (UnconfinedTestDispatcher + runCurrent), (b) switch to a buffered SharedFlow when every emission matters, or (c) assert only the final state.

  • Does using UnconfinedTestDispatcher alone guarantee you see every intermediate value?
    No. If multiple assignments occur with no suspension point between them, conflation can still drop values; you must yield/runCurrent between updates or use a buffered SharedFlow.
  • If the sequence of every emission is the real contract, which type fits better?
    A MutableSharedFlow with extraBufferCapacity and BufferOverflow.SUSPEND, which does not conflate.

StateFlow is a whiteboard showing the latest number; if you erase and rewrite three times before someone looks, they only ever read whatever is on it when they glance.

saying these in an interview costs you the question

  • Calling conflation a bug in runTest or the dispatcher
  • Believing StateFlow delivers every intermediate value to all collectors
  • Forcing intermediate visibility by adding real Thread.sleep
  • Switching dispatchers without understanding why values were dropped
  • Asserting on a fixed list of intermediates that is timing-dependent and flaky

context