Topic 17.5
Testing Async UI and Mocking
In one line
Async UI is tested by waiting for what the user will finally see with findBy and waitFor; the network is replaced with fakes (injected functions, vi.fn/vi.mock, or MSW), timers with fake timers, and an act(...) warning means an update happened that your test didn't wait for.
Think of it like this
Ordering food at a restaurant. You don't stare at the kitchen door and declare "no food" after one second, and you don't test the restaurant by flying in real farmers. You wait for the plate to arrive (with a sensible limit), and in a cooking-school exam the "customers" are classmates following a script. In tests, findBy and waitFor are the patient waiting; mocks are the scripted customers that answer the way you planned, every time.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Asynchronous (async)
- Happening later, not straight away: a server reply, a timer, a promise.
- findBy / waitFor
- Query helpers that keep retrying until the element appears or the check passes, up to a timeout.
- Mock
- A stand-in for a real thing (a server, a function, a clock) that behaves the way the test planned.
- Dependency injection
- Passing a component the things it uses (like a loader function) so a test can pass fakes.
- vi.fn() / vi.spyOn() / vi.mock()
- Vitest tools that create a fake function, wrap an existing method, or replace a whole module.
- MSW (Mock Service Worker)
- A library that intercepts network requests and answers them with handlers you write.
- Handler
- In MSW, a rule: for this method and URL, send back this response.
- Fake timers
- A pretend clock you move forward by hand, so timer code runs instantly in tests.
- act()
- React's test helper that applies all pending renders and effects before you check the screen.
Step by step
01Check the first state with getBy, the later state with findBy
Right after render, the loading text is there now: getByText. The data comes later: await findByRole. Then make sure the loading text is gone: queryByText returns null.
render(<UserCard userId={7} />);
expect(screen.getByText("Loading user...")).toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "Asha Rao" })).toBeInTheDocument();
expect(screen.queryByText("Loading user...")).not.toBeInTheDocument();02Replace the network: from simplest to most realistic
Pick the lightest mock that still tests what matters. Injected fakes are simplest and need no tooling; vi.mock needs none of your component's cooperation; MSW is the most realistic and the most reusable (the same handlers can power the dev server and Storybook).
03Set up MSW once, override per test
Default handlers return happy data. The server starts before all tests, resets after each (so one test's override doesn't leak), and closes at the end. onUnhandledRequest: "error" makes any request you forgot to mock fail loudly.
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("https://api.example.com/users/:id", ({ params }) =>
HttpResponse.json({ id: Number(params.id), name: "Asha Rao" }),
),
];04Test the error path too
Make the fake fail, then wait for the error message. Errors are where real apps go wrong most, because nobody clicks through them by hand.
test("shows an alert when the server fails", async () => {
server.use(
http.get("https://api.example.com/users/:id", () => new HttpResponse(null, { status: 500 })),
);
render(<UserCard userId={7} />);
expect(await screen.findByRole("alert")).toHaveTextContent(/could not load/i);
});05Control time with fake timers
A toast that hides after 3 seconds shouldn't make your test wait 3 seconds. Switch to fake timers, tell user-event about them, and move the clock inside act so React applies the resulting update.
06Read an act warning as "you stopped watching too early"
When the warning appears, find which state update it names (An update to Profile...) and ask: what async work did this test start and not wait for? Add the missing await findBy... for the final UI, or end the work.
Try it yourself
- 1
Make the fake too slow
In the first example, change the fakes' delay from
200to1500. Predict which tests fail and why (findBy's default timeout is 1000 ms). Then put it back. - 2
Test an empty answer
Add a fake that resolves
{ id, name: "" }and a test for it. What should a goodUserCardshow? Change the component to showUnknown userfor an empty name and make your test check it. - 3
Silence the warning the right way
In the act example, add
await screen.findByRole("heading")at the end of the leaky test. Predict the output: the warning disappears because the test now waits for the update.
Code & diagrams
Each fake answers after 200 ms, so findBy and waitForElementToBeRemoved really wait. The third test wraps the fake to record its arguments: what vi.fn() does in Vitest.
Preview
Console
Nothing logged yet.Expected console output
✓ shows loading, then the user's name
✓ shows an alert when loading fails
✓ asks for the right userThe second test keeps the promise's resolve function, so it can check the in-between "Saving..." state and then let the server answer. The real api.save (1 second) never runs.
Preview
Console
Nothing logged yet.Expected console output
✓ saves the note and reports it
✓ disables the button while savingThe leaky test passes, then React reports a state update that happened after it, outside act. The fixed test awaits the final heading: findBy runs inside act, so the update is expected and no warning appears.
Preview
Console
Nothing logged yet.Expected console output
✓ leaky: checks the loading text and stops
An update to Profile inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):
act(() => {
/* fire events that update state */
});
/* assert on the output */
This ensures that you're testing the behavior the user would see in the browser. Learn more at https://react.dev/link/wrap-tests-with-act
✓ fixed: waits for what the user finally seesimport { render, screen } from "@testing-library/react";
import { vi, afterEach } from "vitest";
import * as api from "./api";
import UserCard from "./UserCard";
afterEach(() => vi.restoreAllMocks());
// 1. Replace global fetch for one test
test("loads with fetch", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ id: 7, name: "Asha Rao" }), { headers: { "Content-Type": "application/json" } }),
);
render(<UserCard userId={7} />);
expect(await screen.findByRole("heading", { name: "Asha Rao" })).toBeInTheDocument();
expect(fetch).toHaveBeenCalledWith("https://api.example.com/users/7");
});
// 2. Replace one function of your own api module
test("shows the error from the api", async () => {
vi.spyOn(api, "fetchUser").mockRejectedValue(new Error("Server is down"));
render(<UserCard userId={7} />);
expect(await screen.findByRole("alert")).toHaveTextContent("Server is down");
});
// 3. Or mock the whole module at the top of the file:
// vi.mock("./api"); then vi.mocked(api.fetchUser).mockResolvedValue({ id: 7, name: "Asha Rao" });// src/mocks/server.js
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
// src/setupTests.js (add to the file from Topic 17.2)
import { server } from "./mocks/server";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers()); // drop per-test overrides
afterAll(() => server.close());
// src/UserCard.test.jsx: override just for this test
import { http, HttpResponse } from "msw";
import { server } from "./mocks/server";
test("shows an alert when the server fails", async () => {
server.use(http.get("https://api.example.com/users/:id", () => new HttpResponse(null, { status: 500 })));
render(<UserCard userId={7} />);
expect(await screen.findByRole("alert")).toBeInTheDocument();
});import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, beforeEach, afterEach } from "vitest";
beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true }));
afterEach(() => vi.useRealTimers());
test("the Saved toast hides after 3 seconds", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<SaveButton />);
await user.click(screen.getByRole("button", { name: "Save" }));
expect(screen.getByRole("status")).toHaveTextContent("Saved");
act(() => vi.advanceTimersByTime(3000)); // jump the clock, flush the update
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Assert async data with getBy
In the first example, replace await screen.findByRole("heading", { name: "Asha Rao" }) with screen.getByRole("heading", { name: "Asha Rao" }).
Break #2
Leave async work running when the test ends
Write a test that renders a component which fetches in an effect and only checks the loading text.
Break #3
Pass a new fake function on every render
Render <UserCard userId={7} loadUser={(id) => fakeOk(id)} /> from a parent component that re-renders, while loadUser is in the effect's dependencies.
Myth vs fact
Myth
Wrap everything in act() to make warnings go away.
Fact
RTL already wraps render, events and waits in act. Extra manual act usually hides a test that stops watching too early. Wait for the final UI instead; use act by hand only for things like advancing fake timers.
Myth
Mocking fetch is the same as using MSW.
Fact
A fetch mock replaces your request code, so URL building, headers and parsing aren't tested and a switch to another client breaks every test. MSW leaves your code untouched and only fakes the server's answer.
Myth
Use waitFor with a long timeout to make flaky tests pass.
Fact
Flakiness usually comes from real timers, real network or leaked state between tests. Mock the time and network so results come at known moments; long timeouts only make failures slower.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Don't put side effects inside
waitFor's callback (waitFor(() => user.click(...))): it runs many times. Put only assertions in it, one perwaitFor, so the error points at the right one. And preferfindByoverwaitFor(() => getBy...): it is shorter and gives the same error. - ▸
RTL's
waitForadvances Jest's fake timers automatically when it detectsjest, but it doesn't know about Vitest'sviclock, so with Vitest fake timers afindBycan hang until timeout.vi.useFakeTimers({ shouldAdvanceTime: true })lets the fake clock also follow real time, which keepsfindBy/waitForworking; or advance the clock manually withact(() => vi.advanceTimersByTime(ms)). - ▸
MSW handlers are plain code you can share: the same
handlers.jscan powersetupWorkerin the browser during development and Storybook, so the dev app, the stories and the tests all agree on what the API returns. With TypeScript, type the handler responses with your API types so fakes can't drift from reality. - ▸
Data libraries need care in tests: create a new TanStack Query
QueryClientper test withretry: false(otherwise failed queries retry three times with backoff and your error test times out), and wrap the component in its provider (Topic 17.6 shows the wrapper pattern).
Remember this
- 1
Asynchronous (async) UI changes later, not right away: data arrives from a server, a timer fires, a promise resolves. A
getByright afterrendersees only the first state (usually "Loading..."). To check the later state, wait for it:await screen.findByRole("heading", { name: "Asha Rao" })retries until it appears or 1000 ms pass.await waitFor(() => expect(...))retries any assertion (for example "the callback was called") the same way.await waitForElementToBeRemoved(() => screen.queryByText("Loading..."))waits for something to go away. - 2
Tests must never call a real server: it is slow, it can be down, its data changes, and tests could change real data. So you replace the network with a mock: a stand-in that returns the answer you choose. Three common levels. 1) Inject the dependency: the component receives
loadUseras a prop (or from context), and the test passes a fake function, as in the live examples. 2) Module mocks:vi.mock("./api")replaces a whole module with fake functions, andvi.spyOn(api, "save")replaces one method while recording its calls. 3) Network-level mocks with MSW. - 3
MSW (Mock Service Worker) intercepts requests at the network layer. In tests it runs in Node (
setupServerfrommsw/node); in the browser it uses a Service Worker. You describe handlers: "forGET https://api.example.com/users/:id, reply with this JSON". Your component keeps its realfetchcode, real URL building and real JSON parsing, and only the server is fake, so the test covers more of your code and survives switching fromfetchto another library. A test can override one handler withserver.use(...)to simulate an error (status 500) andserver.resetHandlers()puts the defaults back after each test. - 4
Fake timers replace
setTimeout,setIntervalandDatewith a controllable clock:vi.useFakeTimers(), thenvi.advanceTimersByTime(3000)jumps three seconds instantly. Use them for debounced search, toasts that hide after 3 seconds, polling and countdowns, so a test doesn't really wait. When combined with user-event, pass the clock in:userEvent.setup({ advanceTimers: vi.advanceTimersByTime }), or user-event's own small delays never finish. Always restore withvi.useRealTimers()after the test. - 5
**
act()** is React's testing helper that runs a piece of code and then flushes all resulting renders and effects, so your assertions see a finished screen. RTL already wrapsrender, user-event andfindBy/waitForin it. The famous warning "An update to Profile inside a test was not wrapped in act(...)" means a state update happened **when noactwas active**, usually after your test's assertions: a request resolved or a timer fired that the test never waited for. The fix is almost never to addact()by hand: it is to wait for the final UI withfindBy/waitFor, or to stop the work (fake timers, cancelled requests). The third live example produces the real warning. - 6
Keep mocks honest. Mock at the edge (the network, the clock, the browser APIs jsdom lacks such as
matchMediaorIntersectionObserver), not your own components. Make fake data look like real API responses (TypeScript types or shared fixtures help). Reset mocks between tests (vi.restoreAllMocks(),server.resetHandlers()), and test the unhappy paths too: errors, empty lists, slow responses.
Explain it without notes
How do you test a component that loads data in an effect?
Compare injecting a fake, vi.mock, and MSW for mocking network calls.
What does the "not wrapped in act(...)" warning mean and how do you fix it properly?
How do fake timers work and what do you need to do when combining them with user-event?
Practice
Write a test (live helper style) for a Weather component that receives getWeather and shows Pune: 31 °C. Use a fake that resolves after 100 ms.
Write the MSW handler and a test override that makes GET https://api.example.com/todos return an empty array, and assert the component shows No todos yet.
Trade-offs
- ↔
MSW is the most realistic and reusable but adds a dependency and setup; injected fakes are instant and explicit but change the component's API and skip the request code.
- ↔
Fake timers make time-based tests fast and deterministic, but interact awkwardly with promises and Testing Library's waits; keep their use to the tests that really need time.
Done when you can
I use
findBy,waitForandwaitForElementToBeRemovedfor async UI.I can mock a dependency by injection,
vi.spyOn/vi.mock, and MSW.I can test loading, success, error and empty states.
I can use fake timers with user-event.
I can explain and properly fix an
act(...)warning.