rules/react/testing.md
This file extends typescript/testing.md and common/testing.md with React specific content.
Pick one component test runner per project — do not mix RTL + Playwright CT in the same repo.
Test what the user sees and does, not implementation details.
data-testid only when nothing else fitsRTL 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 valueSemantic queries
getByAltText — for imagesgetByTitle — last resort, low accessibility valueTest IDs
getByTestId("some-id") — escape hatch only, when none of the above workgetBy* throws when no match. queryBy* returns null (use for asserting absence). findBy* returns a promise (use for async).
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"), "[email protected]");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(handleSubmit).toHaveBeenCalledWith({ email: "[email protected]" });
});
await userEvent calls — they are asyncuserEvent.setup() once at the top of each test, then reuse the returned user// 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 appearancewaitFor for async expectations on side effects or other matcherssetTimeout + assertion — flakyUse 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();
});
Snapshots of rendered output are brittle, hard to review, and rubber-stamped by reviewers. Use them only for:
For component visual regression, use Playwright / Cypress / Percy screenshots — actual visual diffs, not DOM diffs.
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.
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);
});
actimport { 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).
Component test with RTL + JSDOM cannot:
For those, use Playwright Component Testing or end-to-end Playwright/Cypress runs. See e2e-testing skill.
| 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 |
container.querySelector — bypasses accessibility queriesjest.mock("react", ...)) — refactor the component insteadact() warnings ignored — they indicate real bugsSee skills/react-testing/SKILL.md for end-to-end test examples, MSW patterns, and accessibility test scaffolding.