Testing components

React · Practical · 15 min

Tests that exercise behaviour rather than implementation let you refactor and upgrade React with confidence.

The standard stack for a Vite app is Vitest (test runner, Jest-compatible API), React Testing Library (RTL) and user-event, running in a simulated DOM (jsdom or happy-dom). Install them and set test.environment: "jsdom" in vite.config.

RTL's philosophy: test what the user sees and does, not internals. A test typically:

  • renders the component: render(<Counter />);
  • queries the DOM the way a user or screen reader would, preferring screen.getByRole("button", { name: /save/i }), then getByLabelText, getByText; test ids are a last resort;
  • interacts with userEvent: const user = userEvent.setup(); await user.click(button); await user.type(input, "hi");
  • asserts on the output, often with @testing-library/jest-dom matchers like toBeInTheDocument() or toHaveValue().

Query variants matter: getBy... throws if nothing matches, queryBy... returns null (for asserting absence), and findBy... returns a promise that waits, which is what you need for anything async, like data that appears after a fetch.

Mock at the network boundary rather than mocking your own Hooks: MSW (Mock Service Worker) intercepts fetch and returns fixtures, so the component's real data-loading code runs. Wrap components that need context or a router in the same providers they use in the app (a custom renderWithProviders helper).

Avoid asserting on state values, Hook calls or component instances; those are implementation details that break on refactors. For pure logic (reducers, formatters) plain unit tests are simplest. End-to-end tests with Playwright cover whole flows in a real browser.

Example

// Counter.test.jsx  (run with: npx vitest)
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom/vitest";
import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
    </>
  );
}

describe("Counter", () => {
  it("increments when the button is clicked", async () => {
    const user = userEvent.setup();
    render(<Counter />);

    await user.click(screen.getByRole("button", { name: /increment/i }));
    await user.click(screen.getByRole("button", { name: /increment/i }));

    expect(screen.getByText("Count: 2")).toBeInTheDocument();
    expect(screen.queryByText("Count: 0")).not.toBeInTheDocument();
  });
});

Remember

  • Render, query by role/label, interact with user-event, assert on what the user sees
  • getBy throws, queryBy returns null, findBy waits for async UI
  • Mock the network (MSW), not your components' internals

Watch out for

  • Using getBy... right after triggering an async load; use await findBy... so the test waits.

Check yourself

  1. Which query should you use to wait for a heading that appears after data loads?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: testing-library.com/docs/react-testing-library/intro/