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.
6.9 KiB
paths
| paths | ||||||
|---|---|---|---|---|---|---|
|
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-testidonly 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:
-
Accessible to everyone
getByRole(role, { name })— primary choicegetByLabelText— for form inputsgetByPlaceholderText— when no label is available (and add a label)getByText— for non-interactive textgetByDisplayValue— for form fields with a current value
-
Semantic queries
getByAltText— for imagesgetByTitle— last resort, low accessibility value
-
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
awaituserEventcalls — they are async - Call
userEvent.setup()once at the top of each test, then reuse the returneduser
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 appearancewaitForfor 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.