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.
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.
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.
| Aspect | Unit test | Integration test |
|---|---|---|
| What it runs | One function, reducer, or hook | A component tree with its real children, hooks, and providers |
| How it acts | Calls the code directly | Clicks, types, and tabs through the screen |
| What it checks | Return values, thrown errors | Text, roles, focus, requests sent |
| What is fake | Usually nothing | The network, the clock, missing browser APIs |
| Typical time | 1 to 10 ms | 20 to 300 ms |
| Good fit | Price formatting, date math, parsers, reducers | Forms, 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.
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");
});
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?
getByRole: finds by ARIA role and accessible name, such asgetByRole("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.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.getByPlaceholderText: a fallback for fields with no label. A placeholder is not a label, so needing this query is itself a small accessibility smell.getByText: finds non-interactive content by its visible text: paragraphs, messages, list item text.getByDisplayValue: finds a filled-in field by its current value. Handy for edit forms that load existing data.getByAltTextandgetByTitle: images by alt text, and elements bytitle. The title attribute is read inconsistently by assistive technology, so prefer the others.getByTestId: finds by adata-testidattribute. 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.
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.
// 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);
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.
| Variant | No match | One match | Several matches | Waits? | Use it to |
|---|---|---|---|---|---|
getBy... | Throws | Returns it | Throws | No | Find something that should be there now |
queryBy... | Returns null | Returns it | Throws | No | Assert that something is absent |
findBy... | Rejects after the timeout | Resolves with it | Rejects | Yes, 1,000 ms by default | Find something that appears later |
getAllBy... | Throws | Array of 1 | Array | No | Count 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.
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.
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.
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();
});
user.click(el)anduser.dblClick(el): pointer events, mouse events, focus, then click. They refuse to click an element withpointer-events: none, as a browser would.user.type(el, "text"): clicks the field, then sends each character askeydown,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.
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 });
});
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.
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.clickinside it clicks dozens of times. Act first, then wait. - Prefer
findBywhen you are waiting for an element. It reads better and its error message shows the accessibility tree. UsewaitForElementToBeRemoved(() => screen.queryByRole("progressbar"))to wait for a spinner to go away.
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();
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.
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);
});
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?
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.
// 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());
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.
server.use puts overrides at the front; resetHandlers removes them after each test. The first handler that matches and returns a response wins.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");
});
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.
Override: server.use(http.get("/api/products", () => HttpResponse.json(products)))
Then: await screen.findAllByRole("listitem")
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.)
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
});
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.
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");
});
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.
- Test the hook directly when it is shared by many components (a library hook like
useDebouncedValueoruseLocalStorage), 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 ofrenderHooktests 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.
// 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();
});
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.
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.
| Question | Vitest | Jest |
|---|---|---|
| How it transforms code | Through Vite, with the same plugins and aliases as your app | Through its own transform (Babel, ts-jest, or SWC), configured separately |
| ES modules and TypeScript | Native, no extra setup | Works, but ESM support has historically needed extra configuration |
| Fits best with | Vite apps and libraries, newer projects | Older codebases, React Native, large existing suites |
| Real browser option | Browser mode runs tests in Chromium, Firefox, or WebKit | No browser mode (jsdom or Node only); use a separate tool for browser tests |
| Fake timers inside findBy | Not detected; switch to real timers or use shouldAdvanceTime | Detected 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 wrapperdiv. 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 anonSelectprop 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 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
| Cause | Symptom | Fix |
|---|---|---|
| Asserting before async work finishes | Fails on slow CI, act warnings | Await findBy or waitFor on the final state |
| State leaking between tests | Passes alone, fails in the full run or in a different order | Reset MSW handlers, create a new query client per test, clear localStorage, restore mocks and timers in afterEach |
| Real time and dates | Fails near midnight, at month end, or in another time zone | Fake the clock with vi.setSystemTime, and set TZ in the test config |
| Unhandled requests reaching the network | Random timeouts, different data per run | onUnhandledRequest: "error" |
| Fixed sleeps | Slow, and still fails under load | Wait for the condition, never for a duration |
| Missing cleanup | Elements from the last test still in the DOM | Testing 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
- 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."
- 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.
- 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 usesuser.keyboard. - 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.
- Place it in the strategy. Unit tests with
renderHookfor 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:
getByRolewith a name first,getByLabelTextfor fields,getByTextfor content,getByTestIdlast. - Act with
user-eventv14: createconst user = userEvent.setup()per test andawaitevery interaction. - Wait for what the user waits for with
findByandwaitFor, never a fixed sleep; act warnings usually mean the test asserted too early. - Fake the network with MSW: shared happy-path handlers,
server.useoverrides for errors, empty, and slow cases, andresetHandlersafter each test. - Use
renderHookfor 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?
It means the test only touches what a user can see and do, plus the edges of the app such as the network. It finds elements by role and label, acts with real input events, and asserts on the screen or on the requests sent. It never reads state, calls internal methods, or checks which child components rendered.
It matters in two directions. Implementation-coupled tests raise false alarms on refactors that users cannot notice, which makes refactoring expensive and teaches the team to ignore red builds. And they give false passes, because the state can be right while the label is disconnected or the button is unreachable by keyboard.
A good test survives any refactor a user cannot see and fails on any change a user can.
Question 2
Why prefer getByRole over getByTestId?
getByRole finds elements through the accessibility tree by role and accessible name, the same information a screen reader uses. So the test matches how people find the element, survives markup and styling refactors, and checks accessibility as a side effect: if the query cannot find the button, a screen reader user cannot either.
A test id is an attribute users never see. It survives refactors too, but it proves nothing about what users experience, and a test that uses it passes even when the label or role is broken. Keep it for elements with no role, name, or text.
Question 3
How would you test a component that fetches data, including its error and loading states?
Render the real component with its providers, using a fresh query client per test with retries off. Fake the network with MSW: shared happy-path handlers, onUnhandledRequest: "error", and resetHandlers after each test.
For each state, add one server.use override: an empty array for the empty state, a 500 for the server error, HttpResponse.error() for a network failure, and delay for the loading state. Then await what the user sees with findByRole, such as the alert and its retry button.
This keeps every line of my fetching code real, works with any HTTP client, and makes the unhappy paths as cheap to test as the happy one.
Question 4
You see "not wrapped in act(...)" warnings in a test. What is going on and how do you fix it?
React is saying a state update happened while the test was not inside act, so the test was not waiting for it. Testing Library already wraps render, user-event, and its async utilities in act, so the usual cause is that async work (a fetch, a timer) finished after the test's last await.
The fix is to await the final state the user would see, for example await screen.findByText("3 results"), not to wrap more code in act by hand. The warning is pointing at a race in the test.
Question 5
When would you test a custom hook with renderHook, and when through a component?
renderHook suits shared, generic hooks with many edge cases, such as a debounce hook used in a dozen places or a date range hook with tricky rules. Testing them directly is faster and covers the cases with a table of inputs.
If only one component uses the hook, its return values are internal plumbing. I would test the component instead, through what the user sees, and the hook is covered along the way. Exporting a hook only so a test can call it is a sign the test is at the wrong level.
Practice it
These design problems lean on unit and integration tests.
References
- Testing Library: guiding principles The principle behind every API choice in the library.
- Testing Library: about queries and their priority The query variants and the recommended order.
- user-event: introduction and setup Why user-event over fireEvent, and the v14 setup pattern.
- Mock Service Worker documentation Handlers, setupServer, per-test overrides, and response helpers.
- React Testing Library: API (render and renderHook) render options, wrapper, and renderHook.
- Kent C. Dodds: Common mistakes with React Testing Library A practical list of habits to avoid.
- Vitest: Mocking and fake timers vi.fn, vi.mock, and vi.useFakeTimers.
- jest-axe on GitHub axe-core as a matcher, and its documented jsdom limits.
Read next
- A frontend testing strategyHow to split a frontend's tests between static checks, unit, integration, and end-to-end tests, so they catch the bugs that matter without making every change slow.
- End-to-end testsTests that drive a real browser through your real app like a user would: what to cover, how to keep them reliable, where test data comes from, and how to run them in CI.
- Contract and visual regression testsTests that guard the edges between teams: schema checks and consumer-driven contracts stop an API change that would break a client, and visual tests catch pixel changes nobody meant, from one button to a whole design system.
- Testing accessibilityHow a team proves a UI works for people who use keyboards, screen readers, zoom, and voice control: layers of linting, automated scans, role-based unit tests, manual keyboard and screen reader passes, and real users, measured against WCAG.
- Debounce and throttleTwo small timing tools that turn a flood of events (keystrokes, scrolls, resizes) into a few calls you can afford: debounce waits for a pause, throttle runs at a steady rate.
- Server-state librariesTanStack Query, SWR, RTK Query, and Apollo cache server data by key, refetch stale entries in the background, and refresh them after mutations, so components stop hand-writing loading flags, races, and invalidation.