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.IOdirectly. - 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
CoroutineScopein 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.Defaultin 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
backgroundScopeso 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.
- Confirm The Scheduler — Use
runTestand ensure your test dispatcher comes from the test scope. - Replace Main Dispatcher — If production code touches
Dispatchers.Main, install a main dispatcher rule with a test dispatcher. - Find Real Dispatchers — Search your code for
Dispatchers.IOandDispatchers.Defaultinside the system under test and replace with injected dispatchers. - Check Hidden Scopes — Look for
CoroutineScope(...)created inside classes and make scopes injectable where it affects behavior. - Stop Forever Collectors — Keep a job handle for flow collection, cancel it after the assert, or use bounded operators like
take. - Pick The Right Advance — Use
runCurrent()for immediate tasks,advanceTimeBy()for known delays, andadvanceUntilIdle()for finite queues. - 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.
