Files
everything-claude-code/rules/react/testing.md
T
AlexisLeDain 1f0486b8d1 feat(rules): add rules/react/ track
Five rule files mirroring per-language convention (coding-style,
hooks, patterns, security, testing). Each has `paths:` glob
frontmatter for auto-activation when editing matching files.

- coding-style.md: file extensions, naming, JSX, RSC boundary
- hooks.md: React hooks (NOT Claude Code hooks) — rules-of-hooks,
  dep arrays, cleanup, memoization, React 19 additions
- patterns.md: container/presentational split, state location
  decision tree, Suspense + error boundaries, forms, data fetching
- security.md: dangerouslySetInnerHTML, unsafe URL schemes,
  server-action validation, env-var leaks, CSP
- testing.md: RTL queries, userEvent, async, MSW, axe, anti-patterns

Each file extends typescript/* and common/* rules.
2026-05-20 09:52:24 +02:00

6.9 KiB

paths
paths
**/*.test.tsx
**/*.test.jsx
**/*.spec.tsx
**/*.spec.jsx
**/__tests__/**/*.ts
**/__tests__/**/*.tsx

React Testing

This file extends typescript/testing.md and common/testing.md with React specific content.

Library Choice

  • React Testing Library (RTL) — the standard for component testing. Tests behavior through the rendered DOM.
  • Vitest — preferred runner for new Vite-based projects. Faster than Jest, native ESM, same API.
  • Jest — still the default for Next.js / CRA projects. RTL works identically.
  • Playwright Component Testing — when component tests need a real browser engine (animation, layout, complex events)
  • Cypress Component Testing — alternative real-browser component runner

Pick one component test runner per project — do not mix RTL + Playwright CT in the same repo.

Core Principle

Test what the user sees and does, not implementation details.

  • Query by accessible role first, then label, then text — fall back to data-testid only when nothing else fits
  • Never assert on internal state, props passed to children, or which hooks were called
  • Refactor without breaking tests = the test was testing behavior; that is the goal

Query Priority

RTL exposes queries in three families. Use this priority order top-down:

  1. Accessible to everyone

    • getByRole(role, { name }) — primary choice
    • getByLabelText — for form inputs
    • getByPlaceholderText — when no label is available (and add a label)
    • getByText — for non-interactive text
    • getByDisplayValue — for form fields with a current value
  2. Semantic queries

    • getByAltText — for images
    • getByTitle — last resort, low accessibility value
  3. Test IDs

    • getByTestId("some-id") — escape hatch only, when none of the above work

getBy* throws when no match. queryBy* returns null (use for asserting absence). findBy* returns a promise (use for async).

User Interaction

Prefer userEvent over fireEvent. userEvent simulates real browser sequences (focus, keydown, beforeinput, input, keyup) — fireEvent dispatches a single synthetic event.

import userEvent from "@testing-library/user-event";

test("submits the form", async () => {
  const user = userEvent.setup();
  render(<UserForm onSubmit={handleSubmit} />);

  await user.type(screen.getByLabelText("Email"), "user@example.com");
  await user.click(screen.getByRole("button", { name: /save/i }));

  expect(handleSubmit).toHaveBeenCalledWith({ email: "user@example.com" });
});
  • Always await userEvent calls — they are async
  • Call userEvent.setup() once at the top of each test, then reuse the returned user

Async Assertions

// WRONG: synchronous query for async-rendered content
expect(screen.getByText("Loaded")).toBeInTheDocument();   // throws — not in DOM yet

// CORRECT: findBy* (returns a promise, retries)
expect(await screen.findByText("Loaded")).toBeInTheDocument();

// CORRECT: waitFor for non-element assertions
await waitFor(() => expect(saveSpy).toHaveBeenCalled());
  • findBy* for async element appearance
  • waitFor for async expectations on side effects or other matchers
  • Never setTimeout + assertion — flaky

Network Mocking with MSW

Use Mock Service Worker for any test that hits a network boundary. MSW runs at the network layer, so the component, hooks, and fetch library all behave as in production.

// test setup
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";

const server = setupServer(
  http.get("/api/users/:id", ({ params }) =>
    HttpResponse.json({ id: params.id, name: "Alice" }),
  ),
);

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

Per-test override:

test("renders error on 500", async () => {
  server.use(http.get("/api/users/:id", () => new HttpResponse(null, { status: 500 })));
  render(<UserPage id="1" />);
  expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
});

Avoid Snapshot Tests for Components

Snapshots of rendered output are brittle, hard to review, and rubber-stamped by reviewers. Use them only for:

  • Pure data serialization (e.g., a transformer that produces a stable string)
  • Catching unintended regressions in non-visual output

For component visual regression, use Playwright / Cypress / Percy screenshots — actual visual diffs, not DOM diffs.

Test Setup Helpers

Wrap providers once:

function renderWithProviders(ui: React.ReactElement) {
  return render(
    <QueryClientProvider client={new QueryClient()}>
      <ThemeProvider theme={lightTheme}>
        <Router>{ui}</Router>
      </ThemeProvider>
    </QueryClientProvider>,
  );
}

Export from test-utils.tsx and use everywhere.

Custom Hook Testing

Use renderHook from RTL:

import { renderHook, act } from "@testing-library/react";

test("useCounter increments", () => {
  const { result } = renderHook(() => useCounter());
  act(() => result.current.increment());
  expect(result.current.count).toBe(1);
});
  • Always wrap state-changing calls in act
  • Always test through the public hook API, not internal implementation

Accessibility Assertions

import { axe } from "vitest-axe";   // or jest-axe

test("UserCard has no a11y violations", async () => {
  const { container } = render(<UserCard user={mockUser} />);
  expect(await axe(container)).toHaveNoViolations();
});

Run axe assertions in component tests — catches missing labels, ARIA misuse, color contrast (limited).

When to Reach for Playwright / Cypress

Component test with RTL + JSDOM cannot:

  • Test real layout (flexbox, grid, viewport-dependent rendering)
  • Test scrolling, drag-and-drop, paste from clipboard
  • Test browser-native animation, CSS transitions
  • Test cross-frame interactions (iframes, popups)

For those, use Playwright Component Testing or end-to-end Playwright/Cypress runs. See e2e-testing skill.

Coverage Targets

Layer Target
Pure utility functions ≥90%
Custom hooks ≥85%
Components (presentational) ≥80% — behavior, not lines
Container components ≥70% — golden paths + error states
Pages (E2E covered separately) Smoke test per route minimum

Anti-Patterns

  • Asserting on container.querySelector — bypasses accessibility queries
  • Asserting on number of renders — implementation detail
  • Mocking React hooks (jest.mock("react", ...)) — refactor the component instead
  • Mocking child components by default — tests the integration, not the parent in isolation
  • Manual act() warnings ignored — they indicate real bugs

Skill Reference

See skills/react-testing/SKILL.md for end-to-end test examples, MSW patterns, and accessibility test scaffolding.