Frontend / Testing / 06_async_ui_testing.md

Testing Async UI — waitFor, findBy*, Races

Updated 4 interview angles 6 min read source
On this page13
  1. Why does expect immediately after user.click sometimes fail?
  2. findBy* vs waitFor — when each?
  3. waitFor — what does it actually do?
  4. act warnings — what causes them?
  5. Fake timers for debounce/throttle.
  6. Testing a request race condition.
  7. Testing “loading then loaded” transitions.
  8. Polling/refetch logic — how do you test?
  9. WebSocket / SSE testing?
  10. Gotchas
  11. Related
  12. Further reading
  13. Interview angle

Testing Async UI — waitFor, findBy*, Races

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.

Why does expect immediately after user.click sometimes fail?

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.

tsx
// 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.

findBy* vs waitFor — when each?

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
ts
// 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).

waitFor — what does it actually do?

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.

ts
await waitFor(() => {
  // throws until the call happens
  expect(handler).toHaveBeenCalledOnce();
  expect(handler).toHaveBeenCalledWith("x");
}, { timeout: 3000, interval: 100 });

Two important rules:

  1. Don’t put side effects inside waitFor — it runs many times. The callback should be pure assertions only.
  2. Don’t put multiple unrelated assertions — split into separate waitFors if they wait for different things.

act warnings — what causes them?

“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:

ts
// Triggers act warning
useEffect(() => { fetch("/api/x").then(setData); }, []);
render(<Component />);
// act warning, data not there yet
expect(screen.getByText(/data/i)).toBeInTheDocument();

// 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.

Fake timers for debounce/throttle.

tsx
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");
  // still debouncing
  expect(mockSearch).not.toHaveBeenCalled();

  // pass the debounce window
  await vi.advanceTimersByTimeAsync(300);
  expect(mockSearch).toHaveBeenCalledWith("abc");
});

Key things:

  • userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) lets user.type advance timers between events — without it, user.type hangs waiting for real time to pass.
  • vi.advanceTimersByTimeAsync is the Promise-aware variant — flushes microtasks too.
  • Always useRealTimers() in afterEach — leaking fake timers into other tests is a nightmare.

Testing a request race condition.

Simulate the slow-then-fast pattern with MSW + delay:

ts
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++;
      // first request slow
      if (q === "ab") await delay(500);
      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 Race conditions and AbortController).

Testing “loading then loaded” transitions.

Use findBy* and waitForElementToBeRemoved together:

tsx
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.

Polling/refetch logic — how do you test?

With fake timers + vi.advanceTimersByTimeAsync:

ts
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));
});

WebSocket / SSE testing?

Use a stub library (mock-socket for WebSocket, custom for SSE) or roll your own minimal client mock:

ts
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

  • Mixing await and then in tests — pick one style; mixing causes race conditions in the test itself.
  • waitFor with 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.findByText returns the first match — for multiple, use findAllByText.
  • expect(...).rejects.toThrow() for promises that reject — synchronous matchers throw “received a promise.”
  • Fake timers without advanceTimers in userEvent.setup makes user.type hang — always pair.
  • Microtasks after fake-timer advance — vi.advanceTimersByTime runs synchronously, but Promise callbacks scheduled don’t flush. Use vi.advanceTimersByTimeAsync for Promise-based code.
  • act warnings in dev but tests still pass — fix them; they predict real bugs.

Further reading

Interview angle 4

  • “How do you assert on something that appears asynchronously?” - findBy* for an element appearing, waitFor for a non-element assertion, and waitForElementToBeRemoved for a spinner going away. What you never do is expect immediately after an action that kicked off async work - that asserts on the state before the update landed.
  • “How do you test a debounced input?” - fake timers, with advanceTimers wired into userEvent.setup() so the library can move the clock while it waits. Use the async advance variant for promise-based code, because advancing synchronously does not flush the microtask queue.
  • “How would you test a race condition?” - make the first response slow and the second fast with per-test MSW handlers, fire both, and assert only the second’s data renders. That exercises the abort or generation-guard path directly, which is otherwise almost impossible to cover.
  • “What does an act warning mean?” - that state updated outside React’s awareness, and it usually predicts a real bug rather than a test artefact. The fix is almost always await-ing the right thing - a findBy or a waitFor - rather than wrapping the call in act manually to silence it.