How to test with fake time
Install the fake clock with vi.useFakeTimers, start the call without awaiting it, push the clock with await vi.advanceTimersByTimeAsync(1000), then await the result. Awaiting the call first hangs the test until the runner fails it at 5000 ms, because the timer it waits on never fires.
Why check this
Retry, debounce, poll and session-expiry code all wait on a timer, and a test that waits with them turns a 30 second backoff into 30 seconds of suite time. A fake clock removes the wait and changes the rules in ways no assertion shows.
Run this check when you add fake timers to a file, when a test starts timing out after a refactor, and before turning off test isolation for speed. The failure it prevents: a retry test that advances the clock, sees the mock called twice and passes, while the code waits 30 ms instead of 30 seconds.
Prerequisites
- Node 22.23.2 and vitest 5.0.0 here. Read your own version with
npx vitest --version: fake-timer behaviour moved between majors, and vitest 5 drives @sinonjs/fake-timers. - The vitest fake timers API for the method names below.
- The module under test, saved as
retry.tsnext to the tests.
// retry.ts
export const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
/** Calls send(). If it rejects, waits delayMs and calls it once more. */
export async function sendWithRetry(
send: () => Promise<string>,
delayMs = 1000,
): Promise<string> {
try {
return await send();
} catch {
await sleep(delayMs);
return send();
}
}
/** Measures its own wait with the clock the runtime gives it. */
export async function measuredSleep(ms: number): Promise<number> {
const start = Date.now();
await sleep(ms);
return Date.now() - start;
}
/** A timer that schedules the next one from inside itself. */
export function startHeartbeat(tick: () => void, ms = 1000): void {
const loop = (): void => {
tick();
setTimeout(loop, ms);
};
setTimeout(loop, ms);
}
Steps
- Step 1.
Write the retry test the way it reads naturally, awaiting the call, and run it.
// a-hang.test.ts import { describe, it, expect, vi, afterEach } from 'vitest'; import { sendWithRetry } from './retry'; afterEach(() => { vi.useRealTimers(); }); describe('sendWithRetry', () => { it('retries once after the delay', async () => { vi.useFakeTimers(); const send = vi.fn<() => Promise<string>>() .mockRejectedValueOnce(new Error('502')) .mockResolvedValue('ok'); await expect(sendWithRetry(send)).resolves.toBe('ok'); }); });npx vitest run a-hang.test.tsRUN v5.0.0 D:/how2check/scratch/fake-timers-date-tz ❯ a-hang.test.ts (1 test | 1 failed) 5012ms ❯ sendWithRetry (1) × retries once after the delay 5010ms ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ FAIL a-hang.test.ts > sendWithRetry > retries once after the delay Error: Test timed out in 5000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ a-hang.test.ts:7:3 5| 6| describe('sendWithRetry', () => { 7| it('retries once after the delay', async () => { | ^ 8| vi.useFakeTimers(); 9| const send = vi.fn<() => Promise<string>>() ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ Test Files 1 failed (1) Tests 1 failed (1) Start at 11:02:16 Duration 5.30s (tests 99%, import 1%, transform 1%)Nothing advanced the clock, so the 1000 ms timer inside
sleepnever ran and the promise stayed pending until the runner gave up. The report points at theit, which says nothing about which timer is stuck. - Step 2.
Start the call first, then compare the synchronous advance with the asynchronous one.
// b-advance.test.ts import { describe, it, expect, vi, afterEach } from 'vitest'; import { sendWithRetry } from './retry'; afterEach(() => { vi.useRealTimers(); }); function failingSend() { return vi.fn<() => Promise<string>>() .mockRejectedValueOnce(new Error('502')) .mockResolvedValue('ok'); } describe('sendWithRetry', () => { it('advanceTimersByTime', async () => { vi.useFakeTimers(); const send = failingSend(); const result = sendWithRetry(send); vi.advanceTimersByTime(1000); await expect(result).resolves.toBe('ok'); }); it('advanceTimersByTimeAsync', async () => { vi.useFakeTimers(); const send = failingSend(); const result = sendWithRetry(send); await vi.advanceTimersByTimeAsync(1000); await expect(result).resolves.toBe('ok'); expect(send).toHaveBeenCalledTimes(2); }); });npx vitest run b-advance.test.tsRUN v5.0.0 D:/how2check/scratch/fake-timers-date-tz ❯ b-advance.test.ts (2 tests | 1 failed) 5049ms ❯ sendWithRetry (2) × advanceTimersByTime 5041ms ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ FAIL b-advance.test.ts > sendWithRetry > advanceTimersByTime Error: Test timed out in 5000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ b-advance.test.ts:13:3 11| 12| describe('sendWithRetry', () => { 13| it('advanceTimersByTime', async () => { | ^ 14| vi.useFakeTimers(); 15| const send = failingSend(); ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ Test Files 1 failed (1) Tests 1 failed | 1 passed (2) Start at 11:02:30 Duration 5.30s (tests 99%, transform 1%)The synchronous advance still fails. When it runs, the rejection has not reached the
catchyet, sosleephas not been called and there is no timer to advance. The asynchronous form flushes pending microtasks between ticks, finds the timer once it exists, and passes. - Step 3.
Print which globals the fake clock replaces, against references captured before installation.
// d-globals.test.ts import { it, vi, afterEach } from 'vitest'; afterEach(() => { vi.useRealTimers(); }); it('names every global the default fake clock replaces', () => { const real = { setTimeout, setInterval, setImmediate, queueMicrotask, nextTick: process.nextTick, Date, performance_now: performance.now, hrtime: process.hrtime.bigint, }; vi.useFakeTimers(); const replaced = { setTimeout: setTimeout !== real.setTimeout, setInterval: setInterval !== real.setInterval, setImmediate: setImmediate !== real.setImmediate, queueMicrotask: queueMicrotask !== real.queueMicrotask, 'process.nextTick': process.nextTick !== real.nextTick, Date: Date !== real.Date, 'performance.now': performance.now !== real.performance_now, 'process.hrtime.bigint': process.hrtime.bigint !== real.hrtime, }; console.log(JSON.stringify(replaced, null, 2)); });npx vitest run d-globals.test.ts --reporter=verboseRUN v5.0.0 D:/how2check/scratch/fake-timers-date-tz stdout | d-globals.test.ts > names every global the default fake clock replaces { "setTimeout": true, "setInterval": true, "setImmediate": true, "queueMicrotask": false, "process.nextTick": false, "Date": true, "performance.now": true, "process.hrtime.bigint": true } ✓ d-globals.test.ts > names every global the default fake clock replaces 3ms Test Files 1 passed (1) Tests 1 passed (1) Start at 11:03:23 Duration 216ms (import 42%, transform 41%, worker 9%, tests 8%)This explains step 2. The default set replaces the timer functions,
Date,performance.nowandprocess.hrtime.bigint, and leaves the microtask schedulers alone. A synchronous advance cannot run a promise continuation, so it cannot see a timer that one is about to create. - Step 4.
Narrow the fake set to
setTimeoutalone and assert on elapsed time.// e-partial.test.ts import { describe, it, expect, vi, afterEach } from 'vitest'; import { measuredSleep } from './retry'; afterEach(() => { vi.useRealTimers(); }); describe('measuredSleep', () => { it('default fake clock: elapsed matches the wait', async () => { vi.useFakeTimers(); const run = measuredSleep(1000); await vi.advanceTimersByTimeAsync(1000); expect(await run).toBe(1000); }); it('toFake setTimeout only: elapsed is zero', async () => { vi.useFakeTimers({ toFake: ['setTimeout'] }); const run = measuredSleep(1000); await vi.advanceTimersByTimeAsync(1000); expect(await run).toBe(1000); }); });npx vitest run e-partial.test.tsRUN v5.0.0 D:/how2check/scratch/fake-timers-date-tz ❯ e-partial.test.ts (2 tests | 1 failed) 17ms ❯ measuredSleep (2) × toFake setTimeout only: elapsed is zero 8ms ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ FAIL e-partial.test.ts > measuredSleep > toFake setTimeout only: elapsed is zero AssertionError: expected +0 to be 1000 // Object.is equality - Expected + Received - 1000 + 0 ❯ e-partial.test.ts:18:23 16| const run = measuredSleep(1000); 17| await vi.advanceTimersByTimeAsync(1000); 18| expect(await run).toBe(1000); | ^ 19| }); 20| }); ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ Test Files 1 failed (1) Tests 1 failed | 1 passed (2) Start at 11:03:33 Duration 235ms (transform 39%, import 31%, tests 23%, worker 7%)Under
vi.useFakeTimers({ toFake: ['setTimeout'] })the wait completes andDate.now()never moves, so the code reports waiting zero milliseconds. Every duration, backoff or expiry the code derives for itself is wrong here, and nothing warns you. - Step 5.
Run a timer that reschedules itself under each of the three advance calls.
// c-heartbeat.test.ts import { describe, it, expect, vi, afterEach } from 'vitest'; import { startHeartbeat } from './retry'; afterEach(() => { vi.useRealTimers(); }); describe('startHeartbeat', () => { it('advanceTimersToNextTimer runs one tick', () => { vi.useFakeTimers(); const tick = vi.fn(); startHeartbeat(tick, 1000); vi.advanceTimersToNextTimer(); expect(tick).toHaveBeenCalledTimes(1); }); it('advanceTimersByTime runs one tick per interval', () => { vi.useFakeTimers(); const tick = vi.fn(); startHeartbeat(tick, 1000); vi.advanceTimersByTime(3000); expect(tick).toHaveBeenCalledTimes(3); }); it('runAllTimers never returns', () => { vi.useFakeTimers(); const tick = vi.fn(); startHeartbeat(tick, 1000); vi.runAllTimers(); expect(tick).toHaveBeenCalled(); }); });npx vitest run c-heartbeat.test.tsRUN v5.0.0 D:/how2check/scratch/fake-timers-date-tz ❯ c-heartbeat.test.ts (3 tests | 1 failed) 51ms ❯ startHeartbeat (3) × runAllTimers never returns 43ms ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ FAIL c-heartbeat.test.ts > startHeartbeat > runAllTimers never returns Error: Aborting after running 10000 timers, assuming an infinite loop! ❯ loop retry.ts:28:5 26| const loop = (): void => { 27| tick(); 28| setTimeout(loop, ms); | ^ 29| }; 30| setTimeout(loop, ms); ❯ c-heartbeat.test.ts:27:8 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ Test Files 1 failed (1) Tests 1 failed | 2 passed (3) Start at 11:02:46 Duration 303ms (tests 48%, transform 28%, import 19%, worker 5%)advanceTimersToNextTimer()ran one tick andadvanceTimersByTime(3000)ran three, both passing.runAllTimers()drains the queue until it is empty, and a heartbeat refills it each time, so the clock stops itself after 10000 iterations. - Step 6.
Remove the restore call from one test and run the file.
// f-leak.test.ts import { it, expect, vi } from 'vitest'; import { sleep } from './retry'; it('leaves the fake clock installed', () => { vi.useFakeTimers(); expect(Date.now()).toBeTypeOf('number'); }); it('waits 20 ms of real time', async () => { await sleep(20); expect(true).toBe(true); });npx vitest run f-leak.test.tsRUN v5.0.0 D:/how2check/scratch/fake-timers-date-tz ❯ f-leak.test.ts (2 tests | 1 failed) 5021ms × waits 20 ms of real time 5017ms ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ FAIL f-leak.test.ts > waits 20 ms of real time Error: Test timed out in 5000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ f-leak.test.ts:9:1 7| }); 8| 9| it('waits 20 ms of real time', async () => { | ^ 10| await sleep(20); 11| expect(true).toBe(true); ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ Test Files 1 failed (1) Tests 1 failed | 1 passed (2) Start at 11:18:21 Duration 5.25s (tests 99%, transform 1%)The report names line 9, the test that waits, and never mentions line 4, where the clock was installed. The failure sends you to correct a test that has no defect.
- Step 7.
Split the two tests into two files and run them under each isolation setting, installer first.
// p-installer.test.ts import { it, expect, vi } from 'vitest'; it('leaves the fake clock installed', () => { vi.useFakeTimers(); expect(Date.now()).toBeTypeOf('number'); });// q-victim.test.ts import { it, expect } from 'vitest'; import { sleep } from './retry'; it('waits 20 ms of real time', async () => { console.log('Date in this file is', Date.name, '| pid', process.pid); await sleep(20); expect(true).toBe(true); });// ordered.config.ts import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { include: ['p-installer.test.ts', 'q-victim.test.ts'], environment: 'node', fileParallelism: false, isolate: false, sequence: { sequencer: class { async shard(files: unknown[]) { return files; } async sort(files: { moduleId: string }[]) { return [...files].sort((a, b) => a.moduleId.localeCompare(b.moduleId)); } } as never, }, }, });echo "=== isolate: true (default) ===" npx vitest run --config ordered.config.ts --isolate --reporter=verbose echo "=== isolate: false ===" npx vitest run --config ordered.config.ts --reporter=verbose=== isolate: true (default) === RUN v5.0.0 D:/how2check/scratch/fake-timers-date-tz ✓ p-installer.test.ts > leaves the fake clock installed 4ms stdout | q-victim.test.ts > waits 20 ms of real time Date in this file is Date | pid 10640 ✓ q-victim.test.ts > waits 20 ms of real time 40ms Test Files 2 passed (2) Tests 2 passed (2) Start at 11:18:27 Duration 502ms (import 30%, tests 29%, transform 29%, worker 12%) === isolate: false === RUN v5.0.0 D:/how2check/scratch/fake-timers-date-tz ✓ p-installer.test.ts > leaves the fake clock installed 3ms stdout | q-victim.test.ts > waits 20 ms of real time Date in this file is ClockDate | pid 39428 × q-victim.test.ts > waits 20 ms of real time 5010ms → Test timed out in 5000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ FAIL q-victim.test.ts > waits 20 ms of real time Error: Test timed out in 5000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ q-victim.test.ts:4:1 2| import { sleep } from './retry'; 3| 4| it('waits 20 ms of real time', async () => { | ^ 5| console.log('Date in this file is', Date.name, '| pid', process.pid); 6| await sleep(20); ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ Test Files 1 failed | 1 passed (2) Tests 1 failed | 1 passed (2) Start at 11:18:29 Duration 5.27s (tests 99%, import 1%)The second file reads
Date.namebefore it waits. Under default isolation it gets the realDateand passes, because each file starts in a fresh environment. Withisolate: false, turned on to save worker startup, it inheritsClockDateand times out.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| Test timed out in 5000ms and the test installed a fake clock | Something is awaiting a timer that was never advanced | Start the call, advance, then await, as in step 2 |
| The advance call returns and the promise is still pending | The timer is created inside a promise continuation that has not run | Switch to advanceTimersByTimeAsync or runAllTimersAsync |
| Aborting after running 10000 timers | A timer schedules its own successor | Use advanceTimersByTime or advanceTimersToNextTimer and assert a tick count |
| An elapsed-time assertion reads 0 | toFake replaced the timers and left Date real | Drop the custom toFake, or add Date to it |
| A test that never touched timers times out | An earlier test left the clock installed | Call vi.useRealTimers() in afterEach |
Common mistakes
What to check next
- How to mock date in jest: freezing the date is a smaller tool than the full clock, and often the only part you need.
- How to run jest tests with a specific timezone: the other half of a date failure, and the half a fake clock cannot fix.
- How to reset mocks between tests: the same leak in the same place, for spies rather than timers.
- How to check test isolation: the run that finds a leaked clock before a colleague does.
FAQ
Why do fake timers time out a test?
The code awaits a promise that settles only after a timer fires, and no advance call ran. The runner fails the test at its own timeout, 5000 ms by default. Awaiting the call before advancing the clock is the usual cause.
How do I advance time in a test?
advanceTimersByTime(ms) runs everything due inside that window. advanceTimersToNextTimer() runs one timer. runAllTimers() drains the queue and aborts on a self-rescheduling timer. Use the async variants whenever a promise sits between the call and the timer.
Are vitest fake timers the same as jest fake timers?
Both drive @sinonjs/fake-timers, so the behaviour carries over under different names: vi.useFakeTimers is jest.useFakeTimers. Every output here came from vitest 5, so confirm the async variants in your own runner.
Does the fake clock replace Date?
By default yes, along with performance.now and process.hrtime.bigint, measured in step 3. It leaves queueMicrotask and process.nextTick alone. A toFake list replaces that list only, which is how a fake setTimeout ends up beside a real Date.now.
Where should useRealTimers go?
In afterEach, not at the end of the test body, so it runs after a failing assertion too. A test that throws before its cleanup line leaves the clock installed for everything after it.
Verified
Verified by Maks Vernyvitest 5.0.0node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate12 minpublished updated Maks Verny