Testing Async UI — waitFor, findBy*, Races
TL;DR
Most real UI is async — fetch resolves, state updates, the DOM changes a render later. The wrong pattern is expect immediately after a click; the right pattern is findBy* (or waitFor) which polls until the expectation passes (or times out). Plus: fake timers for debounce/throttle, waitForElementToBeRemoved for “spinner disappears” assertions, and a few specific gotchas around act warnings.
Interview Q&A
Q: Why does expect immediately after user.click sometimes fail?
A: React’s state updates are batched and the re-render happens on the next tick. Your test runs the click, then immediately expects — but the DOM hasn’t updated yet.
// FAILS — DOM not updated yet
await user.click(button);
expect(screen.getByText(/loaded/i)).toBeInTheDocument();
// PASSES — waits for the element
await user.click(button);
expect(await screen.findByText(/loaded/i)).toBeInTheDocument();
findBy* polls every ~50ms until the element appears (or 1000ms timeout). Modern user-event actually waits for the next microtask after each interaction, so synchronous assertions sometimes work — but async APIs (fetch, setTimeout) need explicit waiting.
Q: findBy* vs waitFor — when each?
A:
| What | Use for | |
|---|---|---|
findBy*("text") |
wait for an element to appear | “after this action, text X should appear” |
waitFor(() => expect(...)) |
wait for any assertion to pass | when the assertion isn’t a single element query |
// findBy — element-focused
expect(await screen.findByText(/welcome/i)).toBeInTheDocument();
// waitFor — for arbitrary assertions
await waitFor(() => {
expect(mockSubmit).toHaveBeenCalledWith(expect.objectContaining({ total: 100 }));
});
// waitForElementToBeRemoved — opposite of findBy
await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
findBy* is the most common; waitFor is for non-DOM assertions (spy calls, store state).
Q: waitFor — what does it actually do?
A: Calls the callback repeatedly until it doesn’t throw, up to a timeout (default 1000ms). Each call increments retries; if any pass, success. If timeout hits, the last error throws.
await waitFor(() => {
expect(handler).toHaveBeenCalledOnce(); // throws until the call happens
expect(handler).toHaveBeenCalledWith("x");
}, { timeout: 3000, interval: 100 });
Two important rules:
- Don’t put side effects inside
waitFor— it runs many times. The callback should be pure assertions only. - Don’t put multiple unrelated assertions — split into separate
waitFors if they wait for different things.
Q: act warnings — what causes them?
A: “An update to X inside a test was not wrapped in act(...).” Means a state update happened outside React’s batched mode in your test — usually because:
- Async work resolved between explicit user actions and your assertion.
- A timer fired (
setTimeout,setInterval) without test control. - A subscription callback fired.
Fix by await-ing the right thing:
// Triggers act warning
useEffect(() => { fetch("/api/x").then(setData); }, []);
render(<Component />);
expect(screen.getByText(/data/i)).toBeInTheDocument(); // act warning, data not there yet
// Correct
render(<Component />);
expect(await screen.findByText(/data/i)).toBeInTheDocument();
findBy* and waitFor are act-aware internally — if you use them, you usually don’t see warnings. Manual act() is rarely needed in modern code.
Q: Fake timers for debounce/throttle.
A:
import { vi, beforeEach, afterEach, it, expect } from "vitest";
import userEvent from "@testing-library/user-event";
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("debounces search input", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<Search />);
await user.type(screen.getByRole("searchbox"), "abc");
expect(mockSearch).not.toHaveBeenCalled(); // still debouncing
await vi.advanceTimersByTimeAsync(300); // pass the debounce window
expect(mockSearch).toHaveBeenCalledWith("abc");
});
Key things:
userEvent.setup({ advanceTimers: vi.advanceTimersByTime })letsuser.typeadvance timers between events — without it,user.typehangs waiting for real time to pass.vi.advanceTimersByTimeAsyncis the Promise-aware variant — flushes microtasks too.- Always
useRealTimers()inafterEach— leaking fake timers into other tests is a nightmare.
Q: Testing a request race condition.
A: Simulate the slow-then-fast pattern with MSW + delay:
import { delay, http, HttpResponse } from "msw";
it("ignores stale request results", async () => {
let count = 0;
server.use(
http.get("/api/search", async ({ request }) => {
const q = new URL(request.url).searchParams.get("q");
count++;
if (q === "ab") await delay(500); // first request slow
return HttpResponse.json([{ q, count }]);
}),
);
const user = userEvent.setup();
render(<Search />);
await user.type(screen.getByRole("searchbox"), "ab");
await delay(100);
await user.clear(screen.getByRole("searchbox"));
await user.type(screen.getByRole("searchbox"), "abc");
// Wait for the second response
expect(await screen.findByText(/abc/)).toBeInTheDocument();
expect(screen.queryByText(/"q":"ab"/)).not.toBeInTheDocument(); // stale "ab" not rendered
});
This exercises the race-condition fix in your component (AbortController / TanStack Query / generation counter — see ../11_apis_data_fetching/05_abort_and_race_conditions.md).
Q: Testing “loading then loaded” transitions.
A: Use findBy* and waitForElementToBeRemoved together:
it("shows loading then data", async () => {
render(<UserList />);
expect(screen.getByText(/loading/i)).toBeInTheDocument(); // loading shown
await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
expect(screen.getByText(/ada lovelace/i)).toBeInTheDocument();
});
If the loading is too fast (sub-50ms), the spinner may flash by before your assertion. Use MSW delay(...) to make it slow enough to observe deterministically.
Q: Polling/refetch logic — how do you test?
A: With fake timers + vi.advanceTimersByTimeAsync:
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("refetches every 30s", async () => {
let calls = 0;
server.use(http.get("/api/status", () => { calls++; return HttpResponse.json({ ok: true }); }));
render(<StatusBadge />);
await waitFor(() => expect(calls).toBe(1));
await vi.advanceTimersByTimeAsync(30_000);
await waitFor(() => expect(calls).toBe(2));
await vi.advanceTimersByTimeAsync(30_000);
await waitFor(() => expect(calls).toBe(3));
});
Q: WebSocket / SSE testing?
A: Use a stub library (mock-socket for WebSocket, custom for SSE) or roll your own minimal client mock:
class MockWebSocket {
static instances: MockWebSocket[] = [];
readyState = 1;
onmessage?: (e: MessageEvent) => void;
constructor(public url: string) { MockWebSocket.instances.push(this); }
send = vi.fn();
close = vi.fn();
emit(data: unknown) { this.onmessage?.(new MessageEvent("message", { data: JSON.stringify(data) })); }
}
beforeEach(() => { (global as any).WebSocket = MockWebSocket; MockWebSocket.instances = []; });
it("displays inbound message", async () => {
render(<Chat />);
const ws = MockWebSocket.instances[0];
ws.emit({ type: "msg", body: "hello" });
expect(await screen.findByText(/hello/i)).toBeInTheDocument();
});
For more realistic WS testing, Playwright with a real WS server is often easier than mocking in unit-style tests.
Gotchas / edge cases
- Mixing
awaitandthenin tests — pick one style; mixing causes race conditions in the test itself. waitForwith a 5-second timeout that always passes after 5s — your assertion is too lenient; the test still passes for a real bug. Keep timeouts tight (default 1s) and let CI flake instead of accepting silently-broken tests.screen.findByTextreturns the first match — for multiple, usefindAllByText.expect(...).rejects.toThrow()for promises that reject — synchronous matchers throw “received a promise.”- Fake timers without
advanceTimersinuserEvent.setupmakesuser.typehang — always pair. - Microtasks after fake-timer advance —
vi.advanceTimersByTimeruns synchronously, but Promise callbacks scheduled don’t flush. Usevi.advanceTimersByTimeAsyncfor Promise-based code. actwarnings in dev but tests still pass — fix them; they predict real bugs.
What a senior is expected to say
- “Use
findBy*for async element appearance,waitForfor non-element assertions,waitForElementToBeRemovedfor disappearance. Neverexpectimmediately after an action that triggers async work.” - “Fake timers +
advanceTimers: vi.advanceTimersByTimeonuserEvent.setupfor debounce/throttle code.advanceTimersByTimeAsyncflushes microtasks.” - “MSW +
delay('infinite')for loading-state tests; MSW + per-test handler for response variations.” - “Race condition tests: slow first response, fast second, assert only the second renders. Exercises the AbortController / TanStack Query path.”
- “
actwarnings predict real bugs — usually fixed byawait-ing the right thing rather than wrapping inactmanually.”
Cross-references
- AbortController + race conditions (the code being tested): ../11_apis_data_fetching/05_abort_and_race_conditions.md
- MSW network mocking: 05_msw_network_mocking.md
- user-event: 04_user_event_vs_fireevent.md
Further reading
- Testing Library — Async utilities: https://testing-library.com/docs/dom-testing-library/api-async
- React docs — Testing Recipes: https://legacy.reactjs.org/docs/testing-recipes.html (still mostly relevant)
- Common testing mistakes (Kent C. Dodds): https://kentcdodds.com/blog/common-mistakes-with-react-testing-library