cf.completefrontend
My progress
FUNDAMENTALS · MODULE 16

Unit and integration tests

Fast tests that check what users see and do: Testing Library queries, user-event, async UI without flakiness, mocking the network with MSW, and testing hooks, forms, and timers without coupling tests to how the code is built.

The problem: tests that lie in both directions

A team owns a product search box. It has a text input, a debounced request to /api/search, a list of results, and an empty state. Over two years it collected 45 tests. Most of them render the component, read its internal state, call its handler methods directly, and check which child components it rendered.

One sprint, a developer rewrites the component from a class to hooks and moves the fetching into a shared useSearch hook. Nothing changes for users: same input, same results, same timing. 38 of the 45 tests turn red. The developer spends a day rewriting tests that were never about the product, only about the old code.

Two weeks later a real bug ships. During a design tweak, the label's for attribute stopped matching the input's id, and the clear button became a div with a click handler. Screen reader users now hear an unlabelled edit field, and keyboard users cannot reach the clear button. Every one of the 45 tests stays green.

Two ways a test can lie

That team's tests failed in both directions at once. A false alarm is a test that goes red when users would notice nothing: it punishes refactoring and teaches the team to ignore red builds. A false pass is a test that stays green while users are hurt: it gives confidence you have not earned.

Both come from the same root cause. The tests watched the implementation (state, methods, component names, CSS classes) instead of the behavior (what is on the screen, what the user can do, what goes over the network). Implementation is exactly what a refactor changes, and it is not what users touch.

Two sets of tests for the same search box, side by side. On the left, tests that watch the code: state isOpen is true, handleChange was called, ResultList was rendered, an element with class btn-clear exists. After a refactor users cannot see, they go red; after the real label and Clear button bugs, they stay green. On the right, tests that watch the screen: a combobox named Search, typing mug shows 3 options, the Clear button gets focus, a GET request to /api/search with q equal to mug. After the refactor they stay green; after the real bugs they go red, because they find the field by its label and the Clear button by its role.watches the codewatches the screenstate.isOpen is truehandleChange was calledrendered ResultList.btn-clear existscombobox named Searchtyping mug shows 3 optionsClear button gets focusGET /api/search?q=mugafter the refactor: redafter the real bugs: greenafter the refactor: greenafter the real bugs: red
The left set lies twice: red when nothing a user sees changed, green when users were hurt. The right set reacts only to what users touch.

What this lesson covers

The lesson on a frontend testing strategy covers the big picture: the pyramid, the trophy, the honeycomb, what each layer catches, and the cost of flaky tests. This lesson goes one level down, into the two layers you run hundreds of times a day. You will learn how to write them so they fail only when users would notice.

  • Unit tests check one small piece on its own: a pure function such as formatPrice, a reducer, or a custom hook. They run in about 1 to 10 ms each.
  • Integration tests render a component with its real children, real state, and real hooks, then act like a user and check the screen. Only the edges are fake: the network, the clock, and browser APIs the test environment lacks. They run in tens to hundreds of milliseconds.

The tools are Testing Library for finding elements and acting on them, user-event for realistic input, Mock Service Worker (MSW) for faking the network, renderHook for hooks, fake timers for debounce, and axe for automated accessibility checks. A test runner (Vitest or Jest) runs all of it, usually in jsdom, a JavaScript copy of the browser DOM that runs in Node.

Check your understanding · Exercise 1

Which of these tests are likely to raise false alarms on a refactor that users cannot see? Select all that apply.

The mental model: a user at the screen, a fake internet behind it

Picture the test as a careful person sitting at the screen. They can only do what a person does: read text, find a button by what it says, type into a field by its label, press Tab, wait for something to appear. They cannot open the source code, read state, or call a function by name.

Behind the screen, everything you wrote is real: components, children, hooks, context, the store, the router. Only at the far edge, where your code leaves your app, does the test swap in fakes. The fake internet answers requests with data you choose. A fake clock moves time forward when you say so.

So a test has two contact points with the code, and only two. At the top, the screen: what the user sees and does. At the bottom, the edges: what goes out over the network and what comes back. Everything in between is free to change, and a refactor that keeps both contact points the same keeps the test green.

A layered stack for an integration test. At the top, your test, which acts like a user. Below it, the screen: the DOM in jsdom, with roles, names, and text. Below that, everything you own runs for real: components, hooks and context, the store and router. At the bottom, only the edges are fake: MSW for the network, a fake clock, and small stubs for browser APIs such as IntersectionObserver and ResizeObserver.Your testthe screenDOM in jsdom: roles, names, textreal: everything you ownComponentsHooks, contextStore, routerfake: only the edgesMSW: networkFake clockBrowser stubs
The test touches the code at two places only: the screen at the top and the edges at the bottom. Everything between them can be refactored freely without breaking it.

Where unit ends and integration begins

The words are used loosely, so it helps to pin them down for frontend code. A unit test has no screen: it calls a function or a hook and checks what comes back. An integration test renders a real piece of UI, usually a whole feature such as a form or a list with filters, and drives it through the screen.

Rough numbers for jsdom on a laptop; they vary with the size of the tree and the number of awaits.
AspectUnit testIntegration test
What it runsOne function, reducer, or hookA component tree with its real children, hooks, and providers
How it actsCalls the code directlyClicks, types, and tabs through the screen
What it checksReturn values, thrown errorsText, roles, focus, requests sent
What is fakeUsually nothingThe network, the clock, missing browser APIs
Typical time1 to 10 ms20 to 300 ms
Good fitPrice formatting, date math, parsers, reducersForms, search boxes, carts, dialogs, any feature with UI

Most of the confidence in a frontend app comes from integration tests, because most bugs live in how parts fit together: a prop not passed, a hook called with the wrong key, a loading state that never clears. Unit tests earn their place for logic with many cases, where driving each case through the UI would be slow and noisy.

The anatomy of one test

Every test has the same three steps: arrange (render the UI and set up the fake network), act (do what a user would do), and assert (check what a user would see). Keep each step short and readable; a reviewer should understand the test without opening the component.

tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { server } from "./test/server";

test("adds an item to the cart", async () => {
  // Arrange: the real component, a fake API
  server.use(
    http.post("/api/cart", () => HttpResponse.json({ count: 1 }))
  );
  const user = userEvent.setup();
  render(<ProductPage id="p-42" />);

  // Act: what a shopper does
  await user.click(await screen.findByRole("button", { name: "Add to cart" }));

  // Assert: what a shopper sees
  expect(await screen.findByRole("status")).toHaveTextContent("1 item in cart");
});
Arrange, act, assert. The test never mentions state, props, hooks, or class names.

Three things in that test will come up again and again. screen is the whole document, queried the way a user reads it. userEvent.setup() creates a simulated user who types and clicks with the full sequence of browser events. server.use adds a network answer for this one test, and the next chapters take each of these in turn.

Check your understanding · Exercise 2

You are writing an integration test for a checkout form. Which parts should be fakes? Select all that apply.

Testing Library: finding elements the way people do

Testing Library is a family of small packages (@testing-library/dom at the core, with wrappers for React, Vue, Svelte, Angular, and others). Its whole design follows one guiding principle: the more your tests resemble the way your software is used, the more confidence they can give you.

In practice that means the library gives you no way to read component state or props, and it makes the user-facing ways of finding elements the easiest to type. You find a button by its role and its name, a field by its label, a message by its text. If you cannot find something that way, often a real user cannot either.

The query priority

The docs rank the queries from most to least preferred. The order follows one question: how close is this to the way a real person, including one using a screen reader, finds the element?

  1. getByRole: finds by ARIA role and accessible name, such as getByRole("button", { name: "Save" }). It reads the accessibility tree, the same data a screen reader uses. Use it for almost everything: buttons, links, headings, text boxes, checkboxes, dialogs, lists, tables.
  2. getByLabelText: finds a form field by its label. Good for fields, and it fails if the label is not connected to the input, which is exactly the bug from chapter 1.
  3. getByPlaceholderText: a fallback for fields with no label. A placeholder is not a label, so needing this query is itself a small accessibility smell.
  4. getByText: finds non-interactive content by its visible text: paragraphs, messages, list item text.
  5. getByDisplayValue: finds a filled-in field by its current value. Handy for edit forms that load existing data.
  6. getByAltText and getByTitle: images by alt text, and elements by title. The title attribute is read inconsistently by assistive technology, so prefer the others.
  7. getByTestId: finds by a data-testid attribute. Users never see it, so a test that uses it proves nothing about what users see. Keep it for elements with no role, name, or text, such as a canvas wrapper.
A bar chart of Testing Library queries in their recommended order, with a bar for how much a passing query proves about what the user gets. getByRole: the longest bar, it reads the accessibility tree. getByLabelText: long, it proves the label is connected. getByPlaceholderText: medium, a placeholder is only a hint. getByText: medium, visible text. getByDisplayValue: medium, the field's value. getByAltText and getByTitle: shorter, alt text and title. getByTestId: almost nothing, an attribute users never see.first choice at the top: bar = what a pass proves about the user's viewgetByRolea11y treegetByLabelTextlabel linkgetByPlaceholderTexthint onlygetByTextvisible textgetByDisplayValuefield valuegetByAltText, Titlealt, titlegetByTestIdinvisible
Work down the list and stop at the first query that fits. getByRole covers most elements; getByTestId is the escape hatch for the rare element with no role, label, or text.

Roles and accessible names

A role is what an element is to assistive technology: button, link, textbox, checkbox, heading, dialog. Native HTML elements have implicit roles: a button element is a button, an a with an href is a link, an input type="email" is a textbox. You rarely need an explicit role attribute.

The accessible name is what a screen reader announces for the element. It comes from, in order of precedence, aria-labelledby, aria-label, a connected label, the element's own text content, and finally title or placeholder. getByRole("button", { name: "Delete" }) checks role and name together, so it also checks that an icon-only button has a name at all.

tsx
// Each query below finds exactly one element, or throws.
screen.getByRole("heading", { level: 1, name: "Your cart" });
screen.getByRole("textbox", { name: "Email" });
screen.getByRole("checkbox", { name: /subscribe/i });
screen.getByRole("button", { name: "Remove Blue mug" });

// Scope a query to one region with within()
const row = screen.getByRole("row", { name: /blue mug/i });
within(row).getByRole("spinbutton", { name: "Quantity" });

// Stuck? Print the DOM, or every role and name on the page
screen.debug();
logRoles(document.body);
A regular expression like /subscribe/i matches part of the name, ignoring case. A string must match the whole name.

getBy, queryBy, findBy

Every query comes in three variants, plus an All form of each that returns a list. The variant decides what happens when zero or several elements match, and whether the query waits.

VariantNo matchOne matchSeveral matchesWaits?Use it to
getBy...ThrowsReturns itThrowsNoFind something that should be there now
queryBy...Returns nullReturns itThrowsNoAssert that something is absent
findBy...Rejects after the timeoutResolves with itRejectsYes, 1,000 ms by defaultFind something that appears later
getAllBy...ThrowsArray of 1ArrayNoCount rows, items, options

The throwing is a feature. When getByRole fails, the error prints the roles and names it did find, which usually shows the problem in seconds: a typo in the name, a missing label, or a div that should be a button. A query that returns undefined silently would hide all of that.

Use queryBy only for absence: expect(screen.queryByRole("alert")).not.toBeInTheDocument(). Using queryBy to find something that should exist turns a clear "could not find" error into a vague "expected null to be in the document".

Lab: choose resilient queries

The lab below renders a small sign-up form. Pick an element, pick a query, then apply refactors a designer or developer might make: renaming CSS classes, moving to design-system markup with new wrapper divs, rewording copy, deleting test ids. Watch which queries survive and which break, and notice that the ones that break on copy changes are breaking because the user-facing text really changed.

A sign-up form as the test sees it. A text field labelled Email, a checkbox labelled Send me the newsletter, a button labelled Create account, and an alert that says Enter a valid email.Sign upEmailEnter a valid emailSend me the newsletterCreate account
The form under test. Apply refactors below and watch the labels change only when the copy changes.

1. Pick a query for each element.

2. Refactor the form. Switch changes on and off.

Pick at least one query to see how it holds up.

Check your understanding · Exercise 3

A form has label for="email" with text "Work email" and an input with id="email" and class field-input. Which queries are the best choices? Select all that apply.

Acting like a user: user-event and forms

Finding an element is half of a test. The other half is acting on it. Testing Library ships fireEvent, which dispatches one DOM event. The separate @testing-library/user-event package simulates a whole interaction, with every event a real browser would fire, in the right order, with the right checks.

The difference matters because components listen to more than one event. A dropdown might open on pointerdown, a field might validate on blur, an autocomplete might handle keydown for arrow keys and input for text. fireEvent.change skips all of that and sets the value directly, so a test can pass while the real keyboard path is broken.

A sequence diagram of one user.type call that types the letter a into an empty field. The test calls user.type on user-event. user-event sends pointerdown to the input, then focus, then pointerup and click, then keydown a, then beforeinput and input. Mouse, hover, and keypress events are left out for space. The input calls the component's onChange with a. Then user-event sends keyup. For comparison, fireEvent.change sends a single change event, with no pointer, focus, or key events.testuser-eventinputcomponentuser.type(el, a)pointerdownfocuspointerup, clickkeydown abeforeinput, inputonChange(a)keyupfireEvent.change: one change event, no focus, no keys
One call to user.type fires more than a dozen events in the order a browser would (the diagram shows the main ones). Handlers on focus, keydown, or pointerdown run, so the test exercises the same paths a person does.

The v14 setup pattern

In user-event version 14 every interaction is async and returns a promise, so you await each one. The recommended pattern is to call userEvent.setup() at the start of each test, before rendering, and use the returned user object. Setup creates one simulated user who keeps state between calls: which keys are held, where the pointer is, what is selected.

tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

function renderWithUser(ui: React.ReactElement) {
  return { user: userEvent.setup(), ...render(ui) };
}

test("keyboard users can pick a plan", async () => {
  const { user } = renderWithUser(<PlanPicker />);

  await user.tab(); // focus moves to the first radio
  await user.keyboard("{ArrowDown}"); // and on to the second
  expect(screen.getByRole("radio", { name: "Pro" })).toBeChecked();

  await user.click(screen.getByRole("button", { name: "Continue" }));
  expect(screen.getByRole("heading", { name: "Payment" })).toBeInTheDocument();
});
A small helper keeps setup and render together. The keyboard part of this test fails if focus order or arrow-key handling breaks.
  • user.click(el) and user.dblClick(el): pointer events, mouse events, focus, then click. They refuse to click an element with pointer-events: none, as a browser would.
  • user.type(el, "text"): clicks the field, then sends each character as keydown, keypress, beforeinput, input, keyup. Special keys go in braces: {Enter}, {Backspace}.
  • user.keyboard("{Shift>}A{/Shift}"): raw key presses on whatever has focus, including held modifier keys.
  • user.tab(), user.clear(el), user.selectOptions(el, "pro"), user.upload(input, file), user.hover(el), user.paste("text"): the other common interactions.

Is fireEvent ever right? Yes, for events no user action produces in jsdom, such as scroll on a container, load on an image, or a custom event. Reach for it on purpose, not by habit.

Testing a form end to end, without a browser

Forms are where integration tests pay off most, because they combine labels, validation, focus, async submit, and error handling. A good form test fills the form the way a person does, submits it, and then checks two things: what the user sees, and what the app sent to the server.

tsx
test("submits the sign-up form", async () => {
  let sent: unknown;
  server.use(
    http.post("/api/signup", async ({ request }) => {
      sent = await request.json();
      return HttpResponse.json({ id: "u-1" }, { status: 201 });
    })
  );
  const user = userEvent.setup();
  render(<SignupForm />);

  await user.type(screen.getByRole("textbox", { name: "Email" }), "ana@example.com");
  await user.type(screen.getByLabelText("Password"), "correct horse 42");
  await user.click(screen.getByRole("checkbox", { name: /accept the terms/i }));
  await user.click(screen.getByRole("button", { name: "Create account" }));

  expect(await screen.findByRole("heading", { name: "Check your inbox" })).toBeInTheDocument();
  expect(sent).toEqual({ email: "ana@example.com", password: "correct horse 42", terms: true });
});
The handler records the request body. Asserting on what was sent is fine: the request is an edge of the app, like the screen.

Note the password field is found with getByLabelText: an input type="password" has no ARIA role, so getByRole cannot find it. That is one of the few common cases where the second query in the priority list is the first choice.

Then cover the paths users hit when things go wrong. Submitting an empty form should show errors that are tied to their fields, so expect(emailField).toHaveAccessibleDescription("Enter an email address") and toBeInvalid() check both the message and its connection. Many forms also move focus to the first invalid field, which expect(emailField).toHaveFocus() checks. A double click on submit should send one request, not two.

Check your understanding · Exercise 4

An autocomplete opens its list on keydown of ArrowDown and filters on input. Which test steps exercise the real paths? Select all that apply.

Async UI: waiting without sleeping

Most real UI is async. A click starts a fetch, a spinner shows, data arrives 80 ms later, the list renders. A debounced search waits 300 ms after the last keystroke, and a toast disappears after 5 seconds. A test that asserts at the wrong moment either fails for no reason or, worse, passes for the wrong reason.

The wrong fix is to sleep: await new Promise(r => setTimeout(r, 500)). It is slow on every run and still flaky, because 500 ms is sometimes not enough on a busy CI machine. The right fix is to wait for the thing the user waits for: the result on the screen.

findBy and waitFor

findBy... queries wait. Internally, findByRole(...) is waitFor(() => getByRole(...)): it runs the query, and if it throws, runs it again every 50 ms and on every DOM change, until it passes or 1,000 ms have gone by. If data arrives after 80 ms, the test continues after about 80 ms, not after a fixed sleep.

Server answers after:
The test:
A timeline from 0 to 3,000 ms. The request is in flight from 0 to 400 ms, when the table appears. The test, using Sleep 500 ms, then getBy, passes at 500 ms.0 ms1,000 ms2,000 ms3,000 msAPIdatatestpass
await sleep(500); screen.getByRole("table"): Passes, but always takes 500 ms even though the data was there after 400 ms.

Pass at 500 ms. Passes, but always takes 500 ms even though the data was there after 400 ms.

waitFor(callback) is the general form for assertions that are not a single query, such as "the save button is enabled again" or "the request was sent". Three rules keep it reliable.

  • One assertion per waitFor. If you put five, a failure in the first one hides whether the others would pass, and the error message is the last one thrown when time runs out.
  • No side effects inside. The callback can run dozens of times. A user.click inside it clicks dozens of times. Act first, then wait.
  • Prefer findBy when you are waiting for an element. It reads better and its error message shows the accessibility tree. Use waitForElementToBeRemoved(() => screen.queryByRole("progressbar")) to wait for a spinner to go away.
tsx
await user.click(screen.getByRole("button", { name: "Save" }));

// Good: wait for what the user waits for
expect(await screen.findByRole("status")).toHaveTextContent("Saved");
await waitFor(() => expect(screen.getByRole("button", { name: "Save" })).toBeEnabled());

// Slow server in this test? Raise the limit for this query only
await screen.findByRole("table", {}, { timeout: 3000 });

// Bad: a fixed sleep, then a sync query
await new Promise((r) => setTimeout(r, 500));
expect(screen.getByText("Saved")).toBeInTheDocument();
The third argument of a findBy query takes waitFor options such as timeout. Raising it is a fix for one slow case, not a habit.

What the act warning is telling you

Sooner or later you will see: "An update to SearchBox inside a test was not wrapped in act(...)". act is React's way of saying "run this, then flush every state update and effect it caused before you look at the screen". Testing Library already wraps render, user-event actions, fireEvent, and the waiting in findBy and waitFor in act for you.

So the warning almost always means one thing: a state update happened while the test was not waiting for anything. Typically a fetch resolved after your last assertion, or a timer fired after the test moved on. The test asserted too early, or ended while work was still in flight.

The fix is not to wrap more code in act until the warning goes away. The fix is to await the final state the user would see: await screen.findByText("3 results"). Once the test waits for the end of the async work, the update happens inside the wait, and the warning disappears because the test is now correct.

Fake timers for debounce and timeouts

Some behavior is about time itself: a search that fires 300 ms after the last keystroke, a toast that hides after 5 seconds, a session warning after 15 minutes. Waiting for real would make the suite slow, and asserting that something has not happened yet is impossible without controlling the clock.

Fake timers replace setTimeout, setInterval, and Date with a clock you move by hand. vi.useFakeTimers() in Vitest (jest.useFakeTimers() in Jest) installs it; vi.advanceTimersByTime(300) moves it. Tell user-event about the fake clock with userEvent.setup({ advanceTimers: vi.advanceTimersByTime }). Its default delay between key presses is 0 ms, but it still goes through setTimeout, so without this option it waits on a clock that never moves.

Two timelines from 0 to 600 ms for a search box with a 300 ms debounce. Top: a person types b at 0 ms, l at 90, u at 170, and e at 240. Each key cancels the previous timer, so the only timer that runs out starts at 240 and fires one request at 540 ms. Bottom: in a test with a fake clock, user-event types all four letters while the clock stays at 0. Advancing 299 ms gives 0 requests; advancing 1 more ms fires the one request at 300 ms.0100200300400500600a person types, real clock (ms)blue1 request at 5403 timers cancelledthe same input in a test, fake clockblueall 4 keys at 0 ms, the clock does not moveadvance 299: 0 requestsadvance 1: 1 request
On the real clock, each key restarts the 300 ms timer. On the fake clock nothing moves until the test advances it, so the test can check the moment just before the request and the moment it fires.
tsx
test("searches once, 300 ms after the last keystroke", async () => {
  vi.useFakeTimers();
  let calls = 0;
  server.use(
    http.get("/api/search", ({ request }) => {
      calls += 1;
      const q = new URL(request.url).searchParams.get("q");
      return HttpResponse.json([{ id: 1, name: `${q} mug` }]);
    })
  );
  const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
  render(<SearchBox />);

  await user.type(screen.getByRole("combobox", { name: "Search" }), "blue");
  await act(() => vi.advanceTimersByTimeAsync(299));
  expect(calls).toBe(0); // still inside the debounce window

  await act(() => vi.advanceTimersByTimeAsync(1));
  vi.useRealTimers(); // let findBy poll on the real clock
  expect(await screen.findByRole("option", { name: "blue mug" })).toBeInTheDocument();
  expect(calls).toBe(1);
});
Time moves only when the test says so. Four keystrokes, one request, and the 299 ms check proves the debounce really waits.

Why switch back to real timers before findBy? In Jest, Testing Library detects Jest's fake timers through the jest global and advances them while it waits. Vitest has no jest global, so that detection does not apply, and a findBy may wait on a frozen clock.

Switching back to real timers once the timed part is done avoids the hang. Creating the timers with vi.useFakeTimers({ shouldAdvanceTime: true }) also works, but then the fake clock also moves with real time, which can blur an exact check like the 299 ms one. This is the corner where Vitest and Jest differ most, so check your runner's docs.

Check your understanding · Exercise 5

A test clicks "Load more", then immediately calls expect(screen.getAllByRole("listitem")).toHaveLength(20) and fails with 10 items. What are good fixes? Select all that apply.

Mocking the network with MSW

Integration tests should not call a real server. A real server is slow, shared, and full of data that changes; it makes tests flaky and turns "what if the API returns a 500" into something you cannot test at all. So the network is the first edge you fake. The question is where you cut.

Three places to cut

Decision Where should a component test replace the network?

Mock your API module

vi.mock("./api") replaces your getProducts function with a fake that returns data.

The component never builds a URL or parses a response.

Pros

  • Quick to write for one test.
  • No extra library.

Cons

  • Skips your real fetching code: URL building, headers, JSON parsing, error mapping all go untested.
  • Ties the test to a module path and function name, so moving or renaming breaks it.
  • Easy to return data the real API never sends.

Stub global fetch

vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(...)).

Everything above fetch runs for real.

Pros

  • Exercises your fetching code.
  • No extra library.

Cons

  • Only catches fetch. A switch to axios, or a library with its own client, bypasses the stub.
  • Matching by URL and method is manual, so tests fill up with if chains.
  • Each test repeats response plumbing.

Intercept requests with MSW

Describe the API as request handlers: http.get("/api/products", () => HttpResponse.json(list)).

MSW intercepts outgoing requests at the network layer, in Node for tests and through a service worker in the browser.

Pros

  • Your whole stack runs for real, whichever HTTP client it uses.
  • The same handlers serve tests, local development, and Storybook.
  • Per-test overrides make errors and slow responses one line each.

Cons

  • One more library and a setup file.
  • Handlers can still drift from the real API; pair them with types or contract tests.

What we pick: Use MSW for component and integration tests: it is the only option that leaves all of your code real and does not care which HTTP client you use. Mocking a module is fine for a unit test of logic that sits above the API layer. Stubbing fetch directly is rarely worth it once MSW is set up.

Handlers and the test server

MSW, short for Mock Service Worker, describes your API as a list of request handlers. Each handler matches a method and a URL pattern and returns a response built with the standard Response API, wrapped in helpers such as HttpResponse.json. In tests, setupServer from msw/node intercepts requests made from Node; no real service worker is involved there.

ts
// test/handlers.ts: the happy path, shared with dev and Storybook
import { http, HttpResponse } from "msw";

export const handlers = [
  http.get("/api/products", () =>
    HttpResponse.json([{ id: "p-1", name: "Blue mug", price: 1200 }])
  ),
  http.get("/api/products/:id", ({ params }) =>
    HttpResponse.json({ id: params.id, name: "Blue mug", price: 1200 })
  ),
];

// test/server.ts
import { setupServer } from "msw/node";
export const server = setupServer(...handlers);

// test/setup.ts: runs before every test file
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
With onUnhandledRequest: "error", MSW logs an error and fails any request that no handler matches, so a test can never quietly reach a real server.

The three lifecycle calls matter. listen starts intercepting. resetHandlers after each test throws away any overrides that test added, so one test's error case does not leak into the next. close stops intercepting when the file is done.

Per-test overrides: errors, empty, slow

The shared handlers describe the happy path. Each test that needs something different calls server.use(...), which puts new handlers in front of the defaults. When a request comes in, MSW checks the newest overrides first, then the defaults, and the first handler that returns a response wins.

MSW's list of request handlers, checked from the newest to the oldest, for a request GET /api/products. During the test, the list is: the server.use override that returns 500, then the default products handler, then the default /api/me handler. The override matches first and wins. After resetHandlers, the override is gone and the default products handler matches. A request that no handler matches fails with a logged error, instead of reaching a real server, because onUnhandledRequest is set to error.GET /api/productsduring the test, newest firstuse(): 500default: listdefault: /api/meafter server.resetHandlers()default: listdefault: /api/meNo handler matches? onUnhandledRequest: error failsthe request and logs it, instead of calling a real server.
server.use puts overrides at the front; resetHandlers removes them after each test. The first handler that matches and returns a response wins.
tsx
import { delay, http, HttpResponse } from "msw";

test("shows an error with a retry button when the API fails", async () => {
  server.use(
    http.get("/api/products", () =>
      HttpResponse.json({ message: "Internal error" }, { status: 500 })
    )
  );
  render(<ProductList />);
  expect(await screen.findByRole("alert")).toHaveTextContent(/could not load/i);
  expect(screen.getByRole("button", { name: "Try again" })).toBeInTheDocument();
});

// Other one-line variations:
http.get("/api/products", () => HttpResponse.json([]));       // empty state
http.get("/api/products", () => HttpResponse.error());        // network failure
http.get("/api/products", async () => {                        // slow response
  await delay(2000);
  return HttpResponse.json([]);
});
http.get("/api/products", async () => {                        // loading forever
  await delay("infinite");
});
HttpResponse.error() makes fetch reject, as it does when the user goes offline. delay("infinite") keeps the request pending so you can test the loading state at leisure.

Try each scenario in the widget below. Every state a user can see (loading, data, empty, server error, offline) is one handler away, which is why teams that use MSW tend to test the unhappy paths that teams with hand-made fetch stubs skip.

The API:

Override: server.use(http.get("/api/products", () => HttpResponse.json(products)))

Then: await screen.findAllByRole("listitem")

The product list as the user sees it when the API is set to Success: The list of three products.ProductsBlue mugOak trayLinen napkins
The list of three products. One line of setup per state, and every line of your fetching code still runs.

Two more options help in specific tests. http.get(url, resolver, { once: true }) answers only the first matching request, then falls through to the defaults: perfect for "fails once, then the retry succeeds". And when a request should be checked rather than answered, read it in the resolver (await request.json(), new URL(request.url).searchParams) as the form test in chapter 4 did.

Check your understanding · Exercise 6

A test adds server.use(http.get("/api/me", () => new HttpResponse(null, { status: 401 }))). The next test in the file expects the logged-in header and fails. What is the likely cause and fix? Select all that apply.

Testing hooks, and when not to

Custom hooks hold a lot of frontend logic: pagination math, a debounced value, a form's dirty state, data fetching through a server-state library. A hook can only run inside a component, so to test one on its own you need a component to host it. renderHook from @testing-library/react is that host.

It renders an invisible test component that calls your hook and stores the latest return value in result.current. You call the hook's functions inside act so React applies the state updates, then read result.current again. (The older @testing-library/react-hooks package does not support React 18 and later; renderHook has been part of @testing-library/react since version 13.1.)

ts
import { act, renderHook } from "@testing-library/react";

test("usePagination moves between pages and stops at the ends", () => {
  const { result, rerender } = renderHook(
    ({ total }) => usePagination({ total, pageSize: 10 }),
    { initialProps: { total: 95 } }
  );
  expect(result.current.pageCount).toBe(10);
  expect(result.current.hasPrevious).toBe(false);

  act(() => result.current.goTo(10));
  expect(result.current.hasNext).toBe(false);

  rerender({ total: 40 }); // the data shrank
  expect(result.current.page).toBe(4); // clamped to the new last page
});
Read result.current after each act: it always holds the latest render's value, never a stale copy.

Hooks that need providers

Many hooks read context: a router, a theme, a store, a query client. Pass a wrapper component that provides them. For server-state libraries, create a fresh client in every test, so cached data from one test cannot satisfy a request in the next, and turn retries off so error tests finish fast.

tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

function createWrapper() {
  const client = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });
  return ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={client}>{children}</QueryClientProvider>
  );
}

test("useProduct loads a product by id", async () => {
  const { result } = renderHook(() => useProduct("p-1"), {
    wrapper: createWrapper(),
  });
  await waitFor(() => expect(result.current.isSuccess).toBe(true));
  expect(result.current.data?.name).toBe("Blue mug");
});
MSW answers the request from the shared handlers. The same createWrapper also works as the wrapper option of render for component tests.

Most teams turn that wrapper into a custom render (often called renderWithProviders) that sets up every provider the app has: query client, router at a given URL, theme, store. Components then render in tests the same way they render in the app, and a test can still pass a starting URL or preloaded store state when it needs one.

Test the hook, or test through a component?

renderHook is a unit test: it checks the hook's return values, which are its implementation as far as users are concerned. If the hook exists only to serve one component, those return values are internal plumbing. Test the component instead, and the hook gets tested along the way, through what users see.

A decision tree for testing a custom hook. First question: is it shared by many components? If no, test it through the component, because the hook is plumbing. If yes, second question: does it have many edge cases or no UI of its own? If yes, write renderHook tests, as for useDebouncedValue or a date range hook. If no, component tests are enough, as for a simple useToggle.Shared by many components?noyesTest the componentthe hook is plumbingMany edge cases or no UI?noyesComponent testsuseTogglerenderHook testsuseDebouncedValue
Start from how many components use the hook. A hook with one caller is part of that component; a shared hook with real rules deserves its own tests.
  • Test the hook directly when it is shared by many components (a library hook like useDebouncedValue or useLocalStorage), when it has many edge cases that would be tedious to drive through UI (pagination math, a date range picker's rules), or when it has no UI of its own yet.
  • Test through a component when only one component uses it, when its job is to wire data into that component, or when the interesting behavior is what the user sees (a loading spinner, an error message).
  • Do both, sparingly, for a central hook such as useCart: a handful of renderHook tests for its rules, plus a component test of the cart page for the user flow.

Check your understanding · Exercise 7

Which of these hooks are good candidates for direct renderHook tests? Select all that apply.

Accessibility checks and choosing your tools

Role-based queries already give you a light accessibility check for free: a test that finds getByRole("button", { name: "Close" }) proves the close button is a button and has a name. Automated rule checkers go further, and they fit into the same unit and integration tests.

axe in unit tests

axe-core is an open source engine from Deque that checks a DOM tree against a set of accessibility rules. The jest-axe package (and vitest-axe, a fork with the same API for Vitest) wraps it in a toHaveNoViolations matcher, so one line checks the rendered output of any test.

tsx
// Setup file, once: import "vitest-axe/extend-expect";
// (jest-axe: expect.extend(toHaveNoViolations) from "jest-axe")
import { axe } from "vitest-axe"; // or: import { axe } from "jest-axe"

test("the sign-up form has no detectable a11y violations", async () => {
  const user = userEvent.setup();
  const { container } = render(<SignupForm />);
  expect(await axe(container)).toHaveNoViolations();

  // Check the error state too: new elements, new chances to break
  await user.click(screen.getByRole("button", { name: "Create account" }));
  expect(await axe(container)).toHaveNoViolations();
});
Run axe on every state a user can reach (errors, open dialogs, expanded menus), not only on the first render.

In jsdom, axe reliably catches structural problems: form fields without labels, images without alt, buttons and links with no accessible name, invalid ARIA roles, attributes, or values, and lists with broken structure. Those are common and cheap to catch on every commit.

It cannot catch what needs a real browser or a human. jsdom computes no layout and applies little CSS, so colour contrast cannot be checked (jest-axe turns that rule off for this reason), and neither can text that overflows or a focus outline hidden by styles. No tool can tell whether alt text is meaningful, whether the focus order makes sense, or whether a custom widget follows the keyboard pattern people expect.

A matrix of seven accessibility problems against three kinds of check. A field with no label and a button with no name: caught by axe in jsdom, by role queries and keyboard tests, and by a browser plus a human. An invalid ARIA attribute: caught by axe, not by queries, partly by a human. Low colour contrast, useless alt text, and a focus ring hidden by CSS: only a browser and a human catch them. An illogical focus order: not axe, partly a keyboard test, fully a human.axe in jsdomqueries, keysbrowser, humanField with no labelyesyesyesButton with no nameyesyesyesInvalid ARIA attributeyesnosomeLow colour contrastnonoyesIllogical focus ordernosomeyesUseless alt textnonoyesHidden focus ringnonoyes
axe in jsdom catches structure cheaply on every commit. Anything that needs layout, CSS, or judgement needs a real browser and a person.

Deque's 2021 study of its own audit data found that automated checks caught about 57% of accessibility issues by volume. Counted by WCAG success criteria instead, automated coverage is much lower, since many criteria need human judgement. So treat a passing axe test as a floor, not a finish line. Keyboard tests with user.tab() and user.keyboard() cover a slice more, and the rest needs browser-level checks and manual testing with a screen reader, covered in the accessibility testing lesson.

Vitest or Jest, jsdom or a real browser

Everything in this lesson works in both major runners; Testing Library, user-event, MSW, and axe are runner-neutral. The APIs are nearly the same (vi.fn() versus jest.fn(), vi.mock versus jest.mock), so the choice is mostly about your build setup. Both projects move fast, so check current docs before relying on any detail below.

A sketch of the trade-off, not a benchmark. Speed depends far more on test design than on the runner.
QuestionVitestJest
How it transforms codeThrough Vite, with the same plugins and aliases as your appThrough its own transform (Babel, ts-jest, or SWC), configured separately
ES modules and TypeScriptNative, no extra setupWorks, but ESM support has historically needed extra configuration
Fits best withVite apps and libraries, newer projectsOlder codebases, React Native, large existing suites
Real browser optionBrowser mode runs tests in Chromium, Firefox, or WebKitNo browser mode (jsdom or Node only); use a separate tool for browser tests
Fake timers inside findByNot detected; switch to real timers or use shouldAdvanceTimeDetected and advanced automatically

For the DOM, jsdom is the usual choice and the most complete simulation. happy-dom is a lighter alternative that is often faster but implements less, so a few tests behave differently. When a feature truly depends on layout, scrolling, real CSS, or browser APIs jsdom lacks, run that test in a real browser through Vitest browser mode or Playwright component tests rather than stubbing half the platform.

Check your understanding · Exercise 8

Your component tests run axe in jsdom and pass. Which problems could still ship? Select all that apply.

Anti-patterns and flaky tests

Most bad component tests fail in one of a few recognisable ways. Each looks reasonable in one pull request and becomes expensive after a year. Learn to spot them in review, because a suite is only as trustworthy as its weakest habits.

Testing implementation details

An implementation detail is anything users cannot see or do: component state, instance methods, the names of child components, hook call counts, render counts, CSS classes, the DOM structure between the element you care about and the root. Tests that check these raise false alarms on every refactor and still miss what users hit.

  • Shallow rendering (rendering one level, with children as placeholders) tests that a parent passes props, not that the feature works. React 19 dropped the built-in shallow renderer and deprecated react-test-renderer; Testing Library has no shallow mode on purpose.
  • Mocking your own child components (vi.mock("./PriceRow")) hides bugs in how parent and child fit together, which is where most bugs live.
  • Selectors such as container.querySelector("div > ul li:nth-child(2) .title") break when a designer adds a wrapper div. Find the list item by its text or the row by its name.
  • Asserting on calls to your own functions (expect(handleSubmit).toHaveBeenCalled()) checks wiring, not outcome. Assert on what the user sees, or on the request that went out. Spying on a callback prop is fine when calling it is the component's public contract, as with an onSelect prop of a reusable component.

Snapshot overuse

A snapshot test saves the rendered output to a file the first time and fails if it ever changes. It is quick to write and feels thorough. In practice, large component snapshots go wrong in a predictable way: every markup change fails dozens of them, the diffs are long, and reviewers learn to press u to update them all without reading.

A snapshot diff for a sign-up button. The only change is a generated class name, from css-1x9f to css-7k2a, after a CSS refactor. The button text, Create account, is the same. Notes say: 47 snapshots fail with zero changes a user can see, and the reviewer runs update all, so a real change in the same batch slips through unread.SignupForm.test.tsx.snap, 1 of 47 failures- button class="btn css-1x9f"+ button class="btn css-7k2a" Create account /buttononly a hashed classchanged: users seenothing new47 snapshots fail,reviewer runs -uon all of thembetter: expect(button).toHaveAccessibleName("Create account")
The diff is all noise: a hashed class name changed and the user sees nothing new. After a few of these, updating every snapshot without reading becomes a habit.

A snapshot also does not say what matters. When it fails, nobody knows whether the change is a bug or a new feature. An explicit assertion such as expect(row).toHaveTextContent("$12.00") states its intent and fails only when that intent breaks. Keep snapshots small and inline (toMatchInlineSnapshot) for output that is truly data: a formatter's result, a generated URL, an error message, a serialized config.

Over-mocking

Every mock is a claim about how something behaves. With five mocks in one test, you are testing five claims plus a sliver of real code. When the real dependency changes, the mock does not, and the test keeps passing against a world that no longer exists.

The rule from chapter 2 limits the damage: fake only the edges. Inside your app, everything runs for real. At the edges, prefer one shared, realistic fake (MSW handlers typed with the same response types as your app) over a new hand-written stub in every test. To catch the drift that remains between your handlers and the real API, add contract tests, the topic of a later lesson.

Why component tests flake, and the fixes

CauseSymptomFix
Asserting before async work finishesFails on slow CI, act warningsAwait findBy or waitFor on the final state
State leaking between testsPasses alone, fails in the full run or in a different orderReset MSW handlers, create a new query client per test, clear localStorage, restore mocks and timers in afterEach
Real time and datesFails near midnight, at month end, or in another time zoneFake the clock with vi.setSystemTime, and set TZ in the test config
Unhandled requests reaching the networkRandom timeouts, different data per runonUnhandledRequest: "error"
Fixed sleepsSlow, and still fails under loadWait for the condition, never for a duration
Missing cleanupElements from the last test still in the DOMTesting Library cleans up after each test when the runner exposes a global afterEach; with Vitest without globals, call cleanup() yourself

Check your understanding · Exercise 9

A test passes when run alone but fails when the whole file runs. What are the likely causes? Select all that apply.

Using it in a system design interview

Testing rarely gets its own interview question, but it often closes one. After you design an autocomplete, a data table, or a checkout form, the interviewer asks "how would you test this?". A vague answer ("unit tests and some end-to-end tests") wastes the moment. A specific one shows you have shipped and maintained real UI.

A two-minute answer

  1. Name the boundary. "I would render the real component tree and fake only the network and the clock. Tests act through the screen with Testing Library and user-event, and the network is faked with MSW."
  2. List the states, not the functions. For an autocomplete: typing shows suggestions, keyboard navigation moves the active option, Enter selects, Escape closes, an empty result shows a message, a server error shows a retry, a slow request shows a spinner, and stale responses do not overwrite newer ones.
  3. Say how each is set up. Each state is one MSW override: an empty array, a 500, delay, or two requests answered out of order. Debounce uses fake timers. Keyboard behavior uses user.keyboard.
  4. Mention accessibility. Role queries prove the combobox and options are exposed correctly, and axe runs on the open state. Contrast and screen reader behavior need a browser check.
  5. Place it in the strategy. Unit tests with renderHook for the shared debounce hook, integration tests for the states above, one end-to-end test for the critical search-to-product path.

How deep to go by level

A mid-level answer covers role queries, user-event, MSW for errors and empty states, and awaiting async UI. A senior answer adds why: testing behavior so refactors stay cheap, the act warning as a signal of a race, fresh clients and reset handlers to prevent flaky order dependence. A staff answer talks about the suite as a system: shared handlers typed from the API schema, contract tests for drift, CI time budgets, and how the team keeps flake rates near zero.

Watch for the trap of over-engineering. You do not need to write test code on the whiteboard. Two or three named tools, a list of states, and one sentence about what you would not mock is usually what the interviewer wants to hear.

Check your understanding · Exercise 10

You designed a comments panel with optimistic posting. Which test plans would impress an interviewer? Select all that apply.

Summary

  • A good component test fails when a user would notice a problem and survives any refactor a user cannot see.
  • Keep everything you own real and fake only the edges: the network, the clock, and browser APIs jsdom lacks.
  • Query like a user: getByRole with a name first, getByLabelText for fields, getByText for content, getByTestId last.
  • Act with user-event v14: create const user = userEvent.setup() per test and await every interaction.
  • Wait for what the user waits for with findBy and waitFor, never a fixed sleep; act warnings usually mean the test asserted too early.
  • Fake the network with MSW: shared happy-path handlers, server.use overrides for errors, empty, and slow cases, and resetHandlers after each test.
  • Use renderHook for shared hooks with many edge cases; test single-use hooks through their component.
  • axe in jsdom catches structural accessibility bugs cheaply but not contrast, focus order, or meaning; treat it as a floor.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

What does "test behavior, not implementation" mean for a React component, and why does it matter?

Question 2

Why prefer getByRole over getByTestId?

Question 3

How would you test a component that fetches data, including its error and loading states?

Question 4

You see "not wrapped in act(...)" warnings in a test. What is going on and how do you fix it?

Question 5

When would you test a custom hook with renderHook, and when through a component?

Practice it

These design problems lean on unit and integration tests.

References

Read next