skip to content

Why should you launch the collection of a hot StateFlow/SharedFlow in runTest's backgroundScope rather than the test scope itself?

level: middleimportance: must knowfreq 70%

answer

  1. Hot flows never complete → collect hangs
  2. Test-scope launch → UncompletedCoroutinesError
  3. backgroundScope auto-cancels at test end
  4. Same testScheduler, virtual time still works
  5. UnconfinedTestDispatcher → eager subscription

basics

~10 s

A hot flow never completes, so collecting it in the main test coroutine would hang forever. backgroundScope runs the collector alongside the test and is automatically cancelled when the test body finishes.

solid answer

~40 s

Hot flows (StateFlow, SharedFlow) never terminate — their collect call suspends indefinitely. If you launch that collect in the test coroutine and then await it, runTest would deadlock or fail with an 'uncompleted coroutines' error because the job stays active. runTest exposes backgroundScope (a TestScope property): coroutines launched there run on the same virtual-time scheduler but are cancelled automatically when the test body returns and before runTest checks for leaked coroutines. So the pattern is backgroundScope.launch { flow.collect { results += it } }, then advance virtual time / trigger updates, then assert on the captured results. This gives you a live collector for observing emissions without the never-completing collect blocking test completion.

code

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

go deeper

for a junior

Knows backgroundScope.launch is the place to collect a hot flow in a test.

for a middle

Explains the UncompletedCoroutinesError and that backgroundScope auto-cancels on the same scheduler.

for a senior

Adds why UnconfinedTestDispatcher(testScheduler) is used for eager subscription to capture the initial value.

for a principal

Reasons about deterministic subscription timing and standardizes a team pattern that avoids both leaks and missed initial emissions.

## The problem: hot flows never complete `StateFlow` and `SharedFlow` are **hot** and **infinite** — `collect { }` on them suspends forever waiting for the next emission. In a test, two things go wrong if you collect them naively: 1. If you `collect` directly in the test body (`flow.collect { }`), the test **hangs**. 2. If you `launch { flow.collect { } }` in the **test scope**, `runTest` finishes the body but then runs its end-of-test check and sees an **active, uncompleted coroutine**, failing with an `UncompletedCoroutinesError`. ## The fix: backgroundScope `runTest` gives you a `TestScope`, and `TestScope` exposes: ```kotlin public val TestScope.backgroundScope: CoroutineScope ``` Coroutines launched in `backgroundScope`: - run on the **same virtual-time `TestCoroutineScheduler`** as the test, so `advanceUntilIdle()` / `advanceTimeBy()` drive them; - are **cancelled automatically** when the test body completes — *before* the leak check — so they never trip `UncompletedCoroutinesError`. ## Canonical pattern ```kotlin @Test fun emits_two_states() = runTest { val vm = SearchViewModel() val emissions = mutableListOf<UiState>() backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { vm.state.collect { emissions.add(it) } } vm.search("abc") advanceUntilIdle() assertEquals(listOf(UiState.Idle, UiState.Loading, UiState.Result), emissions) } ``` ## Why UnconfinedTestDispatcher is common here With the default `StandardTestDispatcher`, launched coroutines do **not** run until you advance the scheduler, so the collector may miss the very first emission timing. Using `UnconfinedTestDispatcher(testScheduler)` starts the collector **eagerly** so it is subscribed before you trigger updates — important for capturing the initial StateFlow value. You still share `testScheduler` so virtual time stays unified. ## Summary - Hot flow + test scope = deadlock or `UncompletedCoroutinesError`. - Use `backgroundScope.launch { ... }` — auto-cancelled, same scheduler. - Often pair with `UnconfinedTestDispatcher(testScheduler)` so the collector subscribes eagerly.

  • What error do you get if you launch the collector in the test scope instead?
    UncompletedCoroutinesError at the end of runTest, because the infinite collect coroutine is still active during the leak check.
  • Does backgroundScope share virtual time with the test?
    Yes — it uses the same TestCoroutineScheduler, so advanceTimeBy/advanceUntilIdle drive coroutines launched there.

backgroundScope is like a kettle you leave boiling while you work; when you leave the kitchen (test ends), it switches itself off automatically.

saying these in an interview costs you the question

  • Calling flow.collect directly in the test body (it hangs)
  • Launching the collector in the test scope and ignoring the leak error
  • Manually cancelling the collector job instead of using backgroundScope
  • Creating a brand-new CoroutineScope that does not share testScheduler

context