Plan which behaviour to cover with unit, integration and end-to-end tests.
Why it matters
Tests are how a team changes code quickly without fear. But a test suite can also slow you down: flaky end-to-end tests that fail at random, snapshot files nobody reads, unit tests that break every time you rename a variable. A mid-level developer is expected to decide what to test and at which level, not just how to write a test.
The levels
Static checks catch whole classes of bugs before anything runs: TypeScript, ESLint (including accessibility rules such as eslint-plugin-jsx-a11y) and formatting. They are the cheapest tests you have.
Unit tests check one function or small module in isolation, in milliseconds. Use them for pure logic with many cases: price formatting, validators, reducers, date maths.
import { describe, it, expect } from "vitest";
import { formatPrice } from "./formatPrice";
describe("formatPrice", () => {
it("formats cents as dollars", () => {
expect(formatPrice(1999, "USD")).toBe("$19.99");
});
it("shows free for zero", () => {
expect(formatPrice(0, "USD")).toBe("Free");
});
});
Integration (component) tests render a component, or a few working together, in a simulated DOM (jsdom) and interact with it the way a user would. With React Testing Library you find elements by role and accessible name, which doubles as an accessibility check:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
it("shows an error when the email is invalid", async () => {
render(<SignupForm onSubmit={vi.fn()} />);
await userEvent.type(screen.getByLabelText(/email/i), "not-an-email");
await userEvent.click(screen.getByRole("button", { name: /sign up/i }));
expect(screen.getByRole("alert")).toHaveTextContent(/valid email/i);
});
Network calls are mocked at the boundary (for example with MSW, Mock Service Worker), so the test exercises your real components, hooks and state together.
End-to-end (e2e) tests drive a real browser against a running app, usually with Playwright or Cypress. They prove that the whole system works: routing, cookies, the real API, the build. They are the slowest and most fragile, so keep them for critical journeys: sign up, log in, checkout, complete a lesson.
import { test, expect } from "@playwright/test";
test("learner can complete a lesson", async ({ page }) => {
await page.goto("/courses/react-fundamentals");
await page.getByRole("link", { name: "Components and props" }).click();
await page.getByRole("button", { name: "Mark complete" }).click();
await expect(page.getByText("Lesson complete")).toBeVisible();
});
Shape of a healthy suite
You may have seen the testing pyramid (many unit tests, fewer integration, few e2e) and the testing trophy (static checks at the base, with integration tests as the largest layer). For UI code the trophy is a good default: component-level integration tests give the most confidence per minute, because they test what the user sees without the cost of a full browser.
A practical plan for a feature:
- Types and lint on everything (free once set up).
- Unit tests for tricky pure logic.
- Integration tests for each component's main behaviours: renders the data, handles empty, loading and error, responds to keyboard and clicks.
- One or two e2e tests for the journey the business cares about.
- Optional: automated accessibility checks (axe in component or e2e tests) and visual regression screenshots for the design system.
What makes a good test
- Tests behaviour, not implementation. Assert on what the user sees and can do, not on internal state or which hook was called. Then refactors do not break tests.
- Finds elements like a user: by role, label and text, not by CSS class or test id unless there is no better option.
- Is deterministic. Control time, random values and network. Wait for conditions (
await expect(...).toBeVisible()), never for fixed sleeps. - Is independent. Each test sets up its own data and can run alone or in any order.
- Fails for one reason, with a name that describes the behaviour: "shows an error when the email is invalid".
Worked example: planning tests for a search box
A course search box debounces input, calls /api/courses?q=, shows results, an empty message, and an error state.
- Unit: the
buildSearchUrl(q, filters)helper, including encoding and empty filters. - Integration: typing shows results from a mocked API; no results shows "No courses match"; a 500 shows an alert; the results list is announced (an
aria-liveregion); arrow keys move through suggestions. - E2e: none just for this; it is covered by the existing "find and start a course" journey.
Common mistakes
- Testing implementation details, such as a component's internal state, so every refactor breaks tests.
- Too many e2e tests, making CI slow and flaky.
- Large snapshot tests that people update without reading.
- Fixed waits (
waitForTimeout(2000)) that are slow on fast machines and flaky on slow ones. - Mocking everything, so the test only proves the mocks work.
- Chasing 100 percent coverage instead of covering the risky behaviour.
Quick recap
- Static checks, unit, integration and e2e tests each catch different bugs at different costs.
- For UI, put most effort into integration tests that use roles and labels; keep e2e for critical journeys.
- Test behaviour, keep tests deterministic and independent, and wait for conditions rather than time.
Try it
Open the Do tab and decide where several tests belong.