react-testing
React component testing with React Testing Library, Vitest/Jest, MSW for network mocking, accessibility assertions with axe, and the decision boundary between component tests and Playwright/Cypress end-to-end runs. Use when writing or fixing tests for React components, hooks, or pages.
React Testing
Comprehensive React testing patterns for behavior-focused component tests, custom hook tests, accessibility assertions, and network-level mocking.
When to Activate
- Writing tests for React components, custom hooks, or pages
- Adding test coverage to legacy untested components
- Migrating from Enzyme or class-component-era patterns to React Testing Library
- Setting up Vitest or Jest for a new React project
- Mocking HTTP requests in tests
- Asserting accessibility violations
- Deciding which tests belong in RTL vs Playwright Component Testing vs full E2E
Core Principle
Test what the user sees and does, not implementation details.
A test should:
- Render the component with the same providers it has in production
- Interact with it via accessible queries (role, label) and
userEvent - Assert visible output and observable side effects (callback fired, request sent)
A test should NOT:
- Inspect component state, props passed to children, or which hooks were called
- Mock React itself or framework hooks
- Assert on the number of renders or DOM structure beyond what affects users
Library Choice
| Runner | When | Note |
|---|---|---|
| Vitest | Vite, Remix, modern setups | Faster, native ESM, Jest-compatible API |
| Jest | Next.js, CRA, established repos | Default for many React projects |
| Playwright Component Testing | Real browser engine needed | Use when JSDOM lacks the required feature |
| Cypress Component Testing | Real browser, Cypress already in use | Alternative to Playwright CT |
Pick one. Do not run RTL + Vitest AND Playwright CT in the same repo unless you have a clear lane separation.
Query Priority
React Testing Library exposes queries in three tiers — use top-down:
- Accessible to everyone:
getByRole,getByLabelText,getByPlaceholderText,getByText,getByDisplayValue - Semantic:
getByAltText,getByTitle - Test IDs (escape hatch):
getByTestId
// Bestscreen.getByRole("button", { name: /save/i });
// OK for inputsscreen.getByLabelText("Email");
// Last resortscreen.getByTestId("save-btn");Variants:
getBy*— throws if no matchqueryBy*— returnsnull(use for “assert absence”)findBy*— async, returns a Promise (use for elements that appear after async work)
User Interaction with userEvent
import userEvent from "@testing-library/user-event";
test("submits the form", async () => { const user = userEvent.setup(); const onSubmit = vi.fn(); render(<UserForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Email"), "user@example.com"); await user.click(screen.getByRole("button", { name: /save/i }));
expect(onSubmit).toHaveBeenCalledWith({ email: "user@example.com" });});- Always
awaituserEvent calls - Call
userEvent.setup()once per test, reuse the returneduser userEventsimulates a real browser sequence;fireEventdispatches a single synthetic event — preferuserEvent
Async Patterns
// Element that appears after async workexpect(await screen.findByText("Loaded")).toBeInTheDocument();
// Side effect assertionawait waitFor(() => expect(saveSpy).toHaveBeenCalled());
// Element that should disappearawait waitForElementToBeRemoved(() => screen.queryByText("Loading"));Never setTimeout + assertion — flaky. Use the matchers above.
Network Mocking with MSW
Mock Service Worker mocks at the network layer. The component, hooks, and fetch library all behave exactly as in production.
Setup
import { setupServer } from "msw/node";import { http, HttpResponse } from "msw";
export const handlers = [ http.get("/api/users/:id", ({ params }) => HttpResponse.json({ id: params.id, name: "Alice" }), ), http.post("/api/users", async ({ request }) => { const body = await request.json(); return HttpResponse.json({ id: "new-id", ...body }, { status: 201 }); }),];
export const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));afterEach(() => server.resetHandlers());afterAll(() => server.close());Configure onUnhandledRequest: "error" so any unmocked request fails the test loudly — silent passes are worse than red.
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();});Provider Wrapping
Wrap providers once in a test-utils.tsx:
import { render, RenderOptions } from "@testing-library/react";import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export function renderWithProviders( ui: React.ReactElement, options?: RenderOptions,) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, });
return render( <QueryClientProvider client={queryClient}> <ThemeProvider theme={lightTheme}> <MemoryRouter>{ui}</MemoryRouter> </ThemeProvider> </QueryClientProvider>, options, );}
export * from "@testing-library/react";Then import { renderWithProviders, screen } from "test-utils" in every test file.
Custom Hook Testing
import { renderHook, act } from "@testing-library/react";
test("useCounter increments and decrements", () => { const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => result.current.increment()); expect(result.current.count).toBe(1);
act(() => result.current.decrement()); expect(result.current.count).toBe(0);});
test("useCounter accepts initial value", () => { const { result } = renderHook(() => useCounter(10)); expect(result.current.count).toBe(10);});
test("useUser fetches user data", async () => { // Instantiate QueryClient ONCE per test outside the wrapper so it survives re-renders. // Creating it inside the wrapper closure resets cache state on every render, producing flaky tests. const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); const wrapper = ({ children }: { children: React.ReactNode }) => ( <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> );
const { result } = renderHook(() => useUser("1"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toEqual({ id: "1", name: "Alice" });});- Wrap state-changing calls in
act - Test through the hook’s public API only
- For hooks that use context, pass a
wrapper
Accessibility Assertions
import { axe, toHaveNoViolations } from "jest-axe"; // or vitest-axeexpect.extend(toHaveNoViolations);
test("UserCard has no a11y violations", async () => { const { container } = render(<UserCard user={mockUser} />); expect(await axe(container)).toHaveNoViolations();});Run axe in component tests for every interactive component. Catches:
- Missing labels on form inputs
- Invalid ARIA usage
- Poor color contrast (limited — JSDOM has no real CSS engine, so this works for inline styles only; visual contrast belongs in Playwright)
- Missing alt text on images
- Heading order violations
Cross-link: skills/accessibility/SKILL.md for the broader a11y testing playbook.
When NOT to Use Snapshot Tests
Snapshots of rendered output:
- Break on every styling change
- Get rubber-stamped during review
- Test implementation detail (DOM structure), not behavior
Acceptable snapshot uses:
- Pure data serialization functions (
formatInvoice(invoice)-> stable string) - Generated config files (e.g., webpack config output)
For visual regression on components, use Playwright/Cypress screenshots or Percy/Chromatic — actual visual diffs, not DOM strings.
When to Reach for Playwright / Cypress
JSDOM (used by Vitest/Jest) cannot:
- Render real layout (flexbox, grid, viewport queries)
- Run native browser animation, CSS transitions
- Test scrolling behavior, drag-and-drop, paste from clipboard
- Handle iframes, popups, downloads, cross-origin flows
- Run real network in a controlled environment with full DevTools support
For any of those, use Playwright Component Testing (component test in real browser) or full E2E. See e2e-testing skill.
Decision boundary:
- A hook, a presentational component, a form with logic -> RTL
- A component whose layout matters or that uses browser APIs not in JSDOM -> Playwright CT
- A full user flow across multiple pages -> Playwright/Cypress E2E
Coverage Targets
| Layer | Target |
|---|---|
| Pure utilities | >=90% |
| Custom hooks | >=85% |
| Presentational components | >=80% — behavior, not lines |
| Container components | >=70% — golden paths + error states |
| Pages | E2E covered separately; smoke test minimum |
Configure via vitest.config.ts / jest.config.js:
test: { coverage: { provider: "v8", reporter: ["text", "html", "lcov"], thresholds: { lines: 80, functions: 80, branches: 70, statements: 80, }, },}Anti-Patterns
container.querySelector("...")— bypasses accessibility queries, lets tests pass when real users would fail- Asserting on number of renders — implementation detail
jest.mock("react", ...)— never mock React. Refactor the component instead- Mocking child components by default — tests the integration, not isolation. Mock only when the child has heavy side effects
- Ignoring
act()warnings — they signal real bugs (state update after unmount, missing async wrapping) - Sharing mutable state across tests — flakes when test order changes
- Tests that pass with
it.skip()removed — your test does not actually assert what you think
TDD Workflow
RED -> Write failing test for the next requirementGREEN -> Write minimal component code to passREFACTOR -> Improve the component, tests stay greenREPEAT -> Next requirementFor new components:
- Define the component’s prop type and signature
- Write the first test for the simplest case
- Verify it fails for the right reason
- Implement just enough to pass
- Add the next test case
- Refactor when the third similar test reveals a pattern
Test Commands
# Vitestvitest # watchvitest run # one-shotvitest run --coverage # with coveragevitest run path/to/file.test.tsx # single file
# Jestjest --watchjest --coveragejest path/to/file.test.tsx
# CI modeCI=true vitest run --coverageRelated
- Rules: rules/react/testing.md
- Skills: react-patterns, accessibility, e2e-testing, tdd-workflow
- Agents:
react-reviewer(reviews test quality during code review),tdd-guide(enforces TDD process) - Commands:
/react-test,/react-review
Examples
Form submission with MSW and userEvent
test("submits user form and shows success", async () => { server.use( http.post("/api/users", () => HttpResponse.json({ id: "1", name: "Alice" }, { status: 201 }), ), );
const user = userEvent.setup(); renderWithProviders(<UserForm />);
await user.type(screen.getByLabelText("Name"), "Alice"); await user.type(screen.getByLabelText("Email"), "alice@example.com"); await user.click(screen.getByRole("button", { name: /save/i }));
expect(await screen.findByText(/saved successfully/i)).toBeInTheDocument();});Testing an error boundary
function Broken() { throw new Error("boom");}
test("error boundary renders fallback", () => { // Suppress React's console.error noise for the expected throw, then restore so // the spy does not leak across tests and hide real errors elsewhere. const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { render( <ErrorBoundary fallback={<div>Something went wrong</div>}> <Broken /> </ErrorBoundary>, );
expect(screen.getByText("Something went wrong")).toBeInTheDocument(); } finally { errorSpy.mockRestore(); }});Testing a Suspense boundary
test("shows loading then content", async () => { renderWithProviders( <Suspense fallback={<div>Loading...</div>}> <UserDetail id="1" /> </Suspense>, );
expect(screen.getByText("Loading...")).toBeInTheDocument(); expect(await screen.findByText("Alice")).toBeInTheDocument();});