| name | qa-react |
| description | React testing skill with React Testing Library, Vitest, MSW, and Playwright. Use when writing or running React frontend tests. |
QA React
Purpose
This skill equips the tester-react agent with everything needed to write, run, and maintain React frontend tests in a fullstack Golang + React codebase. It covers unit tests for components and hooks, integration tests for pages, API mocking with MSW, accessibility checks, and optional E2E flows with Playwright. The guiding philosophy: test what the user experiences, not how the code is wired.
Core Testing Stack
| Tool | Role | Version Guidance |
|---|
| Vitest | Test runner | Vite-compatible, ESM-native, fast HMR-aware |
| React Testing Library (RTL) | Component rendering + queries | @testing-library/react |
| user-event | Realistic user interaction simulation | @testing-library/user-event v14+ |
| MSW | Network-level API mocking | msw v2+ — intercepts fetch/axios at Service Worker level |
| jest-axe | Automated accessibility assertions | jest-axe via toHaveNoViolations |
| Playwright | E2E browser tests (optional) | For critical user flows only |
| @testing-library/react-hooks | Hook testing (legacy) | Prefer renderHook from @testing-library/react v14+ |
RTL Philosophy: Test Behavior, Not Implementation
The single most important principle: tests should resemble how users interact with your app. If a test breaks because you renamed an internal state variable but the UI behavior didn't change, the test is wrong.
Forbidden Patterns
- Never query by CSS class name, tag name, or component internal state
- Never assert on
useState / useReducer values directly
- Never use
container.querySelector unless absolutely no RTL query works
- Never test implementation details (internal function calls, hook return values in isolation without rendering)
- Never use
act() directly when RTL's waitFor / findBy handles it
Encouraged Patterns
- Query by accessible role, label, placeholder, text — what the user sees
- Simulate real user actions (click, type, tab) with
userEvent
- Assert on visible output: text content, element presence/absence, ARIA attributes
- Use
waitFor and findBy* for async state transitions
RTL Query Priority (strict order)
Use the highest-priority query that works for your case:
getByRole — Accessible role + name. Best for buttons, headings, links, form controls. Example: getByRole('button', { name: /submit/i })
getByLabelText — Form fields with associated <label>. Example: getByLabelText(/email address/i)
getByPlaceholderText — When label is absent (not ideal, but acceptable). Example: getByPlaceholderText(/search/i)
getByText — Visible text content. Example: getByText(/welcome back/i)
getByDisplayValue — Current value of input/textarea/select.
getByAltText — Images with alt text.
getByTitle — Elements with title attribute.
getByTestId — Last resort only. Use data-testid when no semantic query works. Example: getByTestId('chart-container')
Query Variants
| Need | Prefix | Throws on miss? | Async? |
|---|
| Element must exist | getBy | Yes | No |
| Element must NOT exist | queryBy | No (returns null) | No |
| Element will appear (async) | findBy | Yes (waits) | Yes |
| Multiple elements | getAllBy / queryAllBy / findAllBy | Respective behavior | Respective |
User Event Simulation
Always use @testing-library/user-event over fireEvent. It simulates complete user interaction sequences (focus, keydown, keypress, keyup, input, change) rather than dispatching isolated synthetic events.
import userEvent from '@testing-library/user-event';
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /save/i }));
await user.type(screen.getByLabelText(/username/i), 'john_doe');
await user.clear(screen.getByLabelText(/email/i));
await user.type(screen.getByLabelText(/email/i), 'new@email.com');
await user.selectOptions(screen.getByRole('combobox', { name: /country/i }), 'US');
await user.keyboard('{Enter}');
await user.tab();
const file = new File(['content'], 'doc.pdf', { type: 'application/pdf' });
await user.upload(screen.getByLabelText(/upload/i), file);
Async Testing
waitFor — poll until assertion passes
await waitFor(() => {
expect(screen.getByText(/data loaded/i)).toBeInTheDocument();
});
findBy — shorthand for waitFor + getBy
const heading = await screen.findByRole('heading', { name: /dashboard/i });
expect(heading).toBeInTheDocument();
waitForElementToBeRemoved — wait for loading spinners to disappear
await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
Rules
- Default timeout is 1000ms. Increase for slow operations:
waitFor(() => ..., { timeout: 3000 })
- Never use
setTimeout or manual delays in tests
- If a
findBy query is sufficient, prefer it over waitFor + getBy
MSW (Mock Service Worker) — API Mocking
MSW intercepts HTTP requests at the network level. Tests hit the same fetch/axios calls as production — no mocking of modules.
Server Setup (src/test/setup.ts)
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/v1/user/me', () => {
return HttpResponse.json({ id: 1, name: 'Test User', email: 'test@example.com' });
}),
http.get('/api/v1/health', () => {
return HttpResponse.json({ status: 'ok' });
}),
];
export const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Per-Test Handler Overrides
import { server } from '@/test/setup';
import { http, HttpResponse } from 'msw';
test('shows error when API fails', async () => {
server.use(
http.get('/api/v1/user/me', () => {
return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 });
}),
);
render(<ProfilePage />);
expect(await screen.findByText(/unauthorized/i)).toBeInTheDocument();
});
MSW Handler Patterns
| Scenario | Pattern |
|---|
| Success response | HttpResponse.json(data, { status: 200 }) |
| Error response | HttpResponse.json({ error: msg }, { status: 4xx/5xx }) |
| Empty response | HttpResponse.json(null, { status: 204 }) |
| Network error | HttpResponse.error() |
| Delayed response | await delay(ms) before returning |
| Request body validation | Read await request.json() and assert / branch |
| Paginated list | Read URL search params, return sliced data |
MSW Convention
- Place default handlers in
src/test/handlers/ organized by API domain (e.g., user.ts, projects.ts, auth.ts)
- Export each handler array, compose into
server in setup.ts
- Override in individual tests only for error/edge cases
Component Testing Pattern
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
const user = userEvent.setup();
const mockOnSubmit = vi.fn();
beforeEach(() => {
mockOnSubmit.mockClear();
});
it('renders email and password fields', () => {
render(<LoginForm onSubmit={mockOnSubmit} />);
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
});
it('calls onSubmit with credentials when form is filled and submitted', async () => {
render(<LoginForm onSubmit={mockOnSubmit} />);
await user.type(screen.getByLabelText(/email/i), 'user@test.com');
await user.type(screen.getByLabelText(/password/i), 'secret123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(mockOnSubmit).toHaveBeenCalledWith({
email: 'user@test.com',
password: 'secret123',
});
});
it('shows validation error when email is empty', async () => {
render(<LoginForm onSubmit={mockOnSubmit} />);
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
expect(mockOnSubmit).not.toHaveBeenCalled();
});
});
Hook Testing Pattern
Use renderHook from @testing-library/react (not the deprecated @testing-library/react-hooks package).
import { renderHook, waitFor } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('starts with initial value', () => {
const { result } = renderHook(() => useCounter(5));
expect(result.current.count).toBe(5);
});
it('increments count', () => {
const { result } = renderHook(() => useCounter(0));
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
});
Hooks that need providers (React Query, Router, etc.)
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
it('fetches user data', async () => {
const { result } = renderHook(() => useUser(1), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.name).toBe('Test User');
});
Page / Integration Testing Pattern
Page tests render a full page component with router context, mocked API, and all providers.
import { render, screen } from '@/test/test-utils';
import { DashboardPage } from './DashboardPage';
describe('DashboardPage', () => {
it('loads and displays project list', async () => {
render(<DashboardPage />);
await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
expect(screen.getByRole('heading', { name: /my projects/i })).toBeInTheDocument();
expect(screen.getAllByRole('listitem')).toHaveLength(3);
});
it('navigates to project detail when clicked', async () => {
render(<DashboardPage />);
await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
await user.click(screen.getByText(/project alpha/i));
expect(await screen.findByRole('heading', { name: /project alpha/i })).toBeInTheDocument();
});
});
Custom Render (src/test/test-utils.tsx)
import { render, RenderOptions } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
function AllProviders({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>{children}</BrowserRouter>
</QueryClientProvider>
);
}
const customRender = (ui: React.ReactElement, options?: Omit<RenderOptions, 'wrapper'>) =>
render(ui, { wrapper: AllProviders, ...options });
export * from '@testing-library/react';
export { customRender as render };
Snapshot Testing
Use snapshots sparingly and only for stable, presentational UI that changes rarely.
it('matches snapshot for static footer', () => {
const { container } = render(<Footer />);
expect(container.firstChild).toMatchSnapshot();
});
Snapshot Rules
- Never snapshot components with dynamic data (dates, IDs, random values)
- Never snapshot entire pages
- Prefer inline snapshots (
toMatchInlineSnapshot) for small outputs
- When a snapshot update is needed, review the diff carefully before accepting with
--update
- If a snapshot breaks frequently, replace it with explicit assertions
Accessibility Testing
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
it('has no accessibility violations', async () => {
const { container } = render(<LoginForm onSubmit={vi.fn()} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Accessibility Checks to Include
- Run
jest-axe on every form component and every page-level component
- Verify focus management after modal open/close
- Verify keyboard navigation for custom interactive elements
- Check ARIA labels on icon-only buttons
Coverage Targets
| Category | Target | Rationale |
|---|
Business logic hooks (use*) | 80%+ | Core logic, high ROI |
| Form components | 80%+ | User input paths must be solid |
| Page components | 70%+ | Integration coverage, some paths are hard to reach |
| Presentational/layout components | 50%+ | Low logic, snapshot may suffice |
| Utility functions | 90%+ | Pure functions, easy to test exhaustively |
| E2E critical paths | 100% of defined flows | If you write an E2E test, it must pass |
Run coverage: npx vitest run --coverage
Playwright E2E (Optional — Critical Flows Only)
Use Playwright only for high-value end-to-end user flows that cannot be adequately tested with RTL + MSW. Examples: login flow, checkout, multi-step wizard.
import { test, expect } from '@playwright/test';
test('user can log in and see dashboard', async ({ page }) => {
await page.goto('/login');
await page.getByLabel(/email/i).fill('user@test.com');
await page.getByLabel(/password/i).fill('password123');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});
Playwright Rules
- E2E tests live in
e2e/ at project root, not alongside components
- E2E tests run against a real dev server (or preview build), not mocked APIs
- Keep E2E suite small: 10-20 critical flows max
- Tag with
@critical for CI gating
Test Setup File (src/test/setup.ts)
This file is loaded by Vitest before every test file. It configures:
- MSW server — starts, resets between tests, closes after suite
- jest-dom matchers — extends
expect with .toBeInTheDocument(), .toHaveTextContent(), etc.
- jest-axe matchers — extends
expect with .toHaveNoViolations()
- Global cleanup — ensures no leaked state between tests
import '@testing-library/jest-dom/vitest';
import { server } from './handlers';
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Referenced in vitest.config.ts:
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true,
css: false,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/test/**', 'src/**/*.d.ts', 'src/main.tsx'],
},
},
});
File Conventions
| Convention | Rule |
|---|
| Test file location | Same directory as source: Button.tsx -> Button.test.tsx |
| Test file naming | ComponentName.test.tsx for components, useHook.test.ts for hooks |
| Test utility location | src/test/test-utils.tsx for custom render, src/test/handlers/ for MSW handlers |
| E2E test location | e2e/ at project root |
| Describe blocks | describe('ComponentName', () => { ... }) — one per file, named after component |
| Test names | Start with verb describing user action or state: "renders...", "shows...", "calls...", "navigates..." |
Conventions
- One test file per source file — never combine tests for multiple components
- Arrange-Act-Assert — every test follows this structure clearly
- No test interdependence — each test must work in isolation; no shared mutable state
- Mock at the network level — use MSW, not
vi.mock() for API calls. Reserve vi.mock() for non-HTTP dependencies (e.g., window.matchMedia, IntersectionObserver)
- Descriptive test names — a failing test name should tell you what broke without reading the test body
- No magic numbers — use named constants or factory functions for test data
- Test data factories — create helper functions for generating test objects with sensible defaults
- Clean up — if a test modifies global state (localStorage, cookies), restore it in
afterEach
Knowledge Strategy
- Patterns to capture: Reusable MSW handler configurations, custom render wrappers for project-specific providers, test data factory patterns, recurring accessibility violation fixes.
- Examples to collect: Successful test suites with high coverage, effective integration test patterns for complex pages, MSW handler compositions for multi-step API flows.
- Update permission: Agents may freely add/update files in
references/. Changes to SKILL.md or scripts/ require user approval.