React Testing
Quick reference for testing React components. Builds on the typescript-testing skill (Vitest config, mocking, coverage) — this skill covers React-specific patterns. Reference files provide full examples and edge cases.
Component Testing
Query Priority
Always prefer queries that reflect how users see the page:
Priority Query Example
1 (best) getByRole
screen.getByRole("button", { name: /guardar/i })
2 getByLabelText
screen.getByLabelText(/correo/i)
3 getByText
screen.getByText(/bienvenido/i)
4 getByAltText
screen.getByAltText("Doctor photo")
Last resort getByTestId
screen.getByTestId("complex-widget")
User Events
import { renderWithI18n, screen } from "@drzum/ui/test"; import userEvent from "@testing-library/user-event";
it("submits login form", async () => { const user = userEvent.setup(); const onSubmit = vi.fn();
renderWithI18n(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/correo/i), "test@example.com"); await user.type(screen.getByLabelText(/contraseña/i), "password123"); await user.click(screen.getByRole("button", { name: /iniciar sesión/i }));
expect(onSubmit).toHaveBeenCalledWith({ email: "test@example.com", password: "password123", }); });
Rules:
-
Call userEvent.setup() before render
-
await all user.* methods
-
Use user.type not fireEvent.change
-
Use user.click not fireEvent.click
Asserting Absence
// Element should NOT exist expect(screen.queryByText(/error/i)).not.toBeInTheDocument();
// Element should exist expect(screen.getByRole("button")).toBeInTheDocument();
// Wait for async element const msg = await screen.findByText(/éxito/i);
See references/component-testing.md for form testing, async patterns, hook testing, GraphQL mocking, and anti-patterns.
Form Testing
Validation Errors
it("shows validation errors", async () => { const user = userEvent.setup(); renderWithI18n(<LoginForm onSubmit={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /iniciar sesión/i }));
expect(await screen.findByText(/correo.*requerido/i)).toBeInTheDocument(); });
Loading State
it("disables button during submission", async () => { const user = userEvent.setup(); renderWithI18n(<LoginForm onSubmit={() => new Promise((r) => setTimeout(r, 100))} />);
await user.type(screen.getByLabelText(/correo/i), "test@example.com"); await user.type(screen.getByLabelText(/contraseña/i), "pass"); await user.click(screen.getByRole("button", { name: /iniciar sesión/i }));
expect(screen.getByRole("button")).toBeDisabled(); });
Custom Hook Testing
import { renderHook, act } from "@testing-library/react";
it("debounces the value", () => { vi.useFakeTimers(); const { result, rerender } = renderHook( ({ value }) => useDebounce(value, 300), { initialProps: { value: "hello" } } );
rerender({ value: "world" }); expect(result.current).toBe("hello"); // Not yet debounced
act(() => vi.advanceTimersByTime(300)); expect(result.current).toBe("world"); // Debounced
vi.useRealTimers(); });
Context and Provider Testing
renderWithI18n
Use the project's renderWithI18n from @drzum/ui/test for all component tests:
import { renderWithI18n, screen } from "@drzum/ui/test";
it("renders in Spanish", () => { renderWithI18n(<Welcome />, { locale: "es" }); expect(screen.getByText("Bienvenido")).toBeInTheDocument(); });
it("renders in English", () => { renderWithI18n(<Welcome />, { locale: "en" }); expect(screen.getByText("Welcome")).toBeInTheDocument(); });
it("renders with router context", () => { renderWithI18n(<Navigation />, { withRouter: true }); expect(screen.getByRole("link", { name: /inicio/i })).toHaveAttribute("href", "/"); });
E2E Testing with Playwright
Page Object Model
// e2e/pages/login.page.ts export class LoginPage { constructor(private page: Page) {}
readonly emailInput = this.page.getByLabel(/correo/i); readonly submitButton = this.page.getByRole("button", { name: /iniciar sesión/i });
async goto() { await this.page.goto("/signin"); }
async login(email: string, password: string) { await this.emailInput.fill(email); await this.page.getByLabel(/contraseña/i).fill(password); await this.submitButton.click(); } }
Authentication State
Save auth state after setup, reuse in test projects:
// playwright.config.ts projects: [ { name: "setup", testMatch: /.*.setup.ts/ }, { name: "authenticated", dependencies: ["setup"], use: { storageState: "e2e/.auth/patient.json" }, }, ]
API Mocking
test("shows empty state", async ({ page }) => { await page.route("**/patient/graphql", async (route) => { await route.fulfill({ status: 200, body: JSON.stringify({ data: { viewer: { appointments: [] } } }), }); }); await page.goto("/appointments"); await expect(page.getByText(/no tienes citas/i)).toBeVisible(); });
Key Rules
-
No hard-coded waits — Use await expect(...).toBeVisible() not waitForTimeout
-
Independent tests — Each test sets up its own state
-
Use Page Objects — Encapsulate selectors and common actions
-
Test on mobile — Add devices["Pixel 5"] project
See references/playwright.md for config, fixtures, auth setup, visual assertions, and multi-step flows.
Accessibility Testing
axe-core in Unit Tests
import { axe, toHaveNoViolations } from "vitest-axe";
expect.extend(toHaveNoViolations);
it("has no accessibility violations", async () => { const { container } = renderWithI18n(<LoginForm />); const results = await axe(container); expect(results).toHaveNoViolations(); });
Keyboard Navigation
it("follows logical tab order", async () => { const user = userEvent.setup(); renderWithI18n(<LoginForm />);
await user.tab(); expect(screen.getByLabelText(/correo/i)).toHaveFocus();
await user.tab(); expect(screen.getByLabelText(/contraseña/i)).toHaveFocus();
await user.tab(); expect(screen.getByRole("button", { name: /iniciar sesión/i })).toHaveFocus(); });
Playwright axe Scan
import AxeBuilder from "@axe-core/playwright";
test("page has no a11y violations", async ({ page }) => { await page.goto("/"); const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).analyze(); expect(results.violations).toEqual([]); });
Every Component Checklist
-
Keyboard navigable — all controls reachable via Tab/Enter/Space
-
Screen reader labels — all interactive elements have accessible names
-
Form labels — all inputs have <label> associations
-
Error association — errors linked via aria-describedby
-
Focus management — modals trap focus, focus returns on close
-
Color contrast — WCAG AA (4.5:1 text, 3:1 large)
See references/accessibility.md for WCAG criteria, keyboard testing patterns, screen reader testing, and the full checklist.
Post-Change Verification
After writing or modifying tests, run the full verification protocol:
pnpm --filter <app> validate
All 4 steps must pass. See typescript-writing-code skill for details.
Reference Files
File Description
references/component-testing.md Testing Library queries, user events, form testing, hooks, async, GraphQL mocking
references/playwright.md Config, Page Object Model, fixtures, auth state, API mocking, visual assertions
references/accessibility.md axe-core, WCAG 2.1, keyboard testing, screen reader testing, focus management