Skip to main content

Mocking Timers & Async in Vitest Tests

Testing code with delays, timeouts, and promises is tricky: real timers make tests slow (waiting for actual delays), and race conditions hide bugs. Vitest's fake timers let you control time, skip delays instantly, and test timeout logic deterministically (Vitest documentation, 2026). This article teaches you to master async testing and timeouts.

Why Fake Timers Are Essential

Imagine testing a debounced search function that waits 300ms before firing:

export function useDebounce(value: string, delay: number) {
const [debouncedValue, setDebouncedValue] = useState(value);

useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);

return () => clearTimeout(timer);
}, [value, delay]);

return debouncedValue;
}

Without fake timers, your test waits 300ms. With 100 tests, that's 30 seconds. With fake timers, the test runs in milliseconds—you control time.

Using vi.useFakeTimers()

Enable fake timers at the test suite level:

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from './useDebounce';

describe('useDebounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.restoreAllMocks(); // Clears all timers
});

it('debounces value changes', async () => {
const { result, rerender } = renderHook(
({ value, delay }: { value: string; delay: number }) => useDebounce(value, delay),
{ initialProps: { value: 'a', delay: 300 } }
);

// Initially, no debounced value
expect(result.current).toBe('a');

// Update the input
rerender({ value: 'ab', delay: 300 });
expect(result.current).toBe('a'); // Still waiting

// Skip 300ms of time
act(() => {
vi.advanceTimersByTime(300);
});

// Now debounced value updated
expect(result.current).toBe('ab');
});
});

Key timers API:

  • vi.useFakeTimers() — Turn on fake timers.
  • vi.advanceTimersByTime(ms) — Skip forward by N milliseconds.
  • vi.runAllTimers() — Skip to the end (all pending timers complete).
  • vi.restoreAllMocks() — Clear mocks and disable fake timers.

Testing setTimeout and setInterval

Control any code that uses setTimeout or setInterval:

// utils/timeout.ts
export async function withTimeout(promise: Promise, ms: number) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), ms)
),
]);
}

Test it with fake timers:

describe('withTimeout', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.restoreAllMocks();
});

it('rejects after timeout', async () => {
const slowPromise = new Promise(() => {}); // Never resolves

const resultPromise = withTimeout(slowPromise, 5000);

// Skip to timeout
act(() => {
vi.advanceTimersByTime(5000);
});

await expect(resultPromise).rejects.toThrow('Timeout');
});

it('resolves before timeout', async () => {
const fastPromise = Promise.resolve('done');

const resultPromise = withTimeout(fastPromise, 5000);

// No need to skip timers—promise resolves immediately
await expect(resultPromise).resolves.toBe('done');
});
});

Testing Retry Logic with Backoff

Fake timers shine when testing retry logic:

// api/retry.ts
export async function fetchWithRetry(url: string, maxRetries = 3) {
let lastError;

for (let i = 0; i < maxRetries; i++) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
} catch (err) {
lastError = err;
// Exponential backoff: 1s, 2s, 4s
const delay = Math.pow(2, i) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}

throw lastError;
}

Test the retry and backoff behavior:

describe('fetchWithRetry', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.restoreAllMocks();
});

it('retries with exponential backoff', async () => {
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);

// First two calls fail, third succeeds
mockFetch
.mockRejectedValueOnce(new Error('Network error'))
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce(new Response(JSON.stringify({ data: 'success' })));

const resultPromise = fetchWithRetry('/api/data', 3);

// Fast-forward through first retry delay (1s)
act(() => {
vi.advanceTimersByTime(1000);
});

// Fast-forward through second retry delay (2s)
act(() => {
vi.advanceTimersByTime(2000);
});

const result = await resultPromise;
expect(result.data).toBe('success');
expect(mockFetch).toHaveBeenCalledTimes(3);
});
});

Testing Polling with Intervals

Test code that uses setInterval to poll:

// hooks/usePoll.ts
export function usePoll(fn: () => Promise, interval: number) {
useEffect(() => {
const timer = setInterval(async () => {
try {
await fn();
} catch (err) {
console.error('Poll error:', err);
}
}, interval);

return () => clearInterval(timer);
}, [fn, interval]);
}

Test it by controlling the interval:

describe('usePoll', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.restoreAllMocks();
});

it('polls at regular intervals', async () => {
const mockFn = vi.fn(() => Promise.resolve());
const { unmount } = renderHook(() => usePoll(mockFn, 1000));

// First poll is immediate
await vi.runAllTimersAsync();
expect(mockFn).toHaveBeenCalledTimes(1);

// Advance 1 second
act(() => {
vi.advanceTimersByTime(1000);
});
await vi.runAllTimersAsync();
expect(mockFn).toHaveBeenCalledTimes(2);

// Advance 2 more seconds (2 more polls)
act(() => {
vi.advanceTimersByTime(2000);
});
await vi.runAllTimersAsync();
expect(mockFn).toHaveBeenCalledTimes(4);

unmount();
});
});

Mixing Real and Fake Timers

Sometimes you want real async behavior (Promises) but fake timers (setTimeout). Use vi.runAllTimersAsync():

it('handles promises and timers together', async () => {
vi.useFakeTimers();

const mockFn = vi.fn();

setTimeout(() => mockFn('delayed'), 500);
Promise.resolve().then(() => mockFn('promise'));

// Run both promise microtasks and fake timers
await vi.runAllTimersAsync();

expect(mockFn).toHaveBeenNthCalledWith(1, 'promise');
expect(mockFn).toHaveBeenNthCalledWith(2, 'delayed');

vi.restoreAllMocks();
});

Testing Race Conditions

Fake timers help expose race conditions:

// hooks/useAsync.ts
export function useAsync(fn: () => Promise, deps: any[]) {
const [data, setData] = useState(null);

useEffect(() => {
let cancelled = false;

fn().then((result) => {
if (!cancelled) {
setData(result);
}
});

return () => {
cancelled = true;
};
}, deps);

return data;
}

Test that old requests don't overwrite new ones:

describe('useAsync', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.restoreAllMocks();
});

it('cancels old requests when deps change', async () => {
const mockFn = vi.fn();

// First request takes 1000ms
// Second request takes 100ms
let callCount = 0;
const mockAsync = vi.fn(() => {
callCount++;
const n = callCount;
return new Promise(resolve =>
setTimeout(() => resolve(`result-${n}`), n === 1 ? 1000 : 100)
);
});

const { rerender } = renderHook(
({ asyncFn }: { asyncFn: () => Promise }) => useAsync(asyncFn, [asyncFn]),
{ initialProps: { asyncFn: mockAsync } }
);

// First request starts
expect(mockAsync).toHaveBeenCalledTimes(1);

// Change deps → new request starts
rerender({ asyncFn: mockAsync });

// Skip 100ms → second (faster) request completes first
act(() => {
vi.advanceTimersByTime(100);
});

// Data should be from the second request
// (the first request completes later but is cancelled)
});
});

Common Fake Timer Pitfalls

Don't forget act():

// ✓ Correct
act(() => {
vi.advanceTimersByTime(300);
});

// ❌ Wrong—state updates not batched
vi.advanceTimersByTime(300);

Always restore timers:

afterEach(() => {
vi.restoreAllMocks(); // Clears fake timers
});

Key Takeaways

  • Use vi.useFakeTimers() to skip time delays and test timeout logic instantly.
  • Use vi.advanceTimersByTime(ms) to skip forward by a specific duration.
  • Use vi.runAllTimers() or vi.runAllTimersAsync() to skip to the end.
  • Always wrap timer advances in act() so React batches state updates.
  • Fake timers expose race conditions and test retry/polling logic deterministically.
  • Always call vi.restoreAllMocks() in afterEach() to clean up.

Frequently Asked Questions

How do I test code with real delays, like waiting for a network response?

Use real async with Promise—no timers involved. Only fake timers when testing setTimeout/setInterval behavior:

it('fetches data', async () => {
// Real async, no fake timers
const data = await fetchData();
expect(data).toBeDefined();
});

What's the difference between vi.runAllTimers() and vi.runAllTimersAsync()?

vi.runAllTimers() processes only setTimeout/setInterval. vi.runAllTimersAsync() also processes Promise microtasks, which is usually what you want. Use vi.runAllTimersAsync() for most tests.

How do I test a function that debounces multiple rapid calls?

Fast-forward below the debounce delay, then check the mock wasn't called:

const debounced = debounce(mockFn, 300);

debounced('a');
debounced('b');
debounced('c');

act(() => {
vi.advanceTimersByTime(299);
});

expect(mockFn).not.toHaveBeenCalled();

act(() => {
vi.advanceTimersByTime(1); // Total 300ms
});

expect(mockFn).toHaveBeenCalledWith('c'); // Only last call

Can I use fake timers with vi.mock()?

Yes. Fake timers and module mocking are independent. Use both in the same test:

vi.mock('../api');
vi.useFakeTimers();

// Now test with mocked modules and controlled time

How do I test a component that updates multiple times with delays?

Advance time in steps, checking the DOM after each step:

it('animates from 0 to 100 in 4 steps of 250ms', () => {
vi.useFakeTimers();
render(<AnimatedCounter />);

expect(screen.getByText('0')).toBeInTheDocument();

act(() => vi.advanceTimersByTime(250));
expect(screen.getByText('25')).toBeInTheDocument();

act(() => vi.advanceTimersByTime(250));
expect(screen.getByText('50')).toBeInTheDocument();

vi.restoreAllMocks();
});

Further Reading