C.W.K.
Stream
Lesson 03 of 04 · published

Fake Timers: Making Time Sit Still

~16 min · vitest-mocking, fake-timers, time

Level 0Test Curious
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"A test that waits 5 seconds for a toast to disappear is a test you'll skip."

Why Fake Time?

Any code that uses setTimeout, setInterval, requestAnimationFrame, or reads Date.now() / new Date() is time-coupled. If your auto-dismiss toast disappears after 5,000 ms, a real test waits 5,000 ms. Multiply by 50 timer-aware tests in a suite and watch your CI go from a minute to an hour.

Fake timers replace the real timer APIs with controllable versions. setTimeout still queues callbacks, but they don't fire on their own — you advance time manually with vi.advanceTimersByTime(5000), and all callbacks scheduled within that window fire synchronously. The test that used to take 5 seconds takes 5 milliseconds.

The Setup / Teardown Pattern

Fake timers are global — once you enable them, every timer in every collaborator uses the fake clock until you turn them off. The clean pattern is per-test or per-suite:

  • beforeEach(() => vi.useFakeTimers()) — enable before each test.
  • afterEach(() => vi.useRealTimers()) — restore after each test.

Forget the cleanup and the next test gets a frozen clock it didn't ask for, leading to "why is this completely unrelated test now hanging?" debugging that eats hours.

The Three Advance Methods

  • vi.advanceTimersByTime(ms) — moves the clock forward by ms, firing every callback scheduled in that window. The most common one.
  • vi.runAllTimers() — runs every pending timer at once, even setInterval (until queue drains). Useful when you want all pending work to complete.
  • vi.runOnlyPendingTimers() — runs only the currently-queued timers, ignoring any new ones scheduled by those callbacks. Useful for breaking out of recursive setTimeout loops.
Fake timers do not advance microtasks automatically. If your timer callback awaits a promise, you need to flush microtasks too — await vi.advanceTimersByTimeAsync(5000) (the Async variant) handles both. Get this wrong and your test sees the timer fire but never sees the promise resolve.

Faking Date Too

Time-coupled code that reads Date.now() directly (or new Date()) is also a candidate for fake clock. vi.setSystemTime('2026-01-01') pins the wall clock to a specific moment; any reads return values consistent with that moment. Combined with vi.useFakeTimers, you get a fully deterministic time environment.

Code

Toast dismiss — instant test, 5-second behavior·typescript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

// Code under test
export function autoDismissToast(onDismiss: () => void, ms = 5000) {
  setTimeout(onDismiss, ms);
}

// Tests
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

describe('autoDismissToast', () => {
  it('does not dismiss before the duration elapses', () => {
    const onDismiss = vi.fn();
    autoDismissToast(onDismiss, 5000);

    vi.advanceTimersByTime(4999);
    expect(onDismiss).not.toHaveBeenCalled();
  });

  it('dismisses exactly at the duration', () => {
    const onDismiss = vi.fn();
    autoDismissToast(onDismiss, 5000);

    vi.advanceTimersByTime(5000);
    expect(onDismiss).toHaveBeenCalledTimes(1);
  });
});
Debounce — perfect use case for fake timers·typescript
// Debounce — only fire after 300ms of quiet
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

export function debounce<T extends (...a: any[]) => void>(fn: T, wait = 300) {
  let id: ReturnType<typeof setTimeout> | undefined;
  return (...args: Parameters<T>) => {
    if (id) clearTimeout(id);
    id = setTimeout(() => fn(...args), wait);
  };
}

beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

describe('debounce', () => {
  it('only fires once for a burst of calls within the wait window', () => {
    const onFire = vi.fn();
    const debounced = debounce(onFire, 300);

    debounced('a');
    vi.advanceTimersByTime(100);
    debounced('b');
    vi.advanceTimersByTime(100);
    debounced('c');
    vi.advanceTimersByTime(300);

    expect(onFire).toHaveBeenCalledTimes(1);
    expect(onFire).toHaveBeenCalledWith('c');
  });
});
Async timers — `advanceTimersByTimeAsync` flushes promises·typescript
// Async timers — flush both timers AND microtasks
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

export async function delayedFetch(url: string, ms: number) {
  await new Promise((resolve) => setTimeout(resolve, ms));
  return fetch(url);
}

beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

it('completes the fetch after the delay', async () => {
  vi.spyOn(global, 'fetch').mockResolvedValue(
    new Response('ok', { status: 200 })
  );

  const promise = delayedFetch('/api/health', 1000);
  await vi.advanceTimersByTimeAsync(1000);   // async version flushes microtasks too

  const response = await promise;
  expect(response.status).toBe(200);
});
`setSystemTime` — pin the wall clock·typescript
// setSystemTime — for code that reads Date.now() directly
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

beforeEach(() => {
  vi.useFakeTimers();
  vi.setSystemTime('2026-01-01T00:00:00Z');
});
afterEach(() => vi.useRealTimers());

it('greets the user with the current year', () => {
  function greet() {
    return `Happy ${new Date().getFullYear()}, traveler.`;
  }

  expect(greet()).toBe('Happy 2026, traveler.');

  // Advance the system clock one year.
  vi.setSystemTime('2027-06-15T00:00:00Z');
  expect(greet()).toBe('Happy 2027, traveler.');
});

External links

Exercise

Write a retryWithBackoff(fn, attempts, baseMs) that retries fn up to attempts times, doubling the delay each retry (baseMs, baseMs*2, baseMs*4, ...). Write three tests: (1) succeeds on first try — no delay needed, (2) succeeds on third try — verify the exponential delays using vi.advanceTimersByTime, (3) exhausts all retries — verify it throws. The test for (2) should complete in milliseconds even though the simulated wait is 1+2+4 = 7 seconds.
Hint
If your fn is async, use vi.advanceTimersByTimeAsync and remember to await the promise. The microtask flush is what lets the next retry actually fire.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.