Testing frontend code
A frontend test suite earns its keep when it lets you refactor without fear and catches the bugs users would notice. Suites that test implementation details do the opposite: they break on every refactor and still miss real bugs.
Choose the level for each test
| Level | Tool examples | Good for |
|---|---|---|
| Static | TypeScript, ESLint | Typos, wrong props, unused code |
| Unit | Vitest, Jest | Pure logic: formatting, reducers, validation |
| Component | Testing Library | A component or feature as a user drives it |
| End-to-end | Playwright, Cypress | Critical journeys through the real app |
Put most effort into component-level tests that render a feature, interact with it and check the result. They are fast and exercise how components fit together, where many bugs live. Reserve unit tests for logic with many cases, and end-to-end tests for the handful of flows that must never break.
Test behaviour through the DOM
The Testing Library principle is that the more your tests resemble the way your software is used, the more confidence they give you. Users see buttons, labels and text, not component state, so query the DOM the way they and assistive technology find things:
getByRolewith an accessible name, such asgetByRole("button", { name: "Save" });getByLabelTextfor form fields;getByTextfor non-interactive content;getByTestIdonly when nothing else works.
A side effect: if a test cannot find a button by role and name, screen reader users probably cannot either.
Simulate real interactions with user-event, which types and clicks the way a browser does (focus, key events, input events), rather than dispatching single synthetic events. For anything that appears asynchronously, use findBy queries, which retry until the element appears or a timeout passes.
Mock at the network boundary
Mocking your own modules (the API client, a hook) couples tests to internal structure. Instead, let your real code run and intercept at the network. Mock Service Worker (MSW) does this: it intercepts requests in the browser with a service worker and in Node at the request level, so your code sees ordinary responses.
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
const server = setupServer(
http.get("/api/invoices", () =>
HttpResponse.json([{ id: 1, total: 120 }]),
),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test("shows invoices", async () => {
render(<Invoices />);
expect(await screen.findByText("£120.00")).toBeVisible();
});
test("shows an error when the API fails", async () => {
server.use(
http.get("/api/invoices", () =>
new HttpResponse(null, { status: 500 }),
),
);
render(<Invoices />);
expect(await screen.findByRole("alert")).toBeVisible();
});
Test the unhappy paths too: errors, empty lists and slow responses are where bugs hide.
Visual regression and accessibility checks
Functional tests don’t notice that a button turned invisible or a layout collapsed. Visual regression testing takes screenshots of components or pages and compares them with approved baselines. Keep it reliable by running in a fixed environment (same browser build, fonts and operating system, often a container), disabling animations and freezing dates and random data. Review diffs as part of code review.
Add automated accessibility checks with axe, either in component tests or in end-to-end runs. They reliably catch missing labels, bad contrast and invalid ARIA. They do not prove a page is accessible; keyboard and screen reader checks are still manual work.
Keep end-to-end tests few and stable
End-to-end tests are the slowest and most fragile, so choose them carefully: sign-in, checkout, the core task your product exists for. Keep them stable:
- give each test its own data, created through an API or seed script, not by clicking through setup screens;
- rely on the tool’s auto-waiting and assertions instead of fixed sleeps;
- quarantine and fix flaky tests quickly, because a suite that fails randomly teaches people to ignore it.
Habits
- Query by role and label; avoid test ids and CSS selectors.
- Mock the network, not your own modules.
- Cover error, empty and loading states, not only success.
- Keep a small number of end-to-end tests, and keep them green.