Testing React
Tests are how you keep a React app working as it grows. The goal is not to chase a coverage number but to gain confidence that real users can complete real tasks. The modern React testing stack—Vitest or Jest as the runner, React Testing Library for components, and Playwright for end-to-end flows—lets you verify behavior rather than implementation details, so your tests survive refactors. This page frames what to test, which tools to reach for, and how to set everything up.
The frontend testing pyramid
The classic testing pyramid suggests many fast unit tests, fewer integration tests, and a handful of slow end-to-end tests. For React UIs, a more useful shape is the “testing trophy”: a thin layer of static checks and unit tests, a wide middle of integration/component tests, and a thin top of E2E. Component tests give the best return because they exercise real rendering and user interaction while staying fast.
| Layer | Scope | Tools | Speed | Use for |
|---|---|---|---|---|
| Static | Types, lint | TypeScript, ESLint | Instant | Catch typos, contract errors |
| Unit | Pure functions, hooks | Vitest / Jest | Very fast | Reducers, utils, custom hooks |
| Component | One component + its DOM | RTL + Vitest/Jest | Fast | Most of your tests |
| End-to-end | Whole app in a browser | Playwright / Cypress | Slow | Critical user journeys |
Test behavior, not implementation
The guiding principle from React Testing Library is: the more your tests resemble the way your software is used, the more confidence they give you. Query the DOM the way a user perceives it—by role, label, and text—not by class names or internal state. Avoid asserting on component internals like useState values; assert on what the user sees.
// Counter.jsx
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
</div>
);
}
// Counter.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test } from "vitest";
import { Counter } from "./Counter";
test("increments the count when the button is clicked", async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText("Count: 0")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /increment/i }));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});
Notice the test never inspects state directly. If you refactor Counter to use useReducer, the test still passes—because the observable behavior is unchanged.
Choosing your tools
- Vitest is the default for Vite projects: it shares your Vite config and transforms, starts fast, and offers a Jest-compatible API (
describe,test,expect,vi.fn()). - Jest remains common in Create React App, Next.js, and legacy setups. The assertions and mocking concepts map almost one-to-one onto Vitest.
- React Testing Library (RTL) renders components and exposes user-centric queries. It is runner-agnostic—pair it with either Vitest or Jest.
- Playwright drives a real browser to test the fully integrated app, including routing, network, and authentication.
Tip: Don’t try to E2E-test everything. Each Playwright test is slow and brittle compared with a component test. Reserve E2E for the few flows that would cost you money or trust if they broke—login, checkout, signup.
Setting up Vitest with React Testing Library
Install the runner, RTL, the jest-dom matchers, and a DOM environment.
npm install -D vitest @testing-library/react @testing-library/user-event \
@testing-library/jest-dom jsdom
Configure Vitest in vite.config.js to use the jsdom environment and load a setup file.
// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: "./src/test/setup.js",
},
});
The setup file registers the extra DOM matchers (like toBeInTheDocument) for every test.
// src/test/setup.js
import "@testing-library/jest-dom/vitest";
Add a script so you can run tests in watch mode locally and once in CI.
{
"scripts": {
"test": "vitest",
"test:ci": "vitest run --coverage"
}
}
Running the suite prints a concise report.
Output:
✓ src/Counter.test.jsx (1 test) 24ms
✓ increments the count when the button is clicked
Test Files 1 passed (1)
Tests 1 passed (1)
Duration 412ms
What to actually test
Focus on the seams where bugs hide: conditional rendering, form validation, list rendering with keys, async data states (loading/error/success), and accessibility roles. Custom hooks deserve their own unit tests via renderHook. Skip trivial assertions—testing that a static heading renders its literal text adds noise without confidence.
Best practices
- Query by accessible role, label, or text; treat
data-testidas a last resort. - Prefer
userEventoverfireEventso interactions mirror real keyboard and pointer behavior. - Use
findBy*queries (not arbitrary timeouts) to await async UI updates. - Keep each test independent—no shared mutable state between tests; reset mocks in
afterEach. - Test the public behavior of a component, so refactors don’t force test rewrites.
- Run static checks (TypeScript + ESLint) in CI alongside tests—they catch a whole class of errors for free.
- Add E2E coverage only for business-critical journeys, and keep that suite small and reliable.