Advanced Flow Testing with Turbine

AndroidKotlin
Share on LinkedIn

Collecting a Flow into a List told us the ViewModel worked. Turbine told us it emitted Loading twice, skipped Empty, then hit Success—because a race restarted load when refresh() fired during init. The list had three items; the bug was the order and duplicate loading state. Turbine exists for that level of scrutiny.

Turbine is a small test library for Kotlin Flows. It provides a test { } block, awaitItem(), completion assertions, and negative assertions (expectNoEvents) that list collectors cannot express.

Basic pattern

@Test
fun `emits loading then success`() = runTest {
    repository.results.test {
        repository.fetch()
        assertEquals(Loading, awaitItem())
        advanceUntilIdle()
        assertEquals(Success(data), awaitItem())
        awaitComplete()
    }
}

Or extension style:

viewModel.uiState.test {
    assertEquals(UiState.Idle, awaitItem())
    viewModel.onAction(Load)
    assertEquals(UiState.Loading, awaitItem())
    advanceUntilIdle()
    assertEquals(UiState.Data(items), awaitItem())
    cancelAndIgnoreRemainingEvents()
}

StateFlow and SharedFlow nuances

StateFlow always emits current value first:

@Test
fun stateFlow() = runTest {
    val state = MutableStateFlow(0)
    state.test {
        assertEquals(0, awaitItem()) // immediate replay
        state.value = 1
        assertEquals(1, awaitItem())
    }
}

SharedFlow without replay emits nothing until new events:

val events = MutableSharedFlow<Event>(replay = 0)

events.test {
    expectNoEvents()
    events.emit(Event.Click)
    assertEquals(Event.Click, awaitItem())
}

Set replay = 1 if subscribers need last event—test accordingly.

skipItems and takeItems

When initial emissions are irrelevant:

flow.test {
    skipItems(1) // skip initial StateFlow value
    triggerUpdate()
    assertEquals(expected, awaitItem())
}

For long streams, awaitItem() in a loop with timeout guards against infinite hangs.

expectNoEvents and debounce

Verify silence during debounce window:

@Test
fun debouncedSearch() = runTest {
    viewModel.queryFlow.test {
        viewModel.onQuery("k")
        viewModel.onQuery("ko")
        viewModel.onQuery("kot")
        expectNoEvents()
        advanceTimeBy(300)
        assertEquals("kot", awaitItem())
    }
}

expectNoEvents() fails fast if anything arrives—critical for debounce and distinctUntilChanged.

Error and completion

failingFlow.test {
    val error = awaitError()
    assertIs<IOException>(error)
}

finiteFlow.test {
    awaitItem()
    awaitComplete()
}

Do not call awaitItem() after awaitComplete().

Testing combine and flatMapLatest

flatMapLatest cancels prior inner Flows—assert cancellation by expecting no further items from old search:

@Test
fun flatMapLatestSwitch()() = runTest {
    repository.searchResults.test {
        repository.search("a")
        advanceUntilIdle()
        assertEquals(resultsForA, awaitItem())

        repository.search("ab")
        advanceUntilIdle()
        assertEquals(resultsForAb, awaitItem())
        expectNoEvents() // no stale "a" results
    }
}

Timeout tuning

Default await timeout is 3 seconds real time—fine locally. In CI with loaded runners, failures may be slow tests not logic bugs. Keep heavy work mocked; Turbine timeouts should never be the bottleneck.

Use cancelAndIgnoreRemainingEvents() when testing partial behavior—avoids Turbine reporting unconsumed events at block exit.

Testing SharedFlow replay buffers

When replay = 2, Turbine receives buffered items immediately on subscribe—account for that in assertions:

@Test
fun replayBuffer() = runTest {
    val flow = MutableSharedFlow<Int>(replay = 2)
    flow.emit(1)
    flow.emit(2)
    flow.test {
        assertEquals(1, awaitItem())
        assertEquals(2, awaitItem())
    }
}

cancelAndConsumeRemainingEvents

Use when testing fire-and-forget side effects without caring about trailing emissions—prevents test failure on unconsumed hot flow events after assertions complete.

What to measure after rollout

Track error rates, tail latency, and resource utilization for two weeks after changes land—most regressions appear under real traffic mixes, not in staging smoke tests. Keep a rollback path documented: feature flags, Helm revision, or Git revert with known good digest. Review on-call pages tied to the topic quarterly; delete alerts that never fire and add thresholds that would have caught your last incident.

Run a short blameless postmortem if production surprised you, even for minor issues. The goal is updating this runbook section with one concrete lesson per quarter so the next engineer inherits context, not just configuration snippets.

Documentation your team should maintain

Maintain a one-page runbook link from your main service README: prerequisites, owner rotation, last drill date, and known sharp edges. Link to vendor docs in the Resources section below but capture org-specific decisions (CIDR ranges, cluster names, approval gates) in internal docs that stay current. New hires should deploy a safe canary within a week using only that runbook—if they cannot, the doc is incomplete.

Pre-production checklist

Before promoting to production, walk through this list with someone who was not the primary author—fresh eyes catch assumptions.

If any item is "we will do that later," treat it as a release blocker for tier-1 services.

Common questions from reviewers

Reviewers and auditors often ask whether this approach scales with team growth and whether it fails safely. Answer explicitly in your design doc: what happens when dependencies are down, when credentials expire, and when traffic doubles overnight. Prefer defaults that deny or degrade gracefully over defaults that fail open. Document known limits (throughput ceilings, supported versions, regions) in the same place operators look during incidents—avoid scattering critical constraints across Slack threads.

Version and compatibility notes

Pin library and control-plane versions in production manifests; track upstream release notes quarterly. Run upgrade drills in non-production before bumping minor versions that touch serialization, auth, or CRD schemas. Keep a compatibility matrix in your internal wiki listing supported Kubernetes, broker, and SDK versions validated together.

Resources

Frequently asked questions

Why use Turbine instead of collecting Flows into lists?

List collection hides timing— you miss intermediate states, duplicate emissions, or spurious events. Turbine asserts event-by-event with timeouts and explicit completion, catching extra emissions that toList() would silently include.

How does Turbine work with runTest virtual time?

When you use turbine { } inside runTest, awaitItem uses the test scheduler for timeouts— delays in the Flow under test advance with advanceTimeBy. This keeps Flow tests fast and synchronized with coroutine tests.

What causes Turbine 'No items were collected' errors?

Usually the Flow is cold and nobody collected yet, SharedFlow has no replay and you subscribed late, or you forgot advanceUntilIdle before awaitItem. For StateFlow, remember it replays the current value immediately on collect.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →