一键导入
qa-react
React testing skill with React Testing Library, Vitest, MSW, and Playwright. Use when writing or running React frontend tests.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
React testing skill with React Testing Library, Vitest, MSW, and Playwright. Use when writing or running React frontend tests.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Node.js + Express + MongoDB backend development for MEAN stack apps. Use when implementing server code: models, routes, controllers, middleware, config, and database connections.
Code review for MEAN stack apps. Use when reviewing backend (Express + MongoDB) and frontend (React) code for quality, security, architecture compliance, performance, and best practices.
Express.js API architecture for MEAN stack apps. Use when designing REST endpoints, middleware chains, authentication flows, error handling contracts, and request/response schemas.
React frontend development for MEAN stack apps. Use when implementing React components, pages, hooks, API integration, routing, state management, and responsive UI.
MongoDB database architecture for MEAN stack apps. Use when designing collections, schemas, indexes, relationships, and data modeling with Mongoose ODM.
Product management for MEAN stack apps. Use when writing PRDs, defining features, acceptance criteria, or reviewing deliverables for MongoDB + Express + React + Node.js projects.
| name | qa-react |
| description | React testing skill with React Testing Library, Vitest, MSW, and Playwright. Use when writing or running React frontend tests. |
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.
| 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+ |
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.
useState / useReducer values directlycontainer.querySelector unless absolutely no RTL query worksact() directly when RTL's waitFor / findBy handles ituserEventwaitFor and findBy* for async state transitionsUse 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')| 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 |
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';
// Setup — create a user instance per test
const user = userEvent.setup();
// Click
await user.click(screen.getByRole('button', { name: /save/i }));
// Type into input
await user.type(screen.getByLabelText(/username/i), 'john_doe');
// Clear and retype
await user.clear(screen.getByLabelText(/email/i));
await user.type(screen.getByLabelText(/email/i), 'new@email.com');
// Select option
await user.selectOptions(screen.getByRole('combobox', { name: /country/i }), 'US');
// Keyboard
await user.keyboard('{Enter}');
await user.tab();
// Upload file
const file = new File(['content'], 'doc.pdf', { type: 'application/pdf' });
await user.upload(screen.getByLabelText(/upload/i), file);
waitFor — poll until assertion passesawait waitFor(() => {
expect(screen.getByText(/data loaded/i)).toBeInTheDocument();
});
findBy — shorthand for waitFor + getByconst heading = await screen.findByRole('heading', { name: /dashboard/i });
expect(heading).toBeInTheDocument();
waitForElementToBeRemoved — wait for loading spinners to disappearawait waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
waitFor(() => ..., { timeout: 3000 })setTimeout or manual delays in testsfindBy query is sufficient, prefer it over waitFor + getByMSW intercepts HTTP requests at the network level. Tests hit the same fetch/axios calls as production — no mocking of modules.
src/test/setup.ts)import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
// Default handlers — shared across all tests
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);
// Vitest global setup
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
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();
});
| 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 |
src/test/handlers/ organized by API domain (e.g., user.ts, projects.ts, auth.ts)server in setup.tsimport { 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();
});
});
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);
});
});
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 tests render a full page component with router context, mocked API, and all providers.
import { render, screen } from '@/test/test-utils'; // custom render with providers
import { DashboardPage } from './DashboardPage';
describe('DashboardPage', () => {
it('loads and displays project list', async () => {
render(<DashboardPage />);
// Wait for loading to complete
await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
// Assert data is rendered
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();
});
});
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 };
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();
});
toMatchInlineSnapshot) for small outputs--updateimport { 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();
});
jest-axe on every form component and every page-level component| 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
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();
});
e2e/ at project root, not alongside components@critical for CI gatingsrc/test/setup.ts)This file is loaded by Vitest before every test file. It configures:
expect with .toBeInTheDocument(), .toHaveTextContent(), etc.expect with .toHaveNoViolations()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'],
},
},
});
| 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..." |
vi.mock() for API calls. Reserve vi.mock() for non-HTTP dependencies (e.g., window.matchMedia, IntersectionObserver)afterEachreferences/. Changes to SKILL.md or scripts/ require user approval.