The failure shape is always the same: code schedules work for later, the test asserts now, and whether “now” beats “later” depends on machine load. On a fast laptop the timer wins and the test is green; on a contended CI runner the assertion wins and the test is red. Real-clock waits paper over this with longer sleeps, which only make the suite slower without making it deterministic.
Prerequisites #
| Component | Version / setting | Notes |
|---|---|---|
| Jest | 29+ |
Modern fake timers via @sinonjs/fake-timers |
| Cypress | 13+ |
cy.clock() / cy.tick() built in |
| Playwright | 1.40+ |
page.clock API for install/fast-forward |
| Browser | Chromium/WebKit/Firefox | CSS animation disable via stylesheet injection |
| App code | uses standard setTimeout/rAF |
Custom schedulers may need explicit mocking |
Fake timers replace the global setTimeout, setInterval, Date, and (optionally) requestAnimationFrame with controllable stand-ins, so test code can advance time by an exact amount instead of waiting for it.
Step-by-step implementation #
1. Fake the clock before the code under test runs #
Install fake timers before the component mounts or the page triggers any scheduled work, or the real timers will already be queued.
// Jest: install before the action that schedules work
beforeEach(() => {
jest.useFakeTimers(); // must precede any setTimeout the SUT registers
});
afterEach(() => {
jest.useRealTimers(); // trade-off: forgetting this leaks fake time into later tests
});
In Cypress, install the clock right after visiting and before the interaction.
// Cypress: freeze time, then drive it manually
cy.clock(); // freezes Date and timers from this point
cy.visit('/search'); // app's debounce now uses the controlled clock
Trade-off: Freezing the clock means nothing time-based advances on its own — including animations and library polling — so you must advance it explicitly or those code paths stall.
2. Advance time by an exact, known amount #
Replace “wait and hope” with a precise tick equal to the scheduled delay.
// Jest: a 300ms debounce becomes deterministic
fireEvent.change(input, { target: { value: 'abc' } });
jest.advanceTimersByTime(300); // exactly clears the debounce — no extra slack needed
expect(onSearch).toHaveBeenCalledWith('abc');
// Cypress: tick the frozen clock forward past the debounce
cy.get('[data-cy=search]').type('abc');
cy.tick(300); // fires the pending setTimeout
cy.get('[data-cy=results]').should('be.visible');
Trade-off: Advancing by an exact delay is precise but brittle if the app changes its timeout constant — import the constant or tick slightly past it rather than hard-coding a magic number twice. Deep coverage of debounce and polling control lives in Faking Timers With Jest and cy.clock().
3. Disable CSS animations and transitions #
Animations introduce a settle delay that interaction stability checks must wait out. The cleanest fix is to disable them globally during tests.
// Playwright: inject a stylesheet that kills transitions/animations
await page.addStyleTag({
content: `*, *::before, *::after {
animation-duration: 0s !important;
transition-duration: 0s !important; /* elements settle instantly = no actionability wait */
}`,
});
// Cypress: same disable via a support-file stylesheet
// cypress/support/e2e.ts
beforeEach(() => {
cy.document().then((doc) => {
const style = doc.createElement('style');
style.innerHTML = '* { transition: none !important; animation: none !important; }';
doc.head.appendChild(style);
});
});
Trade-off: Disabling animations speeds tests and removes a flake source, but you lose coverage of the animated states themselves — keep one dedicated visual test with animations enabled if those states matter.
4. Control requestAnimationFrame loops #
rAF-driven code (charts, canvas, scroll handlers) never settles under a frozen clock unless you advance frames.
// Jest: advance rAF-driven work frame by frame
jest.useFakeTimers();
render(<AnimatedChart />);
jest.advanceTimersToNextTimer(); // steps one scheduled frame; loop is now deterministic
expect(screen.getByTestId('chart')).toHaveAttribute('data-frame', '1');
Trade-off: Stepping frames one at a time is deterministic but verbose for long animations — for those, disable the animation (step 3) and assert the final state instead. These rendering delays overlap heavily with DOM Mutation & Rendering Races, where the same settle-versus-assert race appears.
5. Decide per spec whether motion should be on at all #
Most specs verify behaviour that has nothing to do with movement, and for those the correct setting is no motion: transitions and keyframe animations are removed, elements occupy their final position on the first frame, and a click cannot land between frames. A minority of specs verify the movement itself — a drawer opening, a toast dismissing itself, a stepper advancing — and those need the opposite treatment, waiting for the transition to complete rather than removing it.
Splitting by purpose keeps both honest. A functional project running with motion disabled is fast and deterministic; a small tagged project running with motion enabled preserves the coverage that would otherwise be lost, and its inherent timing sensitivity is bounded to a handful of specs. The alternative — disabling motion everywhere — means an animation that never completes or ends in the wrong position ships unnoticed.
Where the application honours a reduced-motion preference, requesting that preference is the cleanest way to disable motion, because the application then takes its own no-motion code path rather than being overridden from outside. It has a useful side effect: the reduced-motion branch, usually the least tested part of a design system, gets continuous coverage.
// Two projects, two purposes.
// Trade-off: a second project costs CI minutes; scope it by tag so the cost is
// proportional to the number of specs that genuinely need motion.
projects: [
{ name: 'functional', use: { reducedMotion: 'reduce' } },
{ name: 'motion', use: { reducedMotion: 'no-preference' }, grep: /@motion/ },
],
6. Prove the disable actually applied #
A silent regression in the disabling setup produces a slow drip of mysterious click failures weeks later, and by then nobody connects the two. One assertion closes that gap: navigate, read the computed transition duration of an animated element, and require it to be zero. It costs a second per run and fails immediately when an init script is dropped or a stylesheet override stops winning the cascade.
The same argument applies to the clock. A test that installs fake timers and never asserts that time is actually frozen can silently run against real time after a configuration change, at which point every timing-sensitive assertion in that spec becomes a race. Asserting that the clock does not advance across a synchronous block is a cheap guard against a whole class of confusing failures.
What Motion Does to Screenshots and Positions #
Animation interacts with two other testing techniques in ways that are worth stating explicitly, because the resulting failures are usually attributed to the wrong cause.
Visual comparison is the obvious one. A screenshot taken while an element is mid-transition captures an arbitrary frame, so the comparison fails against any baseline — and the failure rate depends on machine speed, which makes it look like an environmental problem. With motion disabled the screenshot captures the settled state, which is both stable and the state anyone reviewing the baseline expects. The corollary is that baselines must be regenerated in the same configuration the suite runs in; a baseline captured with motion on will never match a run with motion off.
Position-sensitive interaction is the subtler one. Runners wait for an element’s bounding box to stop changing before dispatching a click, but that heuristic settles for a short window, which a slow transition or a spring animation that overshoots can satisfy while still moving. The click is then computed against a position the element has already left, and the event goes to whatever is underneath — a backdrop, the element behind, or nothing. This is why “the click did nothing” failures cluster on pages with modals, drawers and toasts.
Both problems have the same root and the same resolution: remove the motion for functional runs, and where the motion is the subject, wait for its end state rather than for a duration. The end state is observable — a computed property reaching its final value, a state attribute, the document’s animations settling — and none of those are coupled to a design token that a designer may change next month.
Configuration reference #
| Option | Accepted values | Default | Effect on flakiness |
|---|---|---|---|
jest.useFakeTimers() |
call / { legacy } |
real timers | Eliminates wall-clock dependence; must pair with cleanup |
advanceTimersByTime(ms) |
integer ms | n/a | Deterministically fires due timers |
cy.clock() arg |
epoch ms / Date |
now | Pins Date.now() for reproducible time |
cy.tick(ms) |
integer ms | n/a | Advances frozen clock; flushes pending timers |
page.clock.install() |
options object | off | Playwright clock control for the page |
| transition-duration override | 0s |
app CSS | Removes animation settle waits |
Interpreting the data #
A timer flake shows a telltale pattern in your history: the test fails only on the slowest CI runs and passes locally and on retry. If your historical flakiness tracking analytics shows a test whose failures correlate with high runner load or long suite duration, suspect a real-clock race before suspecting the network.
Measure the fix by the spread of test duration, not just pass rate. After faking timers, a debounce test should run in single-digit milliseconds with near-zero variance; if duration still varies by hundreds of milliseconds, a real timer is still leaking through. Escalate to quarantine only if the test still flakes after the clock is provably frozen — otherwise fix the timer, because masking it with retries hides the determinism bug. Async sequencing that interacts with timers is covered in Async State Management in E2E Tests.
Timers That Outlive the Test #
A fake clock installed in one test and never uninstalled makes every later test’s setTimeout never fire, which surfaces as a mysterious timeout in an unrelated file. It is the timer version of a leaked spy, and it has the same misleading signature: the failure appears far from its cause and moves when the order changes.
Three variants recur. An uninstalled fake clock leaves subsequent tests running against frozen time. An interval created by a component that was never unmounted keeps firing for the rest of the worker’s life, occasionally issuing requests that land during someone else’s test. A pending debounce or autosave timer fires after teardown, touching a DOM that no longer exists and producing an error attributed to whichever test happens to be running.
The remedies are lifecycle hygiene rather than cleverness: restore real timers in a shared teardown, clear timers the test created, and unmount components so their own cleanup runs. Where a component creates timers and does not clear them on unmount, that is a product bug worth fixing rather than a test problem to work around — in a long-lived browser session the same leak accumulates for real users.
// Shared teardown so no spec can forget.
// Trade-off: restoring real timers means any interval the component left running
// now runs for real — which is exactly the leak you want surfaced.
afterEach(() => {
vi.useRealTimers();
vi.clearAllTimers();
});
The diagnostic, as elsewhere in this catalogue, is the solo re-run: a test that passes alone and fails in the suite is being affected by something earlier, and a frozen clock is among the most common candidates when the failure is a timeout with no other explanation.
When Faking Time Is the Wrong Tool #
Fake timers are powerful enough that they get applied where they do not belong, and the resulting failures are hard to reason about because the test is lying about a fundamental property of the runtime.
Faking time is right when the behaviour under test is the timing: a debounce interval, an autosave cadence, a toast that dismisses itself, a token that expires. In those cases the alternative is waiting in real time, which is slow and timing-dependent, and the fake clock makes the assertion both fast and exact.
It is wrong when the timing is incidental. A test that fakes timers to skip a loading state is really working around a missing readiness signal, and it will break the moment the application’s asynchronous flow changes. A test that fakes timers across a real network interaction is fighting two clocks at once, since the browser’s network stack does not respect the fake one — a common cause of tests that hang forever after the clock is installed.
There is a third case that surprises people: fake timers and user-event simulation can interact badly, because typing helpers often use timers internally to model realistic input. Installing a fake clock and then typing can leave the input events queued behind a clock that is never advanced. The usual fix is to configure the typing helper to use the fake clock explicitly, or to advance the clock after each interaction rather than only at the end.
The rule of thumb that avoids most of this: freeze time as narrowly as possible, around the behaviour that depends on it, and prefer an observable state signal wherever the goal is merely to wait for something to finish.
Common pitfalls & mitigations #
- Installing fake timers after the timer is scheduled. The original real timer is already queued and ignores your fake clock. Mitigation: call
useFakeTimers()/cy.clock()before the triggering action. - Forgetting to restore real timers. Fake time leaks into later tests and stalls async libraries. Mitigation:
jest.useRealTimers()inafterEach. - Freezing the clock then awaiting a real promise that depends on a timer. It hangs forever. Mitigation: advance the clock to flush the timer, then await.
- Hard-coding the debounce constant in both app and test. Drift silently re-introduces the race. Mitigation: import the shared constant or tick just past it.
- Disabling animations globally but never testing animated states. Coverage gap. Mitigation: keep one opt-in test with animations on.
Frequently Asked Questions #
Q: Should I use fake timers or just increase my wait timeout? A: Fake timers. A longer timeout makes the suite slower without making it deterministic — the race window still exists, you have just made it less likely. Faking the clock removes the race entirely.
Q: Why does my test hang after calling jest.useFakeTimers()?
A: You are almost certainly awaiting a promise whose resolution depends on a timer the fake clock has not advanced. Call jest.advanceTimersByTime() or jest.runAllTimers() to flush the pending timer, then await.
Q: Do I need fake timers if I just disable CSS animations?
A: They solve different problems. Disabling animations removes the settle delay for visual transitions; fake timers control JavaScript-scheduled work like debounce, polling, and setTimeout. Most flaky suites need both.
Can I assert that an animation has a particular duration? Yes, and it belongs in a component or visual test rather than in a flow test. Reading the computed transition duration is a fast, stable check that a design token has not changed, and keeping it in one place avoids making every end-to-end spec depend on a value designers are free to adjust.
Does disabling animation invalidate accessibility testing? No — if anything it aligns the run with a real user setting, since disabling motion is what the reduced-motion preference does. What it does remove is the chance to verify that motion is present when the preference is not set, which is why keeping one motion-enabled project is worth the small cost.