frontend / testing / 06_async_ui_testing.md

Testing Async UI — waitFor, findBy*, Races

6 min read source

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:

  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.

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 }) 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.

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

What a senior is expected to say

  • “Use findBy* for async element appearance, waitFor for non-element assertions, waitForElementToBeRemoved for disappearance. Never expect immediately after an action that triggers async work.”
  • “Fake timers + advanceTimers: vi.advanceTimersByTime on userEvent.setup for debounce/throttle code. advanceTimersByTimeAsync flushes 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.”
  • act warnings predict real bugs — usually fixed by await-ing the right thing rather than wrapping in act manually.”

Cross-references

Further reading