Testing React components
Test React components the way users use them: Vitest, React Testing Library, and user-event, role queries, async UI, MSW, hooks, and what to test.
- 01Write a component testSet up Vitest with jsdom and React Testing Library, render a component, find elements by role and label, and click and type with user-event.
- 02Test async UIWait for data with findBy and waitFor, fake the server with a stub or MSW, and test the loading, success, and error states.
- 03Decide what's worth testingTest what users see and do, not state names or class names, test hooks through a component, and know when an end-to-end test is the right tool.
A cart you'd rather not click by hand
Here is the tea shop's cart. Click Add tea a few times, then Empty the cart. You just checked that it works. Now imagine checking it again after every change, along with the checkout form, the product list, and the sign-up box. That's what tests are for: code that clicks and checks for you, in a second, every time.
import { useState } from "react"; export default function Cart() { const [items, setItems] = useState([]); return ( <section> <p>Items in cart: {items.length}</p> <button onClick={() => setItems([...items, "Tea"])}>Add tea</button> {items.length > 0 && <button onClick={() => setItems([])}>Empty the cart</button>} </section> );}Line 4 keeps the items in state. Line 8 shows the count, line 9 adds a tea, and line 10 shows Empty the cart only while there's something in it. Here is its first test. Its result below is the test report: each test, and whether it passed. (On this page, a small stand-in for the test runner Vitest prints that report; the next section explains why.)
import { render, screen, userEvent, test, expect } from "./testing.js";import Cart from "./Cart.jsx"; test("starts with an empty cart", () => { render(<Cart />); expect(screen.getByText("Items in cart: 0")).toBeInTheDocument();}); test("adds a tea when you click Add tea", async () => { const user = userEvent.setup(); render(<Cart />); await user.click(screen.getByRole("button", { name: "Add tea" })); expect(screen.getByText("Items in cart: 1")).toBeInTheDocument();});Line 1 brings in the testing tools; the next section explains where they come from. Line 2 imports the component. Each test has a name and a function. The first, on lines 4 to 7, renders the cart onto a page (line 5), then expects to find the text Items in cart: 0 on it (line 6). The second makes a pretend user on line 10, renders the cart, clicks the button whose role is button and whose name is Add tea (line 12), and expects Items in cart: 1 (line 13). The report shows both passing: Tests: 2 passed (2).
Now press Edit & run and change the 1 on line 13 to 2. The report shows the second test failing with Testing Library's message: Unable to find an element with the text: Items in cart: 2. A failing test tells you what it looked for and didn't find. Change it back, and it passes again.
Notice what the test doesn't touch: the items state, setItems, or any function inside Cart. It finds a button the way you did, by what it says, clicks it, and reads the screen. That's the idea behind React Testing Library, the library this lesson uses: "The more your tests resemble the way your software is used, the more confidence they can give you."
A shop hires a mystery shopper to find out what customers really get. The shopper doesn't inspect the stockroom or read the staff rota. They walk in, find things by their labels, buy something, and check what happens. If the shop rearranges its stockroom, the report doesn't change, as long as customers get the same service.
- In real life: A mystery shopper walks in like any customer
- In React:
render(<Cart />)puts the component on a page - In real life: They look for the tea by its label on the shelf
- In React:
screen.getByRole("button", { name: "Add tea" }) - In real life: They put it in the basket
- In React:
await user.click(…) - In real life: They check the till shows the right total
- In React:
expect(screen.getByText("Items in cart: 1"))… - In real life: They never go into the stockroom
- In React: The test never reads state or calls the component's functions
A component test renders a component, does what a user would do, and checks what the user would see. It finds elements by role, label, and text, not by class names or state, so it keeps passing when you change the code inside and fails when you break what users get.
Vitest, jsdom, and Testing Library: who does what
A React test uses several tools, each with one job. If the basics of a test runner, test, and expect are new, the Unit testing lesson builds a tiny one from scratch. How to read the table: each row is one tool, what it does, and where it runs.
| Tool | What it does | Where it runs |
|---|---|---|
| Vitest | Finds your test files, runs each test, and reports what passed. Gives you expect and vi for mocks. | Node, in your terminal or in CI. |
| jsdom | A pretend browser page in Node: a document with elements, events, and forms, but no layout or painting. | Inside Vitest, set by environment: "jsdom". |
| React Testing Library | render puts a component on the jsdom page; screen finds what a user would find, by role, label, or text. | Your test file, through @testing-library/react. |
| user-event | Clicks, types, and tabs like a person: every event a real browser sends, in order. | Your test file, through @testing-library/user-event. |
| jest-dom | Adds matchers about the page, like toBeInTheDocument and toBeDisabled. | The setup file, once for all tests. |
| MSW | Answers the app's network requests with data you choose, below fetch. | The setup file, and server.use in a test. |
Here is how a Vite project gets them, at the versions on npm when this lesson was checked in October 2026: Vitest 5.0.5, React Testing Library 16.3.3 with @testing-library/dom 10.4.2, user-event 14.6.8, jest-dom 7.0.1, and jsdom 30.1.3. One command installs them as development tools:
npm install --save-dev vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-domSince version 16, React Testing Library lists @testing-library/dom as a peer dependency, so you install it yourself, as above. Then tell Vitest to give tests a jsdom page, and to run a setup file before them:
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.js"],
},
});Line 5 adds Vite's React plugin, so Vitest reads JSX the way your app does. Line 7 makes every test file run with a jsdom page, and line 8 names the setup file:
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});Line 1 adds jest-dom's matchers, like toBeInTheDocument, to Vitest's expect. Lines 5 to 7 call cleanup after each test, which removes what the test rendered. Testing Library does this by itself only when it finds a global afterEach, and Vitest doesn't make one unless you set globals: true. Leave it out, and the second test still sees the first test's cart. This is a real run of the cart's tests with no cleanup, shortened:
❯ src/Cart.test.jsx (2 tests | 1 failed) 84ms
× adds a tea when you click Add tea 68ms
FAIL src/Cart.test.jsx > adds a tea when you click Add tea
TestingLibraryElementError: Found multiple elements with the role "button" and name "Add tea"
Test Files 1 failed (1)
Tests 1 failed | 1 passed (2)Two renders, two Add tea buttons on the page, and getByRole refuses to guess which one you meant. With the setup file above, add a test script to package.json:
{
"scripts": {
"test": "vitest"
}
}npm test now starts Vitest in watch mode: it runs your tests, then runs them again each time you save. npx vitest run runs them once, as CI does. Test files sit beside the component they test (Cart.test.jsx next to Cart.jsx, as in Structuring a React project), and Vitest finds every file named .test. or .spec.. In the project, the cart's test file starts with the real imports:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { test, expect } from "vitest";
import Cart from "./Cart.jsx";
test("starts with an empty cart", () => {
render(<Cart />);
expect(screen.getByText("Items in cart: 0")).toBeInTheDocument();
});
test("adds a tea when you click Add tea", async () => {
const user = userEvent.setup();
render(<Cart />);
await user.click(screen.getByRole("button", { name: "Add tea" }));
expect(screen.getByText("Items in cart: 1")).toBeInTheDocument();
});Lines 1 to 3 import from the three packages, and the rest of the file is the same as the live one. This is what Vitest printed when it ran this file in a real project:
RUN v5.0.5 /shop
✓ src/Cart.test.jsx > starts with an empty cart 19ms
✓ src/Cart.test.jsx > adds a tea when you click Add tea 83ms
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 16:24:28
Duration 782ms (environment 70%, tests 15%, setup 10%, import 3%, transform 2%)Vitest runs in Node, and a lesson page can't start Node. So each live test on this page runs in the result frame instead: with the real React 19.3.1, the real React Testing Library 16.3.3, and the real user-event 14.6.8, loaded from esm.sh, and with a small stand-in for Vitest's test, expect, and vi written for this lesson. Open the testing.js tab of any test to see the imports. The stand-in prints a short report instead of Vitest's, and its own failure messages are shorter than Vitest's and jest-dom's; Testing Library's messages are the real ones. Every live test on this page was also run in a real Vitest project, with the same results. Pop out shows the stand-in, mini-vitest.js, among the files; here it is:
// A small stand-in for Vitest, written for this lesson: test, expect, vi, and afterEach.
// It runs the tests one by one, then shows a short report, as Vitest does in a terminal.
const tests = [];
const afterEachHooks = [];
const stubbed = new Map();
export function test(name, fn) {
tests.push({ name, fn });
if (tests.length === 1) setTimeout(runTests);
}
export function afterEach(fn) {
afterEachHooks.push(fn);
}
async function runTests() {
const results = [];
for (const { name, fn } of tests) {
const logs = [];
const log = console.log;
console.log = (...values) => logs.push(values.join(" "));
let error = null;
try {
await fn();
} catch (thrown) {
error = thrown;
} finally {
console.log = log;
}
for (const hook of afterEachHooks) await hook();
results.push({ name, logs, error });
}
showReport(results);
}
// Testing Library's messages end with a copy of the whole page; the report keeps the start.
function shorten(error) {
const message = String(error?.message ?? error);
const start = message.split(/\n\s*\n(?=Ignored nodes|<body)|\n<body/)[0];
return start.replace(/-{10,}/g, "----------").split("\n").slice(0, 24).join("\n").trim();
}
function addLine(report, text, style = {}) {
const line = document.createElement(style.pre ? "pre" : "p");
line.textContent = text;
Object.assign(line.style, { margin: "0 0 4px", whiteSpace: "pre-wrap" }, style.css);
report.append(line);
}
function showReport(results) {
const report = document.createElement("section");
report.setAttribute("aria-label", "Test results");
Object.assign(report.style, { fontFamily: "ui-monospace, monospace", fontSize: "13px" });
for (const { name, logs, error } of results) {
addLine(report, `${error ? "FAIL" : "pass"} ${name}`, { css: { fontWeight: "bold" } });
for (const text of logs) addLine(report, ` log: ${text}`, { css: { opacity: "0.8" } });
if (error) addLine(report, shorten(error), { pre: true, css: { margin: "0 0 8px 6ch" } });
}
const failed = results.filter((result) => result.error).length;
const passed = results.length - failed;
const counts = [failed && `${failed} failed`, passed && `${passed} passed`].filter(Boolean).join(", ");
addLine(report, `Tests: ${counts} (${results.length})`, { css: { marginTop: "10px", fontWeight: "bold" } });
document.body.append(report);
}
const show = (value) =>
value instanceof Element ? `<${value.tagName.toLowerCase()}>` : typeof value === "string" ? JSON.stringify(value) : (JSON.stringify(value) ?? String(value));
const same = (a, b) => Object.is(a, b) || JSON.stringify(a) === JSON.stringify(b);
function element(value) {
if (!(value instanceof Element)) throw new Error(`received value must be a Node, but it is ${show(value)}`);
return value;
}
export function expect(actual) {
const matchers = (not) => {
const check = (pass, message) => {
if (pass === not) throw new Error(`expected ${not ? "not " : ""}${message}`);
};
const text = () => element(actual).textContent.replace(/\s+/g, " ").trim();
return {
toBe: (expected) => check(Object.is(actual, expected), `${show(actual)} to be ${show(expected)}`),
toEqual: (expected) => check(same(actual, expected), `${show(actual)} to equal ${show(expected)}`),
toHaveLength: (length) => check(actual.length === length, `a length of ${length}, received ${actual.length}`),
toBeGreaterThan: (number) => check(actual > number, `${show(actual)} to be greater than ${number}`),
toBeInTheDocument: () => check(actual !== null && element(actual).ownerDocument.contains(actual), `${show(actual)} to be in the document`),
toHaveTextContent: (expected) =>
check(expected instanceof RegExp ? expected.test(text()) : text().includes(expected), `${show(actual)} to have text ${show(expected)}, received ${show(text())}`),
toBeDisabled: () => check(element(actual).disabled === true, `${show(actual)} to be disabled`),
toBeEnabled: () => check(element(actual).disabled !== true, `${show(actual)} to be enabled`),
toHaveBeenCalledTimes: (times) => check(actual.mock.calls.length === times, `the mock to be called ${times} times, it was called ${actual.mock.calls.length}`),
toHaveBeenCalledWith: (...args) => check(actual.mock.calls.some((call) => same(call, args)), `the mock to be called with ${show(args)}`),
};
};
return { ...matchers(false), not: matchers(true) };
}
export const vi = {
fn(implementation = () => undefined) {
let run = implementation;
const mock = (...args) => {
mock.mock.calls.push(args);
return run(...args);
};
mock.mock = { calls: [] };
mock.mockImplementation = (next) => ((run = next), mock);
mock.mockResolvedValue = (value) => ((run = () => Promise.resolve(value)), mock);
return mock;
},
stubGlobal(name, value) {
if (!stubbed.has(name)) stubbed.set(name, globalThis[name]);
globalThis[name] = value;
},
unstubAllGlobals() {
for (const [name, value] of stubbed) globalThis[name] = value;
stubbed.clear();
},
};Find elements the way users do: by role and label
A test needs to find an element before it can click it or check it. Testing Library's screen has many queries for that, and they come in a recommended order: the ones that find elements the way people do come first. How to read the table: each query, what it finds, and when to reach for it, from most to least preferred.
| Query | What it finds | When to use it |
|---|---|---|
getByRole | An element by its role and accessible name: button named "Add tea". | Almost always. It's how screen readers find things, so it checks those work too. |
getByLabelText | A form field by its label's text. | Inputs, selects, and text areas with a <label>. |
getByPlaceholderText | A field by its placeholder. | Only when a field has no label (which is its own problem). |
getByText | An element by the text inside it. | Text that isn't a control: a message, a heading's words, a price. |
getByDisplayValue | A field by the value it shows now. | Checking a field a form filled in. |
getByAltText, getByTitle | An image by its alt text; an element by its title. | Images, and the rare element named only by a title. |
getByTestId | An element by a data-testid attribute you add. | Last. Users can't see test ids, so the test stops checking what they see. |
A role is what an element is for: button, textbox, link, heading, listitem, alert. HTML elements have one built in, so a <button> is a button without any extra attribute. The accessible name is what a screen reader says for it: a button's text, an input's <label>, or an aria-label. The Accessible React components lesson covers both; a test that finds elements by role and name checks them for free. Here is a sign-up box and its tests:
import { render, screen, userEvent, test, expect } from "./testing.js";import NewsletterForm from "./NewsletterForm.jsx"; test("waits for an email address", async () => { const user = userEvent.setup(); render(<NewsletterForm />); const button = screen.getByRole("button", { name: "Subscribe" }); expect(button).toBeDisabled(); await user.type(screen.getByLabelText("Email"), "asha@example.com"); expect(button).toBeEnabled();}); test("thanks you after you subscribe", async () => { const user = userEvent.setup(); render(<NewsletterForm />); await user.type(screen.getByRole("textbox", { name: "Email" }), "asha@example.com"); await user.click(screen.getByRole("button", { name: "Subscribe" })); expect(screen.getByRole("status")).toHaveTextContent("Thanks! We will write to asha@example.com.");});Open the NewsletterForm.jsx tab: a label, an email input, and a Subscribe button that stays off until the email has an @. In the test, line 7 finds the button by role and name, and line 8 expects it to be disabled. Line 9 finds the input by its label with getByLabelText("Email") and types into it, and line 10 expects the same button to be enabled now. The second test finds the same input another way on line 16: by its role, textbox, and its name, which comes from its label. Line 18 finds the thank-you message by its role, status.
Every query comes in three families, which differ in what they do when the element isn't there. How to read the table: each family, what it does when nothing matches, and when to use it.
| Family | When nothing matches | Use it when |
|---|---|---|
getBy… | Throws an error that lists what is on the page. | The element should be there now. |
queryBy… | Returns null, so you can expect it not to be there. | Checking that something is gone or never showed. |
findBy… | Keeps looking, and rejects after 1 second (by default). | The element appears after a request or a timer. Use with await. |
…AllBy… | Returns a list. getAllBy throws when there are none. | Several matches, like every list item. |
When getByRole finds nothing, its error message is worth reading: it lists every role on the page, with each element's name. This test looks for a button named Sign up, which the form doesn't have (a failure on purpose):
import { render, screen, test } from "./testing.js";import NewsletterForm from "./NewsletterForm.jsx"; test("finds the sign-up button", () => { render(<NewsletterForm />); screen.getByRole("button", { name: "Sign up" });});The report shows Testing Library's message: Unable to find an accessible element with the role "button" and name "Sign up", then "Here are the accessible roles": a textbox named Email and a button named Subscribe. That list is what assistive technology sees on the page. If a control you expect is missing from it, or has no name, users of screen readers can't find it either.
Click and type like a user with user-event
Testing Library has a simple way to send an event: fireEvent.click(button) dispatches one click event. A real click is more than that. This test watches six events on a button, sends a click each way, and prints what arrived. The printed lines show under each test as log:.
import { render, screen, fireEvent, userEvent, test, expect } from "./testing.js"; const watched = ["pointerdown", "mousedown", "focus", "pointerup", "mouseup", "click"]; function watch(button) { const seen = []; for (const type of watched) button.addEventListener(type, () => seen.push(type)); return seen;} test("fireEvent.click sends one event", () => { render(<button>Buy</button>); const seen = watch(screen.getByRole("button", { name: "Buy" })); fireEvent.click(screen.getByRole("button", { name: "Buy" })); console.log(seen.join(", ")); expect(seen).toEqual(["click"]);}); test("user.click sends what a real click sends", async () => { const user = userEvent.setup(); render(<button>Buy</button>); const seen = watch(screen.getByRole("button", { name: "Buy" })); await user.click(screen.getByRole("button", { name: "Buy" })); console.log(seen.join(", ")); expect(seen).toEqual(["pointerdown", "mousedown", "focus", "pointerup", "mouseup", "click"]);});Lines 5 to 9 add a listener for each event in the list on line 3 and collect their names. The first test sends fireEvent.click on line 14, and its log shows one event: click. The second calls user.click on line 23, and its log shows all six watched events, in the order a real mouse click sends them: pointerdown, mousedown, focus, pointerup, mouseup, click. (It also sends the events for moving the pointer onto the button, which the list doesn't watch.) The button even gets the keyboard focus, as it would in a browser.
That's why tests use user-event. A component that opens a menu on onPointerDown, or shows a hint on onFocus, works for real users and does nothing in a fireEvent.click test. user.type works the same way: it types one character at a time, with a key down, an input, and a key up for each, so your onChange runs once per letter, as it does in a browser. user-event also refuses things users can't do, like typing into a disabled field.
A few habits come with it:
- Call
userEvent.setup()at the start of each test, beforerender, and use theuserit gives you. awaiteveryusercall. Each one returns a promise, because a real interaction takes several steps.- Keep
fireEventfor the rare event no user action makes, like ascrollevent on one element.
Wait for the screen to change: findBy and waitFor
Many components change the screen later: after a request, a timer, or a transition. This product list loads its products in an effect, the way the Fetching data lesson does, and shows a loading message, the list, or an error. Its tests swap the real fetch for a stand-in that answers at once:
import { render, screen, waitFor, test, expect, vi } from "./testing.js";import ProductList from "./ProductList.jsx"; const teas = [ { id: 1, name: "Green tea", price: 3 }, { id: 2, name: "Mint tea", price: 4 },]; test("shows the products once they arrive", async () => { vi.stubGlobal("fetch", vi.fn(async () => Response.json(teas))); render(<ProductList />); expect(screen.getByText("Loading products…")).toBeInTheDocument(); const items = await screen.findAllByRole("listitem"); expect(items.map((item) => item.textContent)).toEqual(["Green tea $3", "Mint tea $4"]); expect(screen.queryByText("Loading products…")).not.toBeInTheDocument();}); test("says so when the server fails", async () => { vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 500 }))); render(<ProductList />); expect(await screen.findByRole("alert")).toHaveTextContent("Could not load products.");}); test("waitFor runs its check until it passes", async () => { vi.stubGlobal("fetch", vi.fn(async () => Response.json(teas))); render(<ProductList />); let tries = 0; await waitFor(() => { tries += 1; expect(screen.getAllByRole("listitem")).toHaveLength(2); }); console.log(`The check ran ${tries} times.`); expect(tries).toBeGreaterThan(1);});Line 10 uses vi.stubGlobal to replace the global fetch with a mock, made by vi.fn, that answers with the teas from lines 4 to 7. (This page's setup puts the real fetch back after each test. In a project, call vi.unstubAllGlobals() in your afterEach, or add unstubGlobals: true to the test options in vitest.config.js. The Mocks, spies & fakes lesson covers stand-ins like this.) Right after render, line 12 finds the loading message. Line 13 awaits findAllByRole, which keeps looking until the list items appear. Line 15 uses queryByText to check that the loading message is gone. The second test makes the stand-in answer with status 500 on line 19 and waits for the error on line 21.
render, the list isn't there yet. getAllByRole looks once and throws. findAllByRole keeps looking until the server's answer puts the list on the page.Try the mistake everyone makes once: press Edit & run and change line 13 to const items = screen.getAllByRole("listitem");. The first test fails with Unable to find an accessible element with the role "listitem": getAllByRole looked once, while the page still said "Loading products…".
findBy is built on waitFor, which you can use directly when the thing you wait for isn't one element. waitFor runs your function again and again, when the page changes and every 50 milliseconds, until it stops throwing, for up to a second by default. The third test counts the tries: line 29 adds one each time, and line 32 prints the total. In the report, the check ran more than once: the first try came before the products arrived.
React has a helper called act() that makes sure every update, effect, and re-render has finished before a test checks the screen. Testing Library already wraps render, its events, user-event's actions, and its waiting in act() for you, so you rarely write it. If React warns that an update "was not wrapped in act(...)", something changed the screen after the test stopped watching: usually a missing await or findBy.
Fake the server, not your component
Stubbing fetch works, but it ties the test to how the component asks: switch to a data library or axios, and the stub no longer fits, although users see the same list. Mock Service Worker (MSW, version 3.0.3) fakes the server instead. You describe what each address answers, and MSW catches the requests below fetch, so the component's own request code runs as it would in the browser. MSW needs Node, so this part runs in a project, not on this page.
import { http, HttpResponse } from "msw/http";
export const handlers = [
http.get("/api/products", () => {
return HttpResponse.json([
{ id: 1, name: "Green tea", price: 3 },
{ id: 2, name: "Mint tea", price: 4 },
]);
}),
];Line 1 imports http, for describing requests, and HttpResponse, for answering them. Line 4 says: for a GET request to /api/products, line 5 answers with JSON, the same two teas.
import { setupServer } from "msw/node";
import { handlers } from "./handlers.js";
export const server = setupServer(...handlers);import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterAll, afterEach, beforeAll } from "vitest";
import { server } from "./src/mocks/server.js";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());setupServer from msw/node makes a server from the handlers. The setup file starts it before all tests (line 6), resets any handlers a test added after each one (line 9), and stops it at the end (line 11). onUnhandledRequest: "error" on line 6 makes any request without a handler fail the test, so a typo in a URL can't pass quietly. Now the test needs no stub at all:
import { render, screen } from "@testing-library/react";
import { http, HttpResponse } from "msw/http";
import { test, expect } from "vitest";
import { server } from "./mocks/server.js";
import ProductList from "./ProductList.jsx";
test("shows the products from the server", async () => {
render(<ProductList />);
const items = await screen.findAllByRole("listitem");
expect(items.map((item) => item.textContent)).toEqual(["Green tea $3", "Mint tea $4"]);
});
test("says so when the server fails", async () => {
server.use(http.get("/api/products", () => new HttpResponse(null, { status: 500 })));
render(<ProductList />);
expect(await screen.findByRole("alert")).toHaveTextContent("Could not load products.");
});The first test renders the list and waits for it: the answer comes from the handler. The second test, on line 14, uses server.use to make /api/products answer with status 500 for this one test. This is what Vitest printed when it ran this file in a real project:
RUN v5.0.5 /shop
✓ src/ProductList.test.jsx > shows the products from the server 130ms
✓ src/ProductList.test.jsx > says so when the server fails 14ms
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 16:24:29
Duration 805ms (environment 54%, tests 21%, setup 16%, transform 7%, import 1%)The same handlers can run in the browser while you build the app, through MSW's service worker, so a feature can work before its server exists.
Test a custom hook through a component
A custom hook is a function, but you can't simply call it in a test. Hooks only work while React renders a component:
import { useCart } from "./useCart.js";
const cart = useCart();Line 3 runs useCart outside any component. In development, React prints "Invalid hook call. Hooks can only be called inside of the body of a function component." Then the call throws TypeError: Cannot read properties of null (reading 'useState'): outside a render, React has nothing ready to answer useState, so it reads a property of null. So you test a hook the way the app uses it: through a component. Here is the cart hook from the last lesson, and two ways to test it:
import { render, screen, userEvent, renderHook, act, test, expect } from "./testing.js";import { useCart } from "./useCart.js"; function CartProbe() { const cart = useCart(); return ( <> <p>Lines: {cart.lines.length}, total: {cart.total}</p> <button onClick={() => cart.add({ name: "Tea", price: 3 })}>Add tea</button> <button onClick={cart.clear}>Clear</button> </> );} test("adds up the lines", async () => { const user = userEvent.setup(); render(<CartProbe />); await user.click(screen.getByRole("button", { name: "Add tea" })); await user.click(screen.getByRole("button", { name: "Add tea" })); expect(screen.getByText(/Lines:/)).toHaveTextContent("Lines: 2, total: 6");}); test("renderHook: the same check without writing a component", () => { const { result } = renderHook(() => useCart()); act(() => result.current.add({ name: "Tea", price: 3 })); act(() => result.current.add({ name: "Tea", price: 3 })); expect(result.current.total).toBe(6);});Lines 4 to 13 write CartProbe, a tiny component that exists only for the test: it calls the hook and shows its values, with buttons for its functions. The first test then works like any component test: two clicks on line 18 and 19, and line 20 reads Lines: 2, total: 6 from the screen.
The second test uses renderHook, which writes that small component for you. Line 24 renders a component that calls useCart(), and result.current is always the hook's value from the latest render. Lines 25 and 26 call add inside act(), because these calls happen outside any event Testing Library knows about, and act lets React re-render before the next line reads result.current. Line 27 checks the total: 6.
Which to choose? If the hook is only used by one component, test that component, and the hook gets tested with it. Test a hook on its own when many components share it, like useDebounce in shared/hooks/, so one set of tests covers what they all rely on.
Test behavior, not implementation
Behavior is what users see and do: the text on the screen, what a click changes, the error when the server fails. Implementation details are how the code makes that happen: state names, class names, which hook holds the data. Tests that check details break when you improve the code, even when users notice nothing, and then people stop trusting them.
This experiment runs the same two tests against two versions of the cart. CartBefore.jsx keeps a count with useState in a <p className="count">. CartAfter.jsx is a refactor: it keeps a list of items with useReducer and renames the class. Open both tabs: on the screen they're the same.
import { render, screen, userEvent, test, expect } from "./testing.js";import CartBefore from "./CartBefore.jsx";import CartAfter from "./CartAfter.jsx"; for (const [version, Cart] of [["before", CartBefore], ["after", CartAfter]]) { test(`${version}: the .count element says 1`, async () => { const user = userEvent.setup(); const { container } = render(<Cart />); await user.click(screen.getByRole("button", { name: "Add tea" })); expect(container.querySelector(".count")).toHaveTextContent("Items in cart: 1"); }); test(`${version}: the cart says it holds 1 item`, async () => { const user = userEvent.setup(); render(<Cart />); await user.click(screen.getByRole("button", { name: "Add tea" })); expect(screen.getByText("Items in cart: 1")).toBeInTheDocument(); });}Line 5 runs the tests once for each version. The first test, lines 6 to 11, reaches into the markup with container.querySelector(".count") on line 10. The second, lines 13 to 18, reads the screen with getByText on line 17. The report: both pass before. After the refactor, the .count test fails with received value must be a Node, but it is null (no element has that class anymore), while the screen test still passes. Nothing a user can see changed, and one test broke anyway.
Now break the feature for real: press Edit & run, open CartAfter.jsx, and change line 4 to return items instead of [...items, action.name]. Both "after" tests fail. The screen test fails because users would see the bug; the .count test fails for no useful reason either way.
.count class fails after it; the test that reads the screen passes both times.How to read the table: each row is a kind of check, whether it's worth a test, and an example from this lesson.
| Kind of check | Worth a test? | Example |
|---|---|---|
| What the screen shows for some data | Yes. It's what users see. | "Items in cart: 2" after two clicks. |
| What happens when a user clicks or types | Yes. It's what users do. | Subscribe turns on once the email has an @. |
| Loading, empty, and error states | Yes. They're easy to forget and users hit them. | "Could not load products." when the server fails. |
| Names, labels, and roles | Yes, for free: role queries check them as they find things. | The × button is named "Remove Tea". |
| A state variable's name or value | No. Rename it and nothing a user sees changes. | count became items.length. |
| Class names, element nesting, render counts | No. They change in every refactor. | .count became .cart-summary. |
Some other things are rarely worth testing:
- React itself, or a library. You don't need to test that
useStatekeeps a value or that a router changes pages. Test your code that uses them. - Big snapshots. A snapshot test saves the whole rendered HTML and fails when any of it changes. For a large component that means a failure on every small change, and people learn to update snapshots without reading them. Keep snapshots for small, stable output, if at all.
- Plain logic, through the UI. A reducer or a
formatPricehelper is a pure function: test it directly with inputs and outputs, which is quicker and clearer, as Extracting state logic into a reducer shows.
End-to-end tests with Playwright
Component tests run fast in jsdom, but jsdom isn't a browser: it has no layout, no painting, and no real server behind it. An end-to-end test runs the whole app in a real browser, against a running server, and drives it like a user across pages. Playwright (version 1.64.1 on npm, checked in October 2026) is a common tool for it, and its locators look familiar:
import { test, expect } from "@playwright/test";
test("a shopper adds a tea and sees it in the cart", async ({ page }) => {
await page.goto("http://localhost:5173/");
await page.getByRole("button", { name: "Add tea" }).click();
await page.getByRole("link", { name: "Cart" }).click();
await expect(page.getByText("Items in cart: 1")).toBeVisible();
});Line 4 opens the app, served by Vite at localhost:5173. Lines 5 and 6 click by role and name, like Testing Library's queries, and line 7 waits for the text to be visible. The Testing the DOM & end to end lesson sets Playwright up.
End-to-end tests catch what component tests can't: a broken route, a server that changed its answer, CSS that hides a button. They're also slower and have more ways to fail for reasons outside your code. Most teams keep many component tests and a few end-to-end tests for the journeys that matter most, like signing in and checking out. Vitest also has a browser mode that runs component tests in a real browser, driven by Playwright or another provider, for when jsdom isn't close enough.
Testing a whole sign-up form
Here is everything together, on a form that talks to a server: it sends the email, turns the button off while it waits, then thanks you or shows an error. Its four tests cover what a user can meet.
import { render, screen, userEvent, test, expect, vi } from "./testing.js";import SignupForm from "./SignupForm.jsx"; async function signUp(email) { const user = userEvent.setup(); render(<SignupForm />); await user.type(screen.getByLabelText("Email"), email); await user.click(screen.getByRole("button", { name: "Sign up" }));} test("sends the email to the server", async () => { const fetchMock = vi.fn(async () => Response.json({ ok: true })); vi.stubGlobal("fetch", fetchMock); await signUp("asha@example.com"); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, options] = fetchMock.mock.calls[0]; expect(url).toBe("/api/subscribe"); expect(JSON.parse(options.body)).toEqual({ email: "asha@example.com" }); await screen.findByRole("status");}); test("thanks you when the server says yes", async () => { vi.stubGlobal("fetch", vi.fn(async () => Response.json({ ok: true }))); await signUp("asha@example.com"); expect(await screen.findByRole("status")).toHaveTextContent("Check asha@example.com");}); test("shows an error when the server fails", async () => { vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 500 }))); await signUp("asha@example.com"); expect(await screen.findByRole("alert")).toHaveTextContent("Something went wrong."); expect(screen.getByRole("button", { name: "Sign up" })).toBeEnabled();}); test("turns the button off while it sends", async () => { let answer; vi.stubGlobal("fetch", vi.fn(() => new Promise((resolve) => (answer = resolve)))); await signUp("asha@example.com"); expect(screen.getByRole("button", { name: "Sending…" })).toBeDisabled(); answer(Response.json({ ok: true })); expect(await screen.findByRole("status")).toBeInTheDocument();});Lines 4 to 9 put the steps every test shares in one function, signUp: render, type an email, click Sign up. The first test keeps the mock in fetchMock (line 12) to check the request: called once (line 15), to /api/subscribe (line 17), with the email in its JSON body (line 18). Line 19 waits for the thank-you message, so nothing changes the page after the test ends. The second and third tests check the two answers a user can see. The fourth, on line 37, makes a request that doesn't answer until line 40 calls answer, so line 39 can check the button while it says Sending….
Open SignupForm.jsx and break something: remove disabled={status === "sending"}, or send { mail: email } instead of { email }. Each time, a test names what broke.
- After two clicks on Add tea, the cart says
Items in cart: 2 - Subscribe stays off until the email has an
@ - The page says
Could not load products.when the server fails - The × button is named
Remove Tea - The state variable is called
items - The cart renders exactly twice after a click
- The count sits in an element with the class
count useEffectruns once when the list appears
Sort each check: is it something users see or do, or a detail of how the code works?
Common mix-ups
- "A good test checks the component's state." No. Users never see state. Check what the state puts on the screen.
- "fireEvent.click and user.click are the same." No.
fireEvent.clicksends one event;user.clicksends the pointer, mouse, focus, and click events a real click does. - "getBy works for anything that appears after a request." No. It looks once. Use
await findBy…for what appears later, andqueryBy…to check that something is gone. - "getByTestId is the safest query." No. It survives anything, including a button nobody can use. Prefer roles and labels; keep test ids for elements with no role or text.
- "You need act() around every click." No. Testing Library and user-event wrap their work in
act(). You write it yourself mostly withrenderHook. - "Vitest cleans up between tests by itself." Not unless you set
globals: true. Otherwise callcleanup()in anafterEachin your setup file.
Practice exercises
Each exercise is a test file with its report under it, or under Edit & run. Change the test and the report runs again as you type. Read Testing Library's messages: they usually say what to fix.
How many of these two tests pass? Decide first, then press Edit & run to see the report.
import { render, screen, test, expect } from "./testing.js";
import Cart from "./Cart.jsx";
test("finds the count by its text", () => {
render(<Cart />);
expect(screen.getByText("Items in cart: 0")).toBeInTheDocument();
});
test("finds the Empty the cart button", () => {
render(<Cart />);
expect(screen.getByRole("button", { name: "Empty the cart" })).toBeInTheDocument();
});import { render, screen, test, expect } from "./testing.js";import Cart from "./Cart.jsx"; test("finds the count by its text", () => { render(<Cart />); expect(screen.getByText("Items in cart: 0")).toBeInTheDocument();}); test("has no Empty the cart button while the cart is empty", () => { render(<Cart />); expect(screen.queryByRole("button", { name: "Empty the cart" })).not.toBeInTheDocument();});The first test passes. The second fails: Empty the cart only shows while there are items, and getByRole throws when nothing matches. The solution checks what's true instead, with queryByRole(…) and .not.toBeInTheDocument(): both pass.
The test can't find the remove button. Fix the query on line 6. What name does the test need?
import { render, screen, test, expect } from "./testing.js";import CartLine from "./CartLine.jsx"; test("has a button that removes the line", () => { render(<CartLine name="Tea" />); expect(screen.getByRole("button", { name: "×" })).toBeInTheDocument();});import { render, screen, test, expect } from "./testing.js";import CartLine from "./CartLine.jsx"; test("has a button that removes the line", () => { render(<CartLine name="Tea" />); expect(screen.getByRole("button", { name: "Remove Tea" })).toBeInTheDocument();});An aria-label replaces the button's text as its accessible name, so a screen reader says "Remove Tea", not "times". The test finds the button by that name, which also checks the label is there.
The test empties the cart, but line 10 only says what to check. Replace it with a check that Empty the cart has disappeared. Which query do you use?
import { render, screen, userEvent, test, expect } from "./testing.js";import Cart from "./Cart.jsx"; test("empties the cart", async () => { const user = userEvent.setup(); render(<Cart />); await user.click(screen.getByRole("button", { name: "Add tea" })); await user.click(screen.getByRole("button", { name: "Empty the cart" })); expect(screen.getByText("Items in cart: 0")).toBeInTheDocument(); // Check that the Empty the cart button is gone.});import { render, screen, userEvent, test, expect } from "./testing.js";import Cart from "./Cart.jsx"; test("empties the cart", async () => { const user = userEvent.setup(); render(<Cart />); await user.click(screen.getByRole("button", { name: "Add tea" })); await user.click(screen.getByRole("button", { name: "Empty the cart" })); expect(screen.getByText("Items in cart: 0")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Empty the cart" })).not.toBeInTheDocument();});queryByRole returns null when the button is gone, and .not.toBeInTheDocument() passes for null. Notice the starter passed too: a test with a missing check passes, so a passing report doesn't mean the test checks enough.
This test fails, although the list does load. Fix line 12. Which query replaces getAllByRole?
import { render, screen, test, expect, vi } from "./testing.js";import ProductList from "./ProductList.jsx"; const teas = [ { id: 1, name: "Green tea", price: 3 }, { id: 2, name: "Mint tea", price: 4 },]; test("shows two products", async () => { vi.stubGlobal("fetch", vi.fn(async () => Response.json(teas))); render(<ProductList />); const items = screen.getAllByRole("listitem"); expect(items).toHaveLength(2);});import { render, screen, test, expect, vi } from "./testing.js";import ProductList from "./ProductList.jsx"; const teas = [ { id: 1, name: "Green tea", price: 3 }, { id: 2, name: "Mint tea", price: 4 },]; test("shows two products", async () => { vi.stubGlobal("fetch", vi.fn(async () => Response.json(teas))); render(<ProductList />); const items = await screen.findAllByRole("listitem"); expect(items).toHaveLength(2);});findAllByRole keeps looking until the items appear, so the test waits for the answer instead of reading the loading message. await it: it returns a promise.
Fill in the three steps so the test checks what users see when the server fails. What text does the alert show?
import { render, screen, test, expect, vi } from "./testing.js";import ProductList from "./ProductList.jsx"; test("says so when the server fails", async () => { // 1. Make fetch answer with status 500. // 2. Render ProductList. // 3. Wait for the alert, and check its text.});import { render, screen, test, expect, vi } from "./testing.js";import ProductList from "./ProductList.jsx"; test("says so when the server fails", async () => { vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 500 }))); render(<ProductList />); expect(await screen.findByRole("alert")).toHaveTextContent("Could not load products.");});The stand-in answers with status 500, so response.ok is false, the component sets failed, and the alert appears. findByRole("alert") waits for it. The empty starter passed too: a test with no expect checks nothing.
This test broke when the cart was refactored, though the page didn't change. Rewrite line 8 (and line 6) so it checks the screen. What text does your test look for?
import { render, screen, userEvent, test, expect } from "./testing.js";import CartAfter from "./CartAfter.jsx"; test("counts a tea", async () => { const user = userEvent.setup(); const { container } = render(<CartAfter />); await user.click(screen.getByRole("button", { name: "Add tea" })); expect(container.querySelector(".count")).toHaveTextContent("Items in cart: 1");});import { render, screen, userEvent, test, expect } from "./testing.js";import CartAfter from "./CartAfter.jsx"; test("counts a tea", async () => { const user = userEvent.setup(); render(<CartAfter />); await user.click(screen.getByRole("button", { name: "Add tea" })); expect(screen.getByText("Items in cart: 1")).toBeInTheDocument();});The new test reads what a shopper reads, so it passes for both versions of the cart and only fails when the count shown is wrong.
Check your understanding
For each question, ask what a user would see or do, and what the test can see.
Question 1 of 7Why does Testing Library suggest
getByRolebeforegetByTestId?Choose an answer to see the explanation.
Question 2 of 7Which query do you use to check that the Empty the cart button is no longer on the page?
Choose an answer to see the explanation.
Question 3 of 7In the event-log example, what does
fireEvent.clicksend to the button?Choose an answer to see the explanation.
Question 4 of 7This test runs
useCartwithrenderHook. What does it print?Read the code, then predictJavaScriptconst { result } = renderHook(() => useCart()); act(() => { result.current.add({ name: "Tea", price: 3 }); result.current.add({ name: "Cake", price: 4 }); }); console.log(result.current.lines.length);Choose an answer to see the explanation.
Question 5 of 7A product list loads its data in an effect. What should its test fake?
Choose an answer to see the explanation.
Question 6 of 7Which of these is an implementation detail a test shouldn't check?
Choose an answer to see the explanation.
Question 7 of 7When is an end-to-end test with Playwright the right tool?
Choose an answer to see the explanation.
Key takeaways
- A component test renders the component, acts like a user, and checks the screen: arrange, act, assert.
- Vitest runs the tests, jsdom gives them a page, React Testing Library renders and finds, user-event clicks and types, and jest-dom adds page matchers. Call
cleanup()after each test unless Vitest's globals are on. - Find elements by role and name first, then by label and text. Use
getByfor what's there,queryByfor what's gone, andawait findByfor what appears later. - Fake the network, not your component: a
fetchstub for a quick test, MSW to fake the server belowfetch. - Test a hook through a component, or with
renderHookwhen many components share it. - Test behavior, not implementation details. Add a few end-to-end tests with Playwright for the journeys that matter most.
Remember the one-liner.
Test what users see and do, the way they find it.
Coming next: React with TypeScript. It types props, state, events, refs, and context in React, building on the TypeScript track.