Skip to content
React rc testing 4 min read

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.

LayerScopeToolsSpeedUse for
StaticTypes, lintTypeScript, ESLintInstantCatch typos, contract errors
UnitPure functions, hooksVitest / JestVery fastReducers, utils, custom hooks
ComponentOne component + its DOMRTL + Vitest/JestFastMost of your tests
End-to-endWhole app in a browserPlaywright / CypressSlowCritical 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-testid as a last resort.
  • Prefer userEvent over fireEvent so 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.
Last updated June 14, 2026
Was this helpful?