Async tests and MSW: mock the network, not the fetch
findBy is getBy plus waitFor — 1 s timeout, 50 ms polls — so async UI is awaited, not slept for. MSW 2.x intercepts at the network seam: http.get plus HttpResponse, server.use for per-test overrides. Fetch mocks couple tests to the client; MSW survives client refactors.
Fourteen red CI runs in one week, all in the same spec, none reproducible locally. The dashboard team ran tests on 10-core laptops where the order grid rendered 90 ms after the mocked fetch resolved; CI ran four Vitest workers on a shared 2-vCPU runner, where the same render took 1.3 seconds under saturated CPU — and waitFor gave up at its default 1,000 ms. The first “fix” doubled the timeout globally. The next month a different test started flaking for the opposite reason: it asserted the loading spinner with getByRole("status") after awaiting a click, and on fast runs the response had already landed — the spinner was gone before the assertion ran. The third incident was the expensive one: a test wrapped fireEvent.click(submit) inside a waitFor callback “to make it reliable”. waitFor retries its callback every 50 ms until it stops throwing — so the form submitted four times per test run, the deduplication bug that behavior masked shipped to production, and a customer got charged twice. Three flakes, one root: the team was fighting async with sleeps and retries instead of modeling it — await the state you want, never put side effects in a polling loop, and mock at a seam that does not move.
findBy and waitFor: polling, not sleeping
Ten minutes from now you will know exactly why the Hook’s CI job hung, why that spinner assertion was a race, and — most importantly — why retry: 2 would have hidden all three. Each problem has a one-line diagnosis; the diagnosis only works if you understand the model.
findByRole(...) is literally getByRole wrapped in waitFor: it re-runs the query every 50 ms until it succeeds or a 1,000 ms budget burns, returning a promise. That is the entire async toolkit you need for “data appears”: await screen.findByRole("row", { name: /order #1042/i }) expresses “the user eventually sees this” with no sleep, no arbitrary delay, and a precise failure (timeout with the last query error, not a generic assertion miss). Both knobs are honest configuration, not magic: per call — findByRole("row", {}, { timeout: 3000 }) — or suite-wide via configure({ asyncUtilTimeout }) when your CI runners are genuinely slower than laptops, which they are: a 2-vCPU shared runner under parallel workers routinely takes 5–10x longer per render than a dev machine, and that multiplier is exactly what turns a comfortable 90 ms wait into a 1.3 s timeout breach.
waitFor itself has three rules that prevent the Hook’s incidents. One assertion per callback — waitFor retries until the callback stops throwing, so a callback with five expectations reports the failure of whichever ran last, and the first four re-execute on every poll. No side effects inside the callback — a fireEvent or user.click inside waitFor re-fires on every 50 ms retry; that is how the Hook’s form submitted four times and masked a dedup bug. Never empty — await waitFor(() => {}) is a sleep with extra steps; it waits exactly one tick and asserts nothing, papering over an ordering you have not understood. To assert something disappears, invert: await waitForElementToBeRemoved(() => screen.queryByRole("status")) — note queryBy, the variant that returns null instead of throwing, which is the correct probe for absence.
Asserting the loading state has its own trap: after await user.click(submit), the response may already have landed — mock servers respond in microtasks. Assert the spinner synchronously after the action that starts the request with getByRole("status") (it must be there immediately if your component renders it immediately), or add an explicit delay to the handler when the intermediate state is the thing under test.
A test does: await waitFor(() => { fireEvent.click(submitButton); expect(screen.getByText(/saved/i)).toBeInTheDocument(); }). It passes. What actually happened?
MSW 2.x: mock the seam that does not move
Ask yourself: when the team migrates to react-query next quarter, how many tests break? If the answer depends on which mock strategy you chose, you chose the wrong seam.
Mocking fetch couples the test to a transport function. The day the team migrates from raw fetch to axios, or wraps data access in react-query, every vi.spyOn(global, "fetch") test breaks — not because behavior changed, but because the seam moved. MSW intercepts at the network boundary instead: handlers describe HTTP traffic — method, path, response — and the component under test runs its real data-fetching code, whatever that code is this quarter. The refactor from fetch to axios to react-query changes zero tests, which is the property that makes a 1,000-test suite survivable.
// handlers shared by tests and Storybook alike
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
export const server = setupServer(
http.get("/api/orders", () =>
HttpResponse.json([{ id: 1042, status: "shipped" }]),
),
);
// test setup file
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());Three load-bearing details. onUnhandledRequest: "error" turns every request you forgot to mock into a loud failure instead of a silent hang that later surfaces as a waitFor timeout — the single best flake-prevention switch in the stack. server.resetHandlers() in afterEach discards per-test overrides so one test’s 500 does not leak into the next — skipping it is a top-three source of order-dependent failures. And server.use(...) prepends handlers for the current test only:
it("shows the error boundary on a 500", async () => {
server.use(http.get("/api/orders", () =>
new HttpResponse(null, { status: 500 }),
));
render(<Orders />);
expect(await screen.findByRole("alert")).toHaveTextContent(/could not load/i);
});
it("treats network failure differently from a 500", async () => {
server.use(http.get("/api/orders", () => HttpResponse.error()));
render(<Orders />);
expect(await screen.findByRole("alert")).toHaveTextContent(/offline/i);
});That pair is the error-state discipline: a 500 and a network failure (HttpResponse.error()) are different user experiences — server said no versus server unreachable — and a component that collapses them into one message is a real finding, surfaced only because the seam lets you express both.
A team migrates data fetching from raw fetch to react-query. Suite A mocked fetch with vi.spyOn(global, 'fetch'); suite B used MSW handlers. What happens to each suite, and why?
Green locally, red in CI: the timing post-mortem
Re-run the Hook’s three incidents against the rules and each one resolves. The 1.3 s render breaching a 1 s budget is not a flaky test — it is a correct test with a wrong budget for the environment; raise asyncUtilTimeout deliberately for CI-class hardware, do not sprinkle sleeps. The spinner assertion racing its own removal is an ordering bug in the test: intermediate states are asserted synchronously at the moment they must exist, or made deterministic with a delayed handler. The click-inside-waitFor is the only true defect, and it hid a production bug — which is the general lesson: most “flaky async tests” are tests that encode a wrong model of time, and the fix is never retry: 2. A useful audit heuristic for an inherited suite, in order: grep for waitFor callbacks containing more than one statement; grep for any event dispatch inside waitFor; grep for setTimeout/sleep in tests; then check whether onUnhandledRequest is set to error. Those four greps found 31 latent flakes in the Hook’s 600-test suite in an afternoon.
▸Why this works
Why can the same handler file drive tests, Storybook, and local dev? Because MSW has two interception engines behind one handler API. In Node (Vitest, Jest) setupServer patches the request primitives — fetch, http, XMLHttpRequest — at the module level, so interception is in-process and adds well under a millisecond per request. In the browser, setupWorker registers a Service Worker that intercepts real network requests at the proxy layer the platform provides — actual requests, visible in DevTools, answered by your handlers. Same http.get("/api/orders", ...) definition, two execution environments. That symmetry is why investing in a good handler library pays three times: component tests, Storybook fixtures, and offline development all consume the one source of truth for “what the API does”.
- 01State the mechanics of findBy and the three waitFor rules, with the failure each rule prevents.
- 02Why does mocking at the network seam with MSW outlive fetch mocks, and which three setup details prevent whole categories of flakes?
Async testing has exactly one sound primitive: await the user-visible outcome. findBy embodies it — getBy wrapped in waitFor, polling every 50 ms inside a 1,000 ms default budget, both knobs adjustable per call or suite-wide when CI hardware is honestly slower than laptops, which a shared 2-vCPU runner under parallel workers is by 5-10x. waitFor carries three rules with incident-grade consequences: one assertion per callback, because the loop re-runs everything until nothing throws; no side effects, because a click inside the callback re-fires every poll — the Hook’s four-fold form submission that masked a dedup bug all the way to a double charge; never empty, because waitFor of an empty function is a sleep wearing a seatbelt. Disappearance is asserted with waitForElementToBeRemoved over queryBy, and loading states are asserted synchronously at the instant they must exist, because mocked responses land in microtasks. On the mocking side, the durable decision is the seam. Spying on fetch couples tests to a transport function that the next refactor renames; MSW intercepts at the network boundary — http.get plus HttpResponse describing traffic, not call sites — so the fetch-to-axios-to-react-query migration changes zero tests. The setup trio does the flake prevention: onUnhandledRequest error makes forgotten mocks fail loudly, resetHandlers in afterEach stops per-test overrides leaking into order-dependence, and server.use scopes a 500 or a network failure to the one test that needs it — two error experiences a serious component distinguishes. The same handlers run in Node through patched primitives and in the browser through a Service Worker, which is why one handler library feeds tests, Storybook, and offline dev. And when CI goes red while local stays green, read it as a model-of-time bug in the test, not weather: budget, ordering, or a side effect in a polling loop — never reach for retry. Now when you see a flaky async test in code review, your first question is which of the three mechanisms it is — not how many retries to add.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.