A test for a debounced validator either sleeps for real — making the suite slow and still flaky under CI load — or uses fake timers and then hangs, because user-event waits on a timer that never advances, or a promise scheduled behind the debounce never resolves.
Fake timers make debounce, throttle, delayed pending indicators and retry backoff fully deterministic, once three pieces are wired together: the fake clock, the user-event instance that must know about it, and the microtask queue that fake timers do not control. This page, part of testing form validation, wires them and shows the assertions that make debounce tests meaningful.
Context and prerequisites
What fake timers do and do not control:
- Controlled:
setTimeout,setInterval,Date.now(), and (with modern implementations)performance.now()andrequestAnimationFrame, depending on configuration. - Not controlled: promise resolution (microtasks). A
fetchmock that resolves a promise still resolves on the microtask queue, which runs when the test yields. - user-event simulates typing with its own small delays between keystrokes (
delay, default 0 but still scheduled). With fake timers, it must be told how to advance time, or it waits forever.
The key configuration in Vitest (Jest is equivalent): vi.useFakeTimers({ shouldAdvanceTime: true }) or, more precisely, userEvent.setup({ advanceTimers: vi.advanceTimersByTime }).
The core pattern: fake clock, aware user-event, explicit advances
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import { UsernameField } from "./UsernameField"; // debounces validation by 300 ms
describe("debounced username validation", () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.runOnlyPendingTimers(); vi.useRealTimers(); });
it("validates once, after the user pauses, with the final value", async () => {
const validate = vi.fn().mockResolvedValue(null);
// advanceTimers lets user-event's internal delays progress on the fake clock.
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<UsernameField validate={validate} debounceMs={300} />);
await user.type(screen.getByLabelText("Username"), "ada_l");
// Just before the window closes: nothing has run for ANY intermediate value.
await act(async () => { vi.advanceTimersByTime(299); });
expect(validate).not.toHaveBeenCalled();
// Cross the boundary, then let the validator's promise settle.
await act(async () => { await vi.advanceTimersByTimeAsync(1); });
expect(validate).toHaveBeenCalledTimes(1);
expect(validate).toHaveBeenCalledWith("ada_l", expect.any(AbortSignal));
});
it("does not show 'Checking…' for fast answers (400 ms display delay)", async () => {
let resolve!: (v: null) => void;
const validate = vi.fn(() => new Promise<null>((r) => { resolve = r; }));
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<UsernameField validate={validate} debounceMs={300} pendingDelayMs={400} />);
await user.type(screen.getByLabelText("Username"), "ada");
await act(async () => { await vi.advanceTimersByTimeAsync(300); }); // debounce fires
await act(async () => { await vi.advanceTimersByTimeAsync(200); }); // 200 ms into the check
expect(screen.queryByText("Checking…")).toBeNull();
await act(async () => { resolve(null); }); // answer at 200 ms
await act(async () => { await vi.advanceTimersByTimeAsync(500); });
expect(screen.queryByText("Checking…")).toBeNull(); // never flickered
});
});
Step-by-step walkthrough
- Enable fake timers before rendering. Components that schedule timers on mount capture whichever clock is active at that moment.
- Tell user-event how to advance time.
userEvent.setup({ advanceTimers: vi.advanceTimersByTime })prevents the classic hang where typing never completes. - Advance to just before the boundary and assert nothing happened. The “299 ms” assertion is what proves the debounce exists; without it, a validator that runs per keystroke would also pass.
- Cross the boundary with the async advance.
advanceTimersByTimeAsyncadvances timers and flushes promises scheduled in between, so the validator’s promise settles. - Wrap advances in
actfor React. State updates triggered by timers happen insideact, which avoids warnings and ensures effects run before assertions. - Assert on the final value and call count. A debounced validator should be called once, with the last value, and with a cancellation signal — the contract from debouncing validation triggers in React.
Why the “just before” assertion matters most
Most debounce tests only check that validation eventually happens. That test passes for a correct debounce, for a debounce with the wrong delay, and for no debounce at all — an implementation that validates on every keystroke also “eventually” validates the final value. The assertion that carries the meaning is the negative one at delay − 1: nothing has run yet, for any intermediate value. Pair it with an exact call count after the boundary, and the test pins down the behaviour users experience: silence while typing, one check after a pause.
Testing the full timing chain, not just the debounce
A validated field usually has several timers in series: the debounce before a check starts, a display delay before “Checking…” appears, a timeout on the request, and perhaps a retry delay after a rate limit. Bugs often live in the interaction between them — a pending indicator that appears after the result, a retry that fires after the value changed, a timeout that is shorter than the debounce. With a fake clock, a single test can walk the whole chain: type, advance past the debounce, advance into the display delay, release or fail the request, advance past the retry delay, and assert the visible state at each step. Writing the chain as one test makes the intended sequence explicit, and any later change to one constant that breaks the sequence shows up as a failure in that test rather than as intermittent flicker in production.
Failure modes and edge cases
1. The test hangs on user.type
user-event schedules its steps with timers; under fake timers without advanceTimers, the first delay never elapses. Configure advanceTimers, or use shouldAdvanceTime: true so the fake clock also ticks with real time.
2. runAllTimers never returns
A retry loop or a polling interval schedules new timers forever. runAllTimers gives up after a limit and throws. Advance by specific amounts instead.
3. Promises behind timers
setTimeout(() => fetch(...).then(setState), 300): advancing 300 ms runs the callback, but the then runs on a microtask. Use the async advance, or await act(async () => {}) after advancing, before asserting.
4. Mixing real and fake timers
Libraries that capture setTimeout at import time (before useFakeTimers) keep using real timers. Enable fake timers in a setup file, or re-import modules after enabling them.
5. Testing the timing constant rather than the behaviour
Hard-coding 299 and 1 ties tests to the current delay. Import the debounce constant from the component module, or pass it as a prop in tests, so changing the delay does not break every test.
Verification checklist
Frequently Asked Questions
Should I test debounce in end-to-end tests instead?
End-to-end tests with real time can confirm the behaviour roughly, but they are slower and sensitive to machine load. Test the exact timing in component tests with fake timers, and let end-to-end tests check the user-visible outcome without asserting precise delays.
Do fake timers work with `AbortSignal.timeout`?
AbortSignal.timeout is implemented by the runtime and may not use the faked setTimeout. In tests, inject the timeout duration and create the signal with a controller aborted by setTimeout, which fake timers do control.
How do I test throttled validation?
The same way, with different assertions: the first call happens immediately, calls within the window are dropped (or trailing-called once), and a new call is allowed after the window. Advance the clock in steps smaller than the window and count calls at each step.