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() and requestAnimationFrame, depending on configuration.
  • Not controlled: promise resolution (microtasks). A fetch mock 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 }).

A 300 ms debounced validator under a fake clock The test types four characters at fake times 0, 10, 20 and 30 milliseconds; each keystroke resets the debounce timer. The test advances the clock to 329 milliseconds and asserts the validator has not run. It advances one more millisecond to 330, the debounce fires, and the test asserts the validator ran exactly once with the final value. keystrokes a d a @ debounce timer restarted by each key; fires at 330 validator calls exactly 1, value "ada@" 0ms 100ms 200ms 300ms 400ms

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

  1. Enable fake timers before rendering. Components that schedule timers on mount capture whichever clock is active at that moment.
  2. Tell user-event how to advance time. userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) prevents the classic hang where typing never completes.
  3. 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.
  4. Cross the boundary with the async advance. advanceTimersByTimeAsync advances timers and flushes promises scheduled in between, so the validator’s promise settles.
  5. Wrap advances in act for React. State updates triggered by timers happen inside act, which avoids warnings and ensures effects run before assertions.
  6. 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.

Fake-timer APIs and when to use each advanceTimersByTime moves the clock and runs due timers synchronously; use it when no promises are involved. advanceTimersByTimeAsync also flushes promises between timers; use it when validators return promises. runOnlyPendingTimers runs timers already scheduled; use it in cleanup. runAllTimers runs everything including newly scheduled timers and can loop forever with intervals or retries; avoid it in form tests. setSystemTime changes Date.now; use it for rules that depend on today's date. API Does Use for advanceTimersByTime(ms) run due timers, sync debounce with sync validators advanceTimersByTimeAsync(ms) flush promises between async validators, pending UI runOnlyPendingTimers() run what is scheduled now cleanup in afterEach runAllTimers() until none remain avoid: retries and intervals loop setSystemTime(date) change Date.now() "must be in the future" rules

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.

The three clocks in a form test The fake timer clock controls setTimeout, setInterval and Date.now and is advanced explicitly by the test. user-event's internal scheduling runs on timers and must be given the fake clock's advance function. The promise microtask queue is not controlled by fake timers and is flushed by awaiting, for example through advanceTimersByTimeAsync or an async act. Fake clock setTimeout, intervals, Date.now. Advanced by the test. user-event Schedules steps on timers. Needs advanceTimers. Microtasks Promise callbacks. Flushed by awaiting.

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.


Related

← Testing Form Validation