← back to section

Frontend tests often break not because something broke in the application, but because the test was written about the wrong thing. A test tied to CSS class names or tag order goes red with every markup refactor — even when the user-facing behavior has not changed at all.

The core principle: test what the user sees, not how it is built inside.

Tools: what and why

Two tools cover most needs:

  • Vitest — the test runner. Fast, built into the Vite ecosystem, API-compatible with Jest. Runs .test.ts files, supports mocks and snapshots.
  • Testing Library (@testing-library/react) — helps test components the way users interact with them: renders a component and lets you access elements by role, text, and label.

Installed together:

npm install -D vitest @testing-library/react @testing-library/user-event jsdom

Add to vite.config.ts:

test: {
  environment: "jsdom",
  globals: true,
}

A first component test

Say there is a product card with an "Open" button:

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

test("displays the product and responds to a click", async () => {
  const onOpen = vi.fn();
  render(<ProductCard id="1" name="Coffee grinder" price={4990} onOpen={onOpen} />);

  expect(screen.getByText("Coffee grinder")).toBeInTheDocument();
  await userEvent.click(screen.getByRole("button", { name: "Open" }));
  expect(onOpen).toHaveBeenCalledWith("1");
});

What is happening: render renders the component into a virtual DOM, screen provides access to elements, userEvent simulates real interactions (click, text input). vi.fn() creates a mock function to assert it was called with the right arguments.

Find elements by role, not by class

Testing Library deliberately makes it awkward to query by class names or IDs. The recommended approach is getByRole, getByLabelText, getByText.

// fragile: the test breaks when the class is renamed
container.querySelector(".btn-primary");

// resilient: the test describes what the user sees
screen.getByRole("button", { name: "Save" });

Querying by role is the accessibility standard (ARIA). Elements have roles: a button is button, a link is link, a text input is textbox, a heading is heading. If the element you need cannot be found by role — that is often a signal that the markup is not accessible enough.

Query priority hierarchy:

  1. getByRole — always first
  2. getByLabelText — for form fields
  3. getByPlaceholderText — when there is no label
  4. getByText — for static text
  5. getByTestId — last resort when nothing else fits

Test behavior, not implementation

The distinction is fundamental:

Behavior (test this)Implementation (don't test this)
Component displayed the text from propsContents of useState
Form showed an error for an empty fieldCSS class names
Button invoked the callbackOrder of hook calls
List rendered all passed itemsComponent tree structure

A sign of a good test — it does not change during a refactor when the user-facing behavior stays the same.

Mocking server requests

Components that load data cannot be tested against a real server — the test becomes slow and flaky. Requests are replaced with mocks.

The simplest approach — mock the fetch function directly:

vi.mock("../api/products", () => ({
  fetchProducts: vi.fn().mockResolvedValue([
    { id: "1", name: "Coffee grinder" },
  ]),
}));

For more complex cases, use MSW (Mock Service Worker) — it intercepts fetch requests at the network level and returns prepared responses. This lets you test a component as if the server were actually responding:

const server = setupServer(
  http.get("/api/products", () => {
    return HttpResponse.json([{ id: "1", name: "Coffee grinder" }]);
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Test loading and error states

A component is rarely in just one state. A good test suite covers:

  • initial render (skeleton or spinner);
  • successful response (data is displayed);
  • error (error message is shown).
test("shows an error if the load failed", async () => {
  server.use(
    http.get("/api/products", () => HttpResponse.error())
  );

  render(<ProductList />);
  expect(await screen.findByText("Failed to load data")).toBeInTheDocument();
});

findBy* (unlike getBy*) returns a promise and waits for the element to appear — that is what you need for async operations.

The frontend test pyramid

The same levels as in backend, adapted for UI:

At the bottom, many — unit tests for pure logic. Hooks with business logic, validation functions, formatting utilities. No component rendering required, run instantly.

In the middle, the bulk — component tests. A component or screen with mocked data. Checks behavior: rendered the right thing, responded to an action, showed an error. This is what Testing Library handles.

At the top, few — end-to-end tests in a real browser. Playwright or Cypress. Slow, require the application to be running. Cover critical user flows end-to-end. This is its own specialization — end-to-end tests have their own tools and approaches.

Don't run logic through a slow browser test if a unit test can check it. Don't duplicate a full user flow in every component test.

In short

  • Vitest is the runner, Testing Library is the tool for testing components from the user's perspective.
  • Find elements by role (getByRole) and text, not by class — the test survives a markup refactor.
  • Test behavior (what is shown, what was called), not implementation (state, class names, hook order).
  • Replace server requests with function mocks or MSW — the test does not depend on the network.
  • Cover three states: loading, success, error.
  • Pyramid: logic units → component tests → a minimum of end-to-end tests.
  • Accessibility — role-based queries and accessibility are directly connected.
  • Forms — how to test validation and form submission.
  • State management — how to test components that depend on global state.