react-testing
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()beforerender awaitalluser.*methods- Use
user.typenotfireEvent.change - Use
user.clicknotfireEvent.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()notwaitForTimeout - 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 |