You have a search box that debounces input by 300 ms before querying. Write/describe a test using virtual time that proves a query fires once after the window and not earlier.
answer
- bracket: advanceTimeBy(299) → none, +1 → one
- currentTime confirms boundary (300)
- rapid emits within window collapse to one
- cancel the collector job so the test ends
- Flow.debounce(300) is virtualized
basics
~20 sEmit input, advance the virtual clock to just under 300 ms and check nothing queried yet, then advance past 300 ms and check exactly one query fired. Rapid input before the window should collapse into a single query.
solid answer
~40 sUse runTest plus advanceTimeBy and currentTime to bracket the debounce. Feed input into the system under test, then advanceTimeBy(299); runCurrent() and assert no query happened; advanceTimeBy(1) (now at 300) and runCurrent() and assert exactly one query fired. To prove collapsing, emit several characters back-to-back (each within the 300 ms window) using advanceTimeBy(<300) between them, then advance past the window and assert a single query with the final value. Because delay(300) (or Flow.debounce(300)) is virtualized, the test is instant and deterministic. Drive a backing flow with a MutableSharedFlow/Channel, collect in a launched coroutine, and use advanceUntilIdle() at the end to drain. Reading currentTime lets you assert the query happened exactly at the boundary.
go deeper
Understands the idea: advance past 300 ms, expect one query; before, expect none.
Writes the bracket with advanceTimeBy + runCurrent and asserts counts.
Handles collapsing input, cancels the infinite collector, and asserts exact currentTime at the boundary.
Designs a reusable flow-test harness, reasons about hot-flow lifecycle and determinism across the suite.
## The behavior to test Debounce: after the last input, wait a quiet window (300 ms); if no new input arrives, fire one query with the latest value. Virtual time makes the 300 ms instant and exact. ## Bracketing the window The core technique is **just-before / just-after** using `advanceTimeBy` + `currentTime`: ```kotlin @Test fun debounceFiresOnceAfterWindow() = runTest { val input = MutableSharedFlow<String>(extraBufferCapacity = 8) val queries = mutableListOf<String>() val job = launch { input.debounce(300).collect { queries += it } // Flow.debounce } input.emit("ko") advanceTimeBy(299); runCurrent() assertEquals(emptyList<String>(), queries) // window not elapsed advanceTimeBy(1); runCurrent() assertEquals(listOf("ko"), queries) // fired at exactly t=300 assertEquals(300, currentTime) job.cancel() } ``` ## Proving rapid input collapses to one query Emit several values, each **within** the window, so each new emission resets the debounce timer: ```kotlin @Test fun rapidInputCollapses() = runTest { val input = MutableSharedFlow<String>(extraBufferCapacity = 8) val queries = mutableListOf<String>() val job = launch { input.debounce(300).collect { queries += it } } input.emit("k"); advanceTimeBy(100) input.emit("ko"); advanceTimeBy(100) input.emit("kot"); // no quiet window yet advanceTimeBy(299); runCurrent() assertEquals(emptyList<String>(), queries) advanceTimeBy(1); runCurrent() assertEquals(listOf("kot"), queries) // single query, latest value job.cancel() } ``` ## Why each step - **launch + collect** runs the consumer; with StandardTestDispatcher you may need `runCurrent()`/advancing to flush it. - **advanceTimeBy(299)+runCurrent** asserts the query has NOT fired (boundary not reached). - **advanceTimeBy(1)+runCurrent** crosses the boundary; `currentTime == 300` confirms exact timing. - **job.cancel()** stops the infinite collector so the test ends (otherwise the consumer never goes idle). ## Gotchas - A `MutableSharedFlow` collector never completes — cancel the job or use a turbine-style helper; don't expect `advanceUntilIdle()` to finish on its own. - Remember the `advanceTimeBy` boundary nuance: the resume at exactly the new time may need `runCurrent()`. - Only `delay`/`debounce` timing is virtual; any real-clock logic in the SUT won't be controlled.
- Why does the test hang if you forget job.cancel() and call advanceUntilIdle()?Collecting a MutableSharedFlow never completes, so the scheduler is never idle and advanceUntilIdle loops/awaits forever. Cancel the collecting job (or use a bounded test collector like Turbine).
- How would you assert the query carried the latest typed value, not an intermediate one?debounce emits only the most recent value after the quiet window; assert queries == listOf(finalValue). The collapse test above demonstrates exactly this.
saying these in an interview costs you the question
- Uses Thread.sleep(300) to wait for the debounce
- Asserts after only advanceTimeBy(299) and expects a query
- Forgets to cancel the infinite collector, hanging the test
- Doesn't reset the timer assumption when emitting rapidly
- Believes advanceUntilIdle completes a never-ending shared-flow collect