AdvanceUntilIdle Not Working | Fix Stuck Tests Fast

When AdvanceUntilIdle stalls, your coroutines are running off the test scheduler or a never-ending job keeps the queue busy, so time can’t drain.

You wrote a coroutine test, hit advanceUntilIdle(), and the test hangs or returns too soon. It feels like the test clock is lying. Most of the time the clock is fine. The setup is the problem.

advanceUntilIdle() only drives work that is scheduled on dispatchers linked to the test scheduler. If your code hops to a dispatcher that is not under that scheduler, or if you keep a collector running forever, “idle” never arrives.

What AdvanceUntilIdle Actually Drives

advanceUntilIdle() belongs to the coroutine test toolbox. Its job is simple: run all queued work on the test scheduler, moving virtual time forward until the scheduler has no more tasks.

That scope matters. The scheduler only sees tasks created on dispatchers that share it. In runTest, that usually means a TestDispatcher created from the test’s scheduler, plus any dispatcher you swapped in through dependency injection.

If your production code uses Dispatchers.IO, Dispatchers.Default, a new CoroutineScope(SupervisorJob()), or an injected scope that uses a real dispatcher, that work is outside the test scheduler. Your test may sit at advanceUntilIdle() while “real” work runs elsewhere, or it may finish early while background work is still happening.

  • Runs Scheduler Tasks — It executes queued coroutines tied to the test scheduler.
  • Skips Real Time — It advances virtual delays without waiting on wall-clock time.
  • Stops On Empty Queue — It returns when the scheduler has no pending tasks.
  • Won’t Chase Other Dispatchers — It cannot pull work back from IO, Default, or custom dispatchers not linked to the scheduler.

AdvanceUntilIdle Not Working In runTest Scenarios

If you’re seeing advanceuntilidle not working, start by naming the failure mode. Most “bugs” fall into a small set of patterns, and each one has a quick check you can run.

Symptom Likely Cause Quick Check
Hangs forever Forever job keeps queue non-empty Cancel the collector job and retry
Returns too soon Work runs on a real dispatcher Assert your dispatcher is injected in tests
Flaky ordering Tasks scheduled at “now” not run Call runCurrent() before asserts
Time never advances Delay uses real time Ensure delay uses the test dispatcher
UI never updates Compose idling not aligned Use Compose test clock and idle APIs

Pick the row that matches your test, then fix that root cause. Trying random calls like delay(1) in the test body can mask the issue and make future tests brittle.

Fix Dispatcher Mismatches First

The most common reason advanceUntilIdle() “does nothing” is a dispatcher mismatch. Your code launches on a dispatcher the scheduler can’t see, so the test queue is empty while work is happening.

Make dispatchers injectable. A tiny abstraction is enough: pass a CoroutineDispatcher or a small dispatcher holder into the class you test. In production you supply Dispatchers.Main or IO as needed. In tests you supply a StandardTestDispatcher from the runTest scope.

  • Inject Dispatchers — Pass dispatchers into repositories, use cases, and view models instead of calling Dispatchers.IO directly.
  • Share One Scheduler — Create test dispatchers from the same test scheduler so virtual time is consistent.
  • Set Main In Tests — When code uses Dispatchers.Main, replace it with a test dispatcher using the Main dispatcher rule.

Dispatcher choice inside tests also changes what you see. StandardTestDispatcher queues work, so nothing runs until you call runCurrent() or advance time. That is why a test can read stale state right after a call that launches coroutines. UnconfinedTestDispatcher runs tasks eagerly on the current call stack, yet it still uses the same scheduler for delays. Eager execution can make tests shorter, but it can also hide ordering issues that show up in the app.

A simple rule helps: start with StandardTestDispatcher for predictable scheduling, then add one explicit advance step in the test. If a test still feels noisy, switch to UnconfinedTestDispatcher for that one case and keep the rest on the standard dispatcher.

Watch out for hidden scopes. If your code builds its own CoroutineScope with a new job and a dispatcher you didn’t inject, you split execution across schedulers. In unit tests, that shows up as work that never completes or state that never arrives.

Spot The “New Scope” Trap

Creating a new scope inside the system under test can be fine in production, yet it makes tests harder. If that scope uses a real dispatcher, the test scheduler can’t drive it. If that scope uses a test dispatcher but a different scheduler, time control gets split.

  • Pass A Scope — Let the caller supply a scope, or accept a CoroutineScope in the constructor for testability.
  • Use Structured Concurrency — Launch work in a provided scope so the test can own cancellation.
  • Avoid Hidden Dispatchers — Don’t bury Dispatchers.Default in helper objects that you can’t swap.

Handle Forever Work And Hot Flows

Another classic hang is a collector that never ends. The scheduler sees a task that stays active, so the queue is never empty and advanceUntilIdle() waits forever.

This happens a lot with hot flows, stateIn, shareIn, long-lived loops, and sampling or ticking operators. Your app wants that work to run continuously. Your test needs a clean stopping point.

A telltale sign is a flow that emits on a timer or keeps a channel open. Operators like sample, debounce, or a loop that does while (isActive) { delay(...) } create a stream of scheduled tasks. From the scheduler’s point of view, there is always “one more” job to run. The call is doing what it says: it keeps running until the queue is empty, and the queue never empties.

  • Collect With A Limit — Use a bounded collection like take(n) or read one state value and then cancel the job.
  • Cancel After Assert — Keep a handle to the collector job and cancel it once you have the state you need.
  • Use backgroundScope — In coroutine test APIs, long-lived background work can be launched in backgroundScope so the test body can finish.

If you use a testing helper like Turbine for flows, follow its pattern: collect what you need, then stop collection. A test that keeps collecting until “idle” is fragile when the flow is designed to keep emitting.

Make Idle Meaningful

Ask yourself what “done” means for the behavior you’re testing. Often you don’t need the whole pipeline to finish. You need one emitted value, one database write, one navigation event, or one call to a mocked dependency.

Shape your test around that. Start the action, advance time to the point where that outcome should happen, assert, then cancel long-lived jobs. This keeps tests fast and keeps advanceUntilIdle() predictable.

Use The Right Advance Call For The Job

advanceUntilIdle() is a strong default when the system under test schedules finite work and you want all of it to run. When you need tighter control, other scheduler calls can fit better.

  • runCurrent — Runs tasks scheduled at the current virtual time. Handy after launching work that posts back to the dispatcher “right now”.
  • advanceTimeBy — Moves virtual time forward by a known amount and runs due tasks. Great when your code uses delays, debounces, or timeouts.
  • advanceUntilIdle — Runs everything until the scheduler queue drains. Best when work is finite and you want the end state.

Also note the boundary between the test body and background work. runTest tracks coroutines launched in the test scope and waits for them at the end. Background jobs that should outlive a single action belong in backgroundScope. That keeps your test from waiting on a job that is designed to keep running, while still letting that job feed state into the code you’re exercising.

If your test hangs on advanceUntilIdle(), try switching the shape instead of sprinkling sleeps. Use advanceTimeBy to the point where your expected event should happen, then assert. If the code keeps scheduling more work after that, it won’t block your test.

Compose And UI Tests: Two Clocks To Align

UI tests can add a second layer of “idleness”. Compose has its own synchronization rules, and it may wait for the UI to become idle before allowing actions and assertions. Coroutine test time control is separate unless you wire them together.

If your UI state is driven by a view model that uses coroutines, align dispatchers the same way as in unit tests. Then use the Compose testing APIs to wait for the UI to settle. Don’t rely on advanceUntilIdle() alone to flush recompositions.

  • Keep State On Main — Make sure UI-facing state updates happen on the main dispatcher you control in tests.
  • Wait For Compose Idle — Use Compose test rule idle waiting so assertions run after pending UI work completes.
  • Control The Compose Clock — When animations or time-based effects exist, use the test clock controls instead of real delays.

If you still see advanceuntilidle not working in UI tests, isolate where the stall lives. First verify the view model emits the expected state in a plain unit test. Then verify the UI renders that state with a fake view model. Split the problem before you glue it back together.

A Repeatable Debug Checklist

When a coroutine test gets stuck, you want a short path to the cause. This checklist keeps you from chasing shadows and keeps your fixes consistent across the codebase.

  1. Confirm The Scheduler — Use runTest and ensure your test dispatcher comes from the test scope.
  2. Replace Main Dispatcher — If production code touches Dispatchers.Main, install a main dispatcher rule with a test dispatcher.
  3. Find Real Dispatchers — Search your code for Dispatchers.IO and Dispatchers.Default inside the system under test and replace with injected dispatchers.
  4. Check Hidden Scopes — Look for CoroutineScope(...) created inside classes and make scopes injectable where it affects behavior.
  5. Stop Forever Collectors — Keep a job handle for flow collection, cancel it after the assert, or use bounded operators like take.
  6. Pick The Right Advance — Use runCurrent() for immediate tasks, advanceTimeBy() for known delays, and advanceUntilIdle() for finite queues.
  7. Fail Fast On Hangs — Wrap the wait point with a test timeout so a stuck test reports the stack instead of eating your whole suite.

Once you fix the root issue, simplify the test. A clean test reads like a short story: set up dependencies, run the action, advance time in one clear way, and assert one or two outcomes. When your tests follow that rhythm, advanceUntilIdle() becomes a tool you trust again.

When you hit a snag, log scheduled tasks and keep fixes local not sprinkled across suites.