Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 65% | 0% |
| case-12 | ✗→✓ | ▲ Improved | 57% | 0% |
| case-04 | ✓→✓ | = Same ✓ | 115% | 0% |
| case-13 | ✓→✓ | = Same ✓ | 107% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 351% | 0% |
Comprehensive React testing patterns for behavior-focused component tests, custom hook tests, accessibility assertions, and network-level mocking.
Test what the user sees and does, not implementation details.
A test should:
userEventA test should NOT:
| 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.
React Testing Library exposes queries in three tiers — use top-down:
getByRole, getByLabelText, getByPlaceholderText, getByText, getByDisplayValuegetByAltText, getByTitlegetByTestIdtsx// Best screen.getByRole("button", { name: /save/i }); // OK for inputs screen.getByLabelText("Email"); // Last resort screen.getByTestId("save-btn");
Variants:
getBy* — throws if no matchqueryBy* — returns null (use for "assert absence")findBy* — async, returns a Promise (use for elements that appear after async work)userEventtsximport 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" }); });
await userEvent callsuserEvent.setup() once per test, reuse the returned useruserEvent simulates a real browser sequence; fireEvent dispatches a single synthetic event — prefer userEventtsx// Element that appears after async work expect(await screen.findByText("Loaded")).toBeInTheDocument(); // Side effect assertion await waitFor(() => expect(saveSpy).toHaveBeenCalled()); // Element that should disappear await waitForElementToBeRemoved(() => screen.queryByText("Loading"));
Never setTimeout + assertion — flaky. Use the matchers above.
Mock Service Worker mocks at the network layer. The component, hooks, and fetch library all behave exactly as in production.
ts// test/setup.ts 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.
tsxtest("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(); });
Wrap providers once in a test-utils.tsx:
tsx// 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.
tsximport { 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" }); });
actwrappertsximport { axe, toHaveNoViolations } from "jest-axe"; // or vitest-axe expect.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:
Cross-link: skills/accessibility/SKILL.md for the broader a11y testing playbook.
Snapshots of rendered output:
Acceptable snapshot uses:
formatInvoice(invoice) -> stable string)For visual regression on components, use Playwright/Cypress screenshots or Percy/Chromatic — actual visual diffs, not DOM strings.
JSDOM (used by Vitest/Jest) cannot:
For any of those, use Playwright Component Testing (component test in real browser) or full E2E. See e2e-testing skill.
Decision boundary:
| 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:
ts// vitest.config.ts test: { coverage: { provider: "v8", reporter: ["text", "html", "lcov"], thresholds: { lines: 80, functions: 80, branches: 70, statements: 80, }, }, }
container.querySelector("...") — bypasses accessibility queries, lets tests pass when real users would failjest.mock("react", ...) — never mock React. Refactor the component insteadact() warnings — they signal real bugs (state update after unmount, missing async wrapping)it.skip() removed — your test does not actually assert what you thinkRED -> Write failing test for the next requirement
GREEN -> Write minimal component code to pass
REFACTOR -> Improve the component, tests stay green
REPEAT -> Next requirementFor new components:
bash# Vitest vitest # watch vitest run # one-shot vitest run --coverage # with coverage vitest run path/to/file.test.tsx # single file # Jest jest --watch jest --coverage jest path/to/file.test.tsx # CI mode CI=true vitest run --coverage
react-reviewer (reviews test quality during code review), tdd-guide (enforces TDD process)/react-test, /react-reviewtsxtest("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(); });
tsxfunction 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(); } });
tsxtest("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(); });
Other measured skills in the registry, with their headline benchmark lift.