Fake Timers for Debounce
Test debounced and throttled UI with Vitest/Jest fake timers — advance time deterministically without real sleeps.
- testing
- fake-timers
Debounced search, autosave, and tooltip delays depend on setTimeout. Real timers make tests slow and flaky. Fake timers replace the clock so you advance milliseconds instantly.
Docs: Vitest fake timers, Jest modern timers.
Debounce under test
export function debounce<T extends (...args: unknown[]) => void>(fn: T, ms: number) {
let t: ReturnType<typeof setTimeout> | undefined;
return (...args: Parameters<T>) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), ms);
};
}
import { vi, afterEach, beforeEach, expect, test } from 'vitest';
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test('debounce only calls once after quiet period', () => {
const spy = vi.fn();
const d = debounce(spy, 300);
d('a');
d('ab');
d('abc');
expect(spy).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith('abc');
});
React Testing Library + timers
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('search debounces', async () => {
vi.useFakeTimers();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
const onSearch = vi.fn();
render(<SearchBox onSearch={onSearch} />);
await user.type(screen.getByRole('searchbox'), 'cat');
expect(onSearch).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(300);
expect(onSearch).toHaveBeenCalledWith('cat');
vi.useRealTimers();
});
Important: user-event needs advanceTimers wired when fake timers are on — otherwise typing hangs.
advanceTimersByTime vs runAllTimers
| API | Use |
|---|---|
advanceTimersByTime(ms) |
Precise debounce windows |
runOnlyPendingTimers |
Flush current queue once |
runAllTimers |
Risk infinite loops if recurring timers |
Prefer advancing by known debounce intervals.
Throttle note
Throttle fires immediately then ignores — assert both leading call and suppressed calls before window ends.
Don’t fake timers globally by default
Some libraries use timers for batching (React, animations). Scope fakes to tests that need them; always restore real timers in afterEach.
Footguns
- Forgetting to restore real timers — later tests break.
- Mixing
asyncuser-event withoutadvanceTimers. - Using real
sleepin unit tests. - Microtask ordering — sometimes need
await vi.runAllTicks()/ flush promises after timer advance.
vi.advanceTimersByTime(300);
await Promise.resolve(); // flush then microtasks if needed
Interview out-loud answer
“I test debounce with fake timers: call the function repeatedly, advance time by the delay, assert a single invocation with the last args. I wire user-event to advanceTimers and always restore real timers after the test.”
Recurring timers
Intervals (setInterval) under fake timers need vi.advanceTimersByTime in multiples of the interval. Prefer clearing intervals in afterEach. For React 18 concurrent features, prefer advanceTimersByTimeAsync and await microtasks when assertions flake.
Extra practice
Write a minimal demo in a scratch file or the playground: one happy path, one failure path, and one boundary input. If you cannot exhibit a bug that the pattern prevents, you do not own the concept yet — re-read the primary docs linked below and tighten the example until the failure is obvious.
Notes from real codebases
Teams that succeed here keep the rules mechanical: lint where possible, CI for the rest, and a short human checklist for what automation cannot see. Document exceptions with an owner name and a removal date so “temporary” escapes do not become permanent architecture.
Implementation notes
Wire this into real code on the next feature, not only a demo. Prefer the smallest change that encodes the rule — a shared helper, a lint rule, or a checklist item in the PR template. Revisit after a week of production traffic: if users or tests still hit the failure mode, the documentation (and the abstraction) are not sharp enough yet.
Related on this site
- Unit testing pure logic
- user-event vs fireEvent
- Flaky tests common causes
- Testing Library
- Integration testing UI
Further reading
Related guides
- Accessibility Testing in CIWire axe and lint rules into CI without false confidence — what automation catches, what it misses, and a practical pipeline.
- Component Testing StorybookUse Storybook as a component workshop and optional test runner — stories as living specs, interaction tests, and a11y checks.
- Contract Testing APIsKeep frontend and backend agreements honest — schema contracts, Pact-style consumer tests, and OpenAPI-driven checks without brittle E2E.
- Coverage Metrics PitfallsWhy 100% line coverage can still ship bugs — gaming metrics, useless tests, and what coverage is actually good for.
- End-to-End Testing TradeoffsWhen E2E tests earn their keep, why they flake, and how to keep a small critical-path suite instead of a slow second frontend.