Topic 17.1
What and Why to Test
In one line
Tests are small programs that use your app and check the result; good React tests check behaviour a user can see, not how the component is built inside, so they catch real bugs and survive refactors.
Think of it like this
Before a school bus leaves each morning, the driver walks around it and checks the lights, the brakes and the doors. The driver doesn't open the engine and count the bolts. They check what the bus does, because that is what keeps the children safe. Good React tests work the same way: they press the buttons and read the screen, like a user, instead of peeking at the engine.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Test
- A small program that runs part of your app and checks the result is what you expect.
- Test runner
- The tool that finds test files, runs them and prints which passed and failed. Vitest and Jest are test runners.
- Regression
- A bug where something that used to work stops working after a change.
- Unit test
- A test of one small piece on its own, like a single function.
- Integration test
- A test of several pieces working together, like a component with its children, state and helpers.
- End-to-end (E2E) test
- A test that drives the whole real app in a real browser, the way a user would.
- Implementation detail
- How code is built inside (state names, hooks, class names), as opposed to what it does.
- Refactor
- Changing how code is written inside without changing what it does.
- False alarm
- A test that fails even though the app works. It wastes time and erodes trust.
- Coverage
- The percentage of your code's lines that ran during the tests. It doesn't say the checks were good.
Step by step
01Every test has three steps: arrange, act, assert
Arrange: put the world in the starting state (render the component with some props). Act: do what a user does (click, type). Assert: check the result (the text on the screen). Almost every test you'll write in this phase follows these three steps, often marked with blank lines.
test("clicking adds a like", async () => {
// Arrange
const user = userEvent.setup();
render(<LikeButton />);
// Act
await user.click(screen.getByRole("button", { name: /likes/i }));
// Assert
expect(screen.getByRole("button")).toHaveTextContent("Likes: 1");
});02Pick the layer: the testing trophy
Static checks catch typos for free. Unit tests cover tricky pure logic (prices, dates, reducers). Integration tests cover most of your UI: one screen or feature at a time, with real child components. A few E2E tests cover the journeys that make money or must never break: sign up, log in, pay.
03Ask: would a user notice?
Before writing a check, ask whether a user would notice if it changed. The count on the button: yes. The name of the state variable: no. The class name: no (unless it changes how it looks, which a visual test covers better, Topic 17.7). If a user wouldn't notice, don't assert on it.
04Refactor and watch which tests survive
In the first live example the same LikeButton exists twice: once with useState, once refactored to useReducer with a different class name. The behaviour test passes for both. The implementation test fails for the refactor, even though the button works perfectly. That failing test is pure cost.
05Fix a bug with a failing test first
When a bug is reported, first write a test that reproduces it and watch it fail (red). Then fix the code and watch it pass (green). The test stays in the project forever and guards against the bug coming back. This red-then-green habit is the heart of test-driven development (TDD), which you can use for every feature, not only bugs.
Try it yourself
- 1
Make the implementation test pass, then ask if it helped
In the first example, change
className="like-btn"in v2 back to"count". Predict the output, then run. All four tests pass, but did that test ever protect a user? Change the button text toHearts: {likes}in v2 instead: which tests fail now, and is that a real problem a user would notice? - 2
Plant a real bug
In the Cart example, change the filter to
items.filter((i) => i.id === item.id)(keep instead of remove). Predict which test fails and what message it prints, then run. - 3
Add a unit test
Add
await test("formatPrice handles one paisa", () => { expect(formatPrice(1)).toBe("₹0.01"); });. Predict pass or fail first.
Code & diagrams
Both versions behave the same, yet the class-name test fails for v2: a false alarm. The last rendered button stays on the page; click it to see it works.
Preview
Console
Nothing logged yet.Expected console output
✓ v1 clicking adds a like
✓ v1 has the .count class
✓ v2 clicking adds a like
✗ v2 has the .count class: expected true, got falsePrices are kept in paise (whole numbers) so there are no floating-point surprises. The unit tests pin down the formatter; the integration test proves the cart, its state and the formatter work together.
Preview
Console
Nothing logged yet.Expected console output
✓ formatPrice shows rupees with two decimals
✓ formatPrice handles zero
✓ removing an item updates the total// src/LikeButton.test.jsx: run with "npm test" (Topic 17.2)
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, test, expect } from "vitest";
import LikeButton from "./LikeButton";
describe("LikeButton", () => {
test("starts at zero", () => {
render(<LikeButton />);
expect(screen.getByRole("button")).toHaveTextContent("Likes: 0");
});
test("clicking adds a like", async () => {
const user = userEvent.setup();
render(<LikeButton />);
await user.click(screen.getByRole("button", { name: /likes/i }));
expect(screen.getByRole("button")).toHaveTextContent("Likes: 1");
});
});Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Assert on how it's built instead of what it does
In the first example, refactor v2 (rename the class) and keep the .count test.
Break #2
A test with no assertion always passes
In the Cart example, delete the three expect(...) lines from the integration test and plant the bug from "Plant a real bug".
Myth vs fact
Myth
100% code coverage means the app is fully tested.
Fact
Coverage only says which lines ran. A test with no assertions gives coverage and no protection. Aim for confidence in important behaviour, and use coverage to find untested areas, not as a score.
Myth
Unit tests are the best tests, so test each component alone with all children mocked.
Fact
Mocking every child tests your mocks. For React, integration tests (real children, fake network) catch far more real bugs per test. Keep unit tests for tricky pure logic.
Myth
Tests slow a team down.
Fact
Writing them takes time; but without them every change needs manual clicking, and bugs reach users. Good tests make refactoring and reviewing faster. Brittle implementation tests are what slow teams down.
Myth
You should test that state changed.
Fact
Users can't see state. Test what the state change produces on screen. If state change has no visible effect, ask why it's state at all.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Confidence per minute is the real metric. An integration test of a whole form often replaces ten unit tests of its pieces, runs in tens of milliseconds in jsdom, and never breaks on a refactor. Spend unit tests on code with many branches and no UI: reducers, parsers, money and date maths.
- ▸
"Implementation details" includes more than state: the component's name (snapshot tests print it), the exact element structure,
data-testidoveruse, and mocked child components. Each ties the test to today's code shape. React's own team moved from Enzyme-style shallow rendering to Testing Library for this reason;react-test-rendereris deprecated in React 19. - ▸
Snapshot tests (saving rendered output and diffing it later) look cheap but tend to become noise: large snapshots get approved without reading. If you use them, keep them tiny and focused (an inline snapshot of one formatted string), never whole component trees.
- ▸
Flaky tests (pass sometimes, fail sometimes) are worse than none: they teach people to rerun until green. The usual causes are real timers, shared state between tests, unawaited async work and network calls. Topic 17.5 shows the fixes:
findBy, fake timers and MSW.
Remember this
- 1
A test is a small piece of code that runs part of your app and checks that the result is what you expect. If the check fails, the test fails and tells you what was different. A test runner (such as Vitest, Topic 17.2) finds your test files, runs them and prints a report. Tests let you change code without fear: you change something, run the tests, and know in seconds whether you broke anything. This safety net is called regression testing (a regression is something that used to work and now doesn't).
- 2
There are several kinds of tests. A static check isn't a test you write: it is a tool reading your code without running it, like TypeScript (Phase 12) and ESLint, catching typos and wrong types. A unit test checks one small piece alone, such as a pure function
formatPrice(12950). An integration test checks several pieces working together, such as aCartcomponent with its buttons, state and helper functions. An end-to-end (E2E) test drives the whole real app in a real browser, from login to checkout (Topic 17.7). - 3
Kent C. Dodds, who created Testing Library, drew this as the testing trophy: a thin base of static checks, a small layer of unit tests, a big middle of integration tests, and a small top of E2E tests. Higher layers give more confidence that the app really works, but they are slower and harder to debug. For React apps the sweet spot is the integration test: render a component with its real children, act like a user, check the screen. The trophy replaces the older testing pyramid, which put most effort into unit tests.
- 4
The most important rule: test behaviour, not implementation. Behaviour is what the user can see and do: the text, the buttons, what happens when they click. Implementation details are how you built it: state variable names, which hook you used, class names, the number of child components. A test that checks implementation details breaks when you refactor (change the inside without changing what it does), even though nothing is wrong. That is a false alarm, and after a few of them people stop trusting the tests. The live example below shows exactly this.
- 5
Testing Library's guiding principle says it in one line: "The more your tests resemble the way your software is used, the more confidence they can give you." Your software has two users: the person clicking in the browser, and the developer who renders your component with props. Test for those two. Never test for a third, imaginary user who reads your component's state.
- 6
Knowing what not to test matters as much. Don't test React itself (that
useStatestores a value), third-party libraries, or the browser. Don't test exact styles and markup that a designer changes weekly. Don't chase 100% coverage (the percentage of lines your tests ran): coverage says which lines ran, not whether the checks were meaningful. Do test what would hurt users if it broke: forms and validation, money and dates, permissions, error states, and every bug you fix (write a test that fails first, then fix it, so the bug can't come back).
Explain it without notes
What is the difference between a unit, an integration and an end-to-end test? Give a React example of each.
What is the testing trophy and why does it put integration tests in the middle?
What does "test behaviour, not implementation details" mean, and why does it matter?
What should you not test in a React app?
Practice
Rewrite this implementation-detail test as a behaviour test: expect(container.querySelector(".badge").dataset.count).toBe("3") for a component that shows 3 new messages.
Write a unit test (with the live helper) for function initials(name) that returns "AK" for "Asha Kumar".
Trade-offs
- ↔
Integration tests catch wiring bugs and survive refactors, but when one fails you may need a minute to find which piece broke. Unit tests point straight at the broken function but miss bugs between pieces.
- ↔
E2E tests give the most confidence but need a running app, a browser and test data, and are slower and more prone to flakiness. Keep them for the few journeys that must never break.
Done when you can
I can explain static, unit, integration and E2E tests and the testing trophy.
I can write a test in arrange, act, assert steps.
I can tell a behaviour assertion from an implementation-detail assertion.
I know what not to test and why coverage isn't the goal.
I write a failing test before fixing a bug.