You mock the clock, advance it, and assert. The assertion runs before the code under test has finished.
async function poll() {
await new Promise((r) => setTimeout(r, 1000))
return 'done'
}
test('resolves after a second', () => {
vi.useFakeTimers()
const p = poll()
vi.advanceTimersByTime(1000)
expect(p).resolves.toBe('done') // never settles in time
})
What's actually happening
advanceTimersByTime is synchronous. It fires every timer callback whose deadline has passed and then returns immediately — in the same tick.
But poll doesn't finish when the timer fires. The timer resolves a promise, and the code after await is queued as a microtask. Microtasks only run when the current synchronous block yields. Your assertion is still inside that block.
So the ordering is:
-
advanceTimersByTime(1000)— timer callback runs, promise resolves -
expect(...)— still in the same tick, continuation hasn't run - test ends
- microtask would have run, but nobody is listening
The fix
Use the async variant. It advances the clock and drains the microtask queue between timers:
test('resolves after a second', async () => {
vi.useFakeTimers()
const p = poll()
await vi.advanceTimersByTimeAsync(1000)
await expect(p).resolves.toBe('done')
})
Every timer method has an async twin:
| sync | async |
|---|---|
advanceTimersByTime |
advanceTimersByTimeAsync |
runAllTimers |
runAllTimersAsync |
runOnlyPendingTimers |
runOnlyPendingTimersAsync |
advanceTimersToNextTimer |
advanceTimersToNextTimerAsync |
The async versions also pick up timers that get scheduled during the advance — a setTimeout registered inside a resolved promise, for example. The sync versions cannot see those, because the promise hasn't resolved yet when they run.
When you actually need the sync version
Almost never, if there's a promise anywhere in the chain. Reach for the sync methods only when the code under test is entirely callback-based with no promises involved:
const spy = vi.fn()
setInterval(spy, 100)
vi.advanceTimersByTime(350)
expect(spy).toHaveBeenCalledTimes(3) // fine, no microtasks in play
Two more things that bite
Restore the clock. Leaving fake timers installed leaks into the next file when tests share a worker:
afterEach(() => { vi.useRealTimers() })
Watch out for infinite intervals. runAllTimersAsync on a setInterval that reschedules forever will throw after 10 000 iterations rather than hang. That error message is telling you to use advanceTimersByTimeAsync with a bound instead.
The rule
If there is an
awaitanywhere between the timer and the assertion, use theAsyncvariant.
That one substitution fixes the overwhelming majority of "my fake timer test just hangs" reports.
These posts come out of material I build for my Udemy courses — 25 of them now, mostly drill-based, across Go, Python, TypeScript, testing and Three.js. If this was useful, the full list is at udemy-c1f90.web.app. The links on that page carry a coupon I refresh each month, which usually lands around half the list price.
Top comments (0)