From react-plugin
Testing React SPAs: React Testing Library + Vitest/Jest for components and hooks, msw for network mocks, Playwright/Cypress for e2e. Query priority, user events, async assertions, mocking patterns. Use this skill to: - Pick the right runner (Vitest vs Jest) and setup. - Write component tests with RTL using accessible queries. - Test custom hooks via renderHook. - Mock network with msw at the boundary. - Set up Playwright or Cypress for end-to-end coverage. Do NOT use this skill for: - General React conventions (see react-conventions). - State management testing patterns specific to a store lib (see react-state-management). - Form-specific testing (apply react-forms patterns inside test).
How this skill is triggered — by the user, by Claude, or both
Slash command
/react-plugin:react-testingThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
| Layer | Framework |
| Layer | Framework |
|---|---|
| Component, hook, plain TS unit | Vitest (preferred for new) or Jest |
| End-to-end | Playwright or Cypress |
Match what's installed. Vitest is the modern default for Vite projects; Jest is common in CRA / older setups.
vitest.config.ts:
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
css: true,
coverage: {
reporter: ['text', 'html'],
exclude: ['**/*.config.*', '**/*.test.*', 'src/main.tsx'],
},
},
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
});
vitest.setup.ts:
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(() => cleanup());
Install: pnpm add -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom.
jest.config.ts:
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEach: ['<rootDir>/jest.setup.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|scss|less)$': 'identity-obj-proxy',
},
};
export default config;
jest.setup.ts:
import '@testing-library/jest-dom';
// src/features/users/UserCard.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserCard } from './UserCard';
describe('UserCard', () => {
it('renders user name and email', () => {
render(<UserCard user={{ id: '1', name: 'Alice', email: '[email protected]' }} />);
expect(screen.getByRole('heading', { name: 'Alice' })).toBeInTheDocument();
expect(screen.getByText('[email protected]')).toBeInTheDocument();
});
it('calls onDelete when delete button clicked', async () => {
const onDelete = vi.fn();
const user = userEvent.setup();
render(<UserCard user={{ id: '1', name: 'Alice', email: '[email protected]' }} onDelete={onDelete} />);
await user.click(screen.getByRole('button', { name: /delete/i }));
expect(onDelete).toHaveBeenCalledWith('1');
});
});
getByRole(role, { name }) — accessible name. Mirrors how screen readers see the page.getByLabelText — for form inputs.getByPlaceholderText — fallback for inputs without label.getByText — for non-interactive text.getByDisplayValue — for inputs with current value.getByAltText — for images.getByTitle — for elements with title attribute.getByTestId — last resort, when nothing else works.getBy* throws if not found. queryBy* returns null. findBy* is async (returns promise that retries).
expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); // assert absence
expect(await screen.findByText('Loaded')).toBeInTheDocument(); // wait for async
import userEvent from '@testing-library/user-event';
const user = userEvent.setup();
await user.click(button);
await user.type(input, 'hello');
await user.selectOptions(select, 'option-value');
await user.upload(input, file);
await user.tab(); // keyboard navigation
await user.keyboard('{Enter}');
fireEvent is lower-level and skips realistic event sequences (mousedown→mouseup→click). Stick to user-event.
// src/hooks/useDebounce.test.ts
import { describe, it, expect, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from './useDebounce';
describe('useDebounce', () => {
it('returns the latest value after delay', async () => {
vi.useFakeTimers();
const { result, rerender } = renderHook(({ value }) => useDebounce(value, 300), {
initialProps: { value: 'a' },
});
expect(result.current).toBe('a');
rerender({ value: 'b' });
expect(result.current).toBe('a'); // not yet debounced
act(() => {
vi.advanceTimersByTime(300);
});
expect(result.current).toBe('b');
vi.useRealTimers();
});
});
// vitest.setup.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { afterAll, afterEach, beforeAll } from 'vitest';
const server = setupServer(
http.get('/api/users', () => HttpResponse.json([{ id: '1', name: 'Alice' }])),
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: '2', ...body }, { status: 201 });
}),
);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
export { server };
Per-test override:
import { http, HttpResponse } from 'msw';
import { server } from '../vitest.setup';
it('shows error on 500', async () => {
server.use(http.get('/api/users', () => HttpResponse.json({ error: 'oops' }, { status: 500 })));
render(<UsersPage />);
expect(await screen.findByText(/error/i)).toBeInTheDocument();
});
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render } from '@testing-library/react';
function renderWithQuery(ui: React.ReactElement) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } }, // don't retry in tests
});
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
it('shows users from API', async () => {
renderWithQuery(<UsersList />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
});
import { MemoryRouter, Routes, Route } from 'react-router-dom';
it('navigates to user detail', async () => {
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={['/users']}>
<Routes>
<Route path="/users" element={<UsersList />} />
<Route path="/users/:id" element={<UserDetail />} />
</Routes>
</MemoryRouter>
);
await user.click(screen.getByRole('link', { name: 'Alice' }));
expect(await screen.findByText('User Detail: 1')).toBeInTheDocument();
});
For data routers (with loader etc.), use createMemoryRouter and RouterProvider.
it('shows validation errors and submits valid data', async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={onSubmit} />);
// Submit empty
await user.click(screen.getByRole('button', { name: /log in/i }));
expect(await screen.findByText(/invalid email/i)).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
// Fill correctly
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'longenough');
await user.click(screen.getByRole('button', { name: /log in/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({ email: '[email protected]', password: 'longenough' });
});
});
playwright.config.ts:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
use: { baseURL: 'http://localhost:5173', trace: 'on-first-retry' },
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
],
});
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test('user can log in', async ({ page }) => {
await page.goto('/login');
await page.getByLabel(/email/i).fill('[email protected]');
await page.getByLabel(/password/i).fill('longenough');
await page.getByRole('button', { name: /log in/i }).click();
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('heading', { name: /welcome/i })).toBeVisible();
});
// cypress/e2e/login.cy.ts
describe('login', () => {
it('user can log in', () => {
cy.visit('/login');
cy.get('[name=email]').type('[email protected]');
cy.get('[name=password]').type('longenough');
cy.contains('button', 'Log in').click();
cy.url().should('include', '/dashboard');
});
});
Cypress feels more "live" (interactive runner) but each test is browser-resident, slower at scale than Playwright.
Target ≥80% on:
Skip / lower bar:
main.tsx.Configure exclusions:
// vitest.config.ts
test: {
coverage: {
exclude: [
'src/main.tsx',
'src/App.tsx',
'**/*.config.*',
'**/types.ts',
'**/*.stories.tsx',
],
},
}
The qa-engineer agent has a hard 3-attempt cap on fixing failing tests. Mark genuinely flaky tests it.skip(...) with a comment after attempt #3, and report in the QA summary.
getByTestId everywhere instead of accessible queries.fireEvent for interactions when userEvent would be more realistic.fetch directly when msw exists in the project.act() warnings — they signal real issues with async state updates.webServer config or seed data.setTimeout waits in tests — use findBy* (which retries) or waitFor.npx claudepluginhub aratkruglik/claude-sdlc --plugin react-pluginProvides patterns for React component testing with React Testing Library, Vitest/Jest, MSW, and accessibility assertions. Helps decide between component tests and Playwright/Cypress E2E.
Guides React component testing with React Testing Library, Vitest/Jest, MSW mocking, and axe accessibility assertions. Covers test boundaries between component and E2E tests.
Provides testing patterns and examples for React components, hooks, and integrations using Vitest, React Testing Library, and Jest.