Async behaviour and fake timers in component tests
This section sits under component testing fundamentals and covers the one class of failure that survives every other isolation measure: time. A component whose render depends on a debounce, a poll, an animation frame, or an unresolved promise can be perfectly isolated from the network and the store and still fail intermittently, because the assertion and the update are racing.
Prerequisites
Why timing failures look like flakiness
A component test asserts on the DOM. The DOM is updated by the framework, and the framework decides when to apply an update — React batches state changes and flushes them in a microtask; Vue schedules on the next tick; a debounced handler defers by a timer that has not fired yet. The assertion, meanwhile, runs synchronously on the line after the interaction.
Whether the test passes therefore depends on whether the update happened to land before the assertion read the DOM. Locally, on a warm machine with a fast event loop, it usually does. In CI, on a shared runner processing microtasks at a different cadence, it often does not. Nothing about the component changed — only the timing did, which is why these failures are so consistently misdiagnosed as “flaky infrastructure”.
There are exactly two correct responses. Either wait for the state you expect, using a query that re-polls until it appears, or control the clock, so the timer that defers the update fires under your instruction rather than in real time. A fixed setTimeout in the test is neither: it is a guess about how long the machine will take, and it is wrong on the machine you did not test on.
Step-by-step implementation
Step 1 — Replace synchronous queries with awaited ones
Any assertion about state that appears after an interaction must use a query that waits. Testing Library’s findBy* family returns a promise that re-polls the DOM until a match appears or the timeout expires.
// SearchBox.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchBox } from './SearchBox';
it('shows results after typing', async () => {
const user = userEvent.setup();
render(<SearchBox />);
await user.type(screen.getByRole('searchbox'), 'button');
// findByRole re-polls until the list exists — it does not guess a duration
expect(await screen.findByRole('list', { name: /results/i })).toBeInTheDocument();
});
Verify it works: temporarily change the expected name to something the component never renders. The failure message should read Unable to find role="list" after the full timeout, not immediately — that confirms the query really is polling.
Step 2 — Install fake timers for anything that defers
A debounce, a poll, or a retry backoff should not cost the suite real seconds. Fake timers let you advance the clock explicitly, which is both faster and deterministic.
import { vi } from 'vitest';
beforeEach(() => {
// shouldAdvanceTime keeps microtasks flowing so awaited promises still resolve
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
Verify it works: a test that previously took 300 ms of real time for a debounce should now complete in single-digit milliseconds, and the suite total should drop by roughly the sum of every deferred duration.
Step 3 — Teach userEvent about the fake clock
userEvent inserts small delays between the events that make up a realistic interaction. With a fake clock installed those delays never elapse, and the interaction hangs. The setup call has to be told how to advance time.
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await user.type(screen.getByRole('searchbox'), 'but');
await vi.advanceTimersByTimeAsync(300); // fire the debounce deliberately
expect(await screen.findByRole('list')).toBeInTheDocument();
Verify it works: remove the advanceTimers option and the same test should time out inside user.type. That failure is the signature of a fake clock and a real userEvent in the same test.
Step 4 — Flush pending work before asserting an absence
Asserting that something did not happen is the one case a re-polling query cannot help with: queryBy* returns null immediately, whether the update is absent or merely late. Flush first, then assert.
await user.click(screen.getByRole('button', { name: /save/i }));
await vi.runOnlyPendingTimersAsync(); // let every scheduled callback run
await Promise.resolve(); // and drain the microtask queue
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
Verify it works: introduce a deliberate bug that renders the alert after a 50 ms timer. Without the flush the test passes (wrongly); with it, the test fails.
Step 5 — Fail the suite on unhandled rejections
A promise that rejects after the test has finished is reported against whichever test is running at the time, which sends every investigation to the wrong file.
// vitest.config.ts
export default defineConfig({
test: {
dangerouslyIgnoreUnhandledErrors: false, // the default; never turn this on
setupFiles: ['./src/setupTests.ts'],
restoreMocks: true,
},
});
Verify it works: vitest run should report an unhandled rejection as its own failure with the originating stack, rather than folding it into an unrelated test.
Configuration reference
| Option | Type | Default | Effect |
|---|---|---|---|
timeout (findBy/waitFor) |
number (ms) | 1000 |
How long a query re-polls before failing |
interval (waitFor) |
number (ms) | 50 |
Gap between poll attempts |
shouldAdvanceTime |
boolean | false |
Lets real microtasks progress under a fake clock |
advanceTimers |
function | none | Function userEvent calls to move the fake clock |
asyncUtilTimeout |
number (ms) | 1000 |
Global default for every Testing Library async utility |
Common pitfalls
Wrapping a synchronous assertion in waitFor. waitFor(() => expect(x).toBe(1)) on a value that never changes asynchronously succeeds on the first poll and hides nothing — but it also swallows the real failure message behind a timeout, making a genuine bug much harder to read. Use waitFor only for conditions that genuinely become true later.
Raising the timeout instead of finding the wait. A findBy* that needs five seconds is nearly always waiting on something that should have been mocked. Raising the timeout converts a fast failure into a slow one and leaves the real dependency in place.
Installing fake timers globally without restoring them. A fake clock that leaks into the next file makes every subsequent timer-dependent test behave oddly, and the failure appears far from its cause. Pair useFakeTimers with useRealTimers in afterEach, every time.
Using act() manually to silence a warning. The warning means an update happened outside the code the test was awaiting. Wrapping the assertion in act removes the message without removing the race — the correct fix is almost always an awaited query.
Choosing between waiting and controlling
Both techniques make a test deterministic, and they solve different halves of the problem. Waiting adapts to however long the environment takes; controlling removes the duration entirely. Knowing which to reach for saves a lot of guesswork.
Wait when the delay is not yours. A framework flush, a resolved fetch promise, a transition the browser schedules — you do not know how long these take and you should not care. An awaited query re-polls until the state arrives, which is correct on a fast laptop and on a loaded CI runner alike.
Control when the delay is a deliberate product decision. A 300 ms debounce, a 30 s poll interval, an exponential retry backoff — these durations exist because someone chose them, and the test’s job is to prove the behaviour at the boundary, not to sit through the wait. Fake timers let you assert the state before the timer fires, advance to exactly the boundary, and assert again.
Use both when the behaviour has both. A debounced search that then fetches has a controlled delay followed by an uncontrolled one. Advance the clock past the debounce, then await the result query. Trying to control the fetch as well — by advancing until the promise resolves — couples the test to the mock’s internals and breaks the moment the mock changes.
The one combination to avoid is a real clock with a fixed sleep, because it is neither: it neither adapts nor controls, and its correctness depends entirely on hardware you have not tested on.
Cleaning up work the component started
A component that schedules work is responsible for cancelling it when it unmounts, and a test is the cheapest place to prove that. This matters beyond tidiness: a timer or subscription that outlives its component writes to state that no longer exists, which in a real application is a memory leak and in a test suite is a mysterious failure in whichever file runs next.
it('cancels its poll on unmount', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const onTick = vi.fn();
const { unmount } = render(<StatusPoller intervalMs={5000} onTick={onTick} />);
await vi.advanceTimersByTimeAsync(5000);
expect(onTick).toHaveBeenCalledTimes(1);
unmount();
await vi.advanceTimersByTimeAsync(15000); // three more intervals
expect(onTick).toHaveBeenCalledTimes(1); // still one — the poll was cleared
});
Verify it works: delete the clearInterval from the component’s cleanup function. The second assertion should fail with four calls instead of one, which is exactly the leak the test exists to catch.
The same pattern covers event listeners, AbortController signals, and subscriptions: perform the interaction, unmount, advance or emit again, and assert nothing further happened. vi.getTimerCount() is a useful blunt instrument here — asserting it is zero after unmount catches any scheduled work you forgot to name.
Integration point
Async discipline is what makes every other tier trustworthy. The isolation principles remove ambient state, but a component that updates on a timer still needs the clock controlled or the isolation buys nothing. In Storybook, the same rules apply inside a play function — interaction testing fails in CI for exactly this reason more often than for any other. And in visual regression, an animation still in flight is a timing bug that surfaces as a pixel diff rather than as a failed assertion, which is why dynamic content masking starts by freezing time.
FAQ
Why does my test pass locally and time out in CI?
Because the assertion and the update are racing, and the two machines resolve the race differently. A CI runner is usually slower and shares its CPU, so a framework flush or a network-mocked promise that resolves in two milliseconds locally may take twenty there. A synchronous query samples the DOM once and loses; an awaited findBy* re-polls and wins on both machines. Raising the timeout is not the fix — it only widens the window in which the same race can still be lost.
Do I need fake timers if I already use findBy*?
Not for correctness, but for speed. An awaited query will eventually see the state a real 300 ms debounce produces, so the test passes either way; it just spends 300 ms of real time doing so. Multiply that across a suite with dozens of debounced inputs and polls and the cost becomes minutes. Fake timers make the same test deterministic and effectively instant.
What does the act() warning actually mean?
That the component updated its state outside the code the test framework was tracking — typically because a promise resolved or a timer fired after the awaited interaction had already returned. The warning is telling you there is an unawaited update, not that you need to add an act call. Find the thing that resolves late — usually an unmocked request or an uncontrolled timer — and await it properly.
Should I use waitFor or findBy*?
Prefer findBy* whenever you are waiting for an element, because the failure message names the query and prints the DOM, which makes a real failure readable. Reach for waitFor when the condition is not “an element exists” — a mock having been called, a store having reached a value, several conditions being true at once.
Are fake timers safe to enable for the whole suite?
Enabling them globally is workable, but only with shouldAdvanceTime: true and a matching useRealTimers in afterEach. Without the first, any test that awaits a real promise chain stalls; without the second, a leaked fake clock makes unrelated later tests behave strangely. The lower-risk default is to install them per file, in the files that actually have deferred behaviour, so the blast radius of a mistake is one file rather than the run.
How do I test an exponential backoff without waiting for it?
Advance the clock to each expected boundary and assert the attempt count in between. With a base of 200 ms, assert one attempt, advance 200 ms and assert two, advance 400 ms and assert three, advance 800 ms and assert four. That proves the schedule rather than the total, which is what the backoff actually specifies — and it runs in microseconds instead of the several real seconds the sequence would otherwise take.
Why does waitFor sometimes pass when the component is broken?
Because waitFor resolves as soon as its callback stops throwing, and an assertion that was already true when the first poll ran resolves immediately. If the callback asserts something that is true before the interaction as well as after, it never had a chance to fail. Assert on the state that specifically distinguishes “after” from “before” — the new text, the removed spinner, the changed count — rather than on something incidentally true in both.
Related
- Isolation Principles — the boundaries that make a component test reproducible in the first place
- Mock Boundaries — intercepting the network so no assertion waits on a real round-trip
- Interaction Testing — the same async rules applied inside a Storybook play function
- Dynamic Content Masking — freezing time and motion before a screenshot is captured
- Component Testing Fundamentals — the parent guide this section sits under