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

// 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

  1. 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.ts
    
     RUN  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 sleep never ran and the promise stayed pending until the runner gave up. The report points at the it, which says nothing about which timer is stuck.

  2. 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.ts
    
     RUN  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 catch yet, so sleep has 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.

  3. 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=verbose
    
     RUN  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.now and process.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.

  4. Step 4.

    Narrow the fake set to setTimeout alone 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.ts
    
     RUN  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 and Date.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.

  5. 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.ts
    
     RUN  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 and advanceTimersByTime(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.

  6. 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.ts
    
     RUN  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.

  7. 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.name before it waits. Under default isolation it gets the real Date and passes, because each file starts in a fresh environment. With isolate: false, turned on to save worker startup, it inherits ClockDate and 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

Sign: The test passes when run alone and times out when the whole file runs.Cause: An earlier test in the file installed the fake clock and never removed it. The stack trace names the test that waits, not the one that installed the clock, so the failure reads as a defect in code that is correct.
Sign: advanceTimersByTime returns, the mock was never called a second time, and the test still times out.Cause: The clock does not replace queueMicrotask or process.nextTick, measured in step 3. When the timer is scheduled from inside a .then or a catch, the synchronous advance runs before that continuation and finds an empty queue.
Sign: A duration or expiry assertion reads 0 while the code awaited a full second.Cause: A toFake list naming only setTimeout leaves Date.now real, so code that measures its own elapsed time sees no movement. Every timeout, backoff and token lifetime the code derives from the clock is then wrong in the test and right in production.
Sign: Turning off isolation to speed up the suite turns one unrelated file red.Cause: With isolate set to false the files share a worker, so a fake clock left installed by one file is still installed when the next file loads. The same leak is invisible under the default setting, which is why it appears on the day the setting changes and not on the day the leak was written.

What to check next

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.

intermediate12 minpublished updated Maks Verny