From testing-skills
General-purpose React Testing Library guide with Vitest. Use when writing, reviewing, or setting up React component and hook tests. Covers project setup from scratch, RTL query priority, userEvent, async patterns, mocking strategies, and common anti-patterns. Applicable to any Vite + React project.
How this skill is triggered — by the user, by Claude, or both
Slash command
/testing-skills:react-testing-libraryThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
General-purpose guide for testing React components and hooks with React Testing Library (RTL) and Vitest. Project-agnostic — works with any Vite + React setup.
General-purpose guide for testing React components and hooks with React Testing Library (RTL) and Vitest. Project-agnostic — works with any Vite + React setup.
"Write tests. Not too many. Mostly integration." — Kent C. Dodds
E2E ← Few: critical user journeys only (Playwright/Cypress)
Integration ← MOST tests: components with real providers, MSW for APIs
Unit ← Some: complex pure logic, utilities, formatters
Static Analysis ← Always: TypeScript, ESLint
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
Optional but recommended:
npm install -D msw # Network-level API mocking
npm install -D @vitest/coverage-v8 # Code coverage
Create vitest.config.js at the client root (or extend vite.config.js):
import { defineConfig, mergeConfig } from 'vitest/config';
import viteConfig from './vite.config.js';
export default mergeConfig(viteConfig, defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.js'],
css: true,
include: ['src/**/*.test.{js,jsx,ts,tsx}'],
},
}));
Create src/test/setup.js:
import '@testing-library/jest-dom/vitest';
This registers matchers like toBeInTheDocument(), toBeVisible(), toHaveTextContent().
Add to package.json:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}
Before writing tests, identify the component type and pick scenarios from this matrix. Write 1-3 tests per component — each test covers a full user flow, not a single assertion.
| # | Test | What it covers |
|---|---|---|
| 1 | Happy path: fill all fields → submit → success feedback | Rendering, typing, validation passing, API call, success state |
| 2 | Validation: submit empty/invalid → error messages appear | Required fields, validation rules, error rendering |
| 3 | API failure: fill valid → submit → server error shown | Error handling, error UI, form stays filled |
| # | Test | What it covers |
|---|---|---|
| 1 | Happy path: data loads → items render → user interacts | Loading state, data rendering, click/navigation |
| 2 | Empty state: no data → empty message shown | Zero-data handling |
| 3 | Error state: API fails → error message shown | Network failure handling |
| # | Test | What it covers |
|---|---|---|
| 1 | Happy path: data loads → full content renders → user actions work | Data fetching, rendering, interactions (edit/delete/comment) |
| 2 | Not found / error: invalid ID → appropriate message | 404 handling, error boundaries |
| # | Test | What it covers |
|---|---|---|
| 1 | Authenticated: user sees protected content and can interact | Auth context, content rendering, user actions |
| 2 | Unauthenticated: redirects or shows login prompt | Guard behavior, redirect |
| # | Test | What it covers |
|---|---|---|
| 1 | Renders with props and handles user interaction | Props → UI mapping, click/hover callbacks |
| 2 | Conditional rendering: different props → different output | Only if the component has meaningful branching |
This is what a well-structured test file looks like. Each test walks through a real user flow.
// BlogList.test.jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import BlogList from './BlogList';
// --- MSW setup: mock the API at the network level ---
const blogs = [
{ _id: '1', title: 'First Post', category: 'Technology', excerpt: 'About tech' },
{ _id: '2', title: 'Second Post', category: 'Startup', excerpt: 'About startups' },
];
const server = setupServer(
http.get('/api/blogs', () => HttpResponse.json({ success: true, blogs })),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// --- Render helper (keeps tests DRY) ---
const renderBlogList = () =>
render(<MemoryRouter><BlogList /></MemoryRouter>);
// --- Tests: 3 tests covering ALL real scenarios ---
describe('BlogList', () => {
it('loads blogs and lets user navigate to a post', async () => {
const user = userEvent.setup();
renderBlogList();
// Loading state appears first
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Blogs appear after fetch
expect(await screen.findByText('First Post')).toBeInTheDocument();
expect(screen.getByText('Second Post')).toBeInTheDocument();
// User clicks a blog card
await user.click(screen.getByRole('link', { name: /first post/i }));
// Assert navigation happened (or verify detail view renders)
});
it('shows empty state when no blogs exist', async () => {
server.use(
http.get('/api/blogs', () => HttpResponse.json({ success: true, blogs: [] })),
);
renderBlogList();
expect(await screen.findByText(/no blogs/i)).toBeInTheDocument();
expect(screen.queryByRole('article')).not.toBeInTheDocument();
});
it('shows error message when API fails', async () => {
server.use(
http.get('/api/blogs', () => HttpResponse.json(
{ success: false, message: 'Server error' },
{ status: 500 },
)),
);
renderBlogList();
expect(await screen.findByRole('alert')).toBeInTheDocument();
expect(screen.queryByRole('article')).not.toBeInTheDocument();
});
});
// LoginForm.test.jsx
describe('LoginForm', () => {
it('logs in with valid credentials and shows dashboard', async () => {
const user = userEvent.setup();
render(<MemoryRouter><LoginForm /></MemoryRouter>);
// Fill form
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
// Success: redirected or success message
expect(await screen.findByText(/welcome/i)).toBeInTheDocument();
});
it('shows validation errors when submitted empty', async () => {
const user = userEvent.setup();
render(<MemoryRouter><LoginForm /></MemoryRouter>);
await user.click(screen.getByRole('button', { name: /sign in/i }));
// Both fields show errors
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
// Form is still visible (not redirected)
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
});
it('shows server error when API returns 401', async () => {
server.use(
http.post('/api/admin/login', () =>
HttpResponse.json({ success: false, message: 'Invalid credentials' }, { status: 401 }),
),
);
const user = userEvent.setup();
render(<MemoryRouter><LoginForm /></MemoryRouter>);
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'wrongpassword');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(await screen.findByText(/invalid credentials/i)).toBeInTheDocument();
});
});
// Test runner — ALWAYS from vitest
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// RTL — render, screen, waitFor
import { render, screen, waitFor, within } from '@testing-library/react';
// User interaction — ALWAYS userEvent, NEVER fireEvent
import userEvent from '@testing-library/user-event';
// Hook testing
import { renderHook, act } from '@testing-library/react';
NEVER import from jest. Use vi.fn(), vi.spyOn(), vi.mock().
Queries are ordered by how closely they reflect user experience.
| Query | Use For |
|---|---|
getByRole | Buttons, links, headings, inputs, checkboxes, comboboxes — always try first |
getByLabelText | Form fields with a <label> |
getByPlaceholderText | Inputs without a label (prefer adding a label instead) |
getByText | Static text content — paragraphs, spans, error messages |
getByDisplayValue | Input with a current value |
| Query | Use For |
|---|---|
getByAltText | Images |
getByTitle | Elements with title attribute |
| Query | Use For |
|---|---|
getByTestId | Only when no accessible query works; requires data-testid |
| Variant | Returns | Use When |
|---|---|---|
getBy | Element or throws | Element must be present |
queryBy | Element or null | Asserting element does not exist |
findBy | Promise<Element> | Element appears after an async operation |
*AllBy | Array variants | Multiple matching elements |
getByRole('button', { name: /submit/i })
getByRole('heading', { level: 2 })
getByRole('textbox', { name: /email/i })
getByRole('link', { name: /read more/i })
getByRole('checkbox', { name: /agree/i })
getByRole('combobox') // <select>
getByRole('status') // role="status"
getByRole('alert') // role="alert"
getByRole('dialog') // <dialog> or role="dialog"
getByRole('navigation') // <nav>
Always call userEvent.setup() before rendering. All methods are async.
| Method | Purpose |
|---|---|
user.click(el) | Click |
user.dblClick(el) | Double-click |
user.type(el, 'text') | Type text (appends to existing value) |
user.clear(el) | Clear input value |
user.selectOptions(el, 'value') | Select dropdown option |
user.tab() | Tab to next focusable element |
user.keyboard('{Enter}') | Press a key |
user.hover(el) / user.unhover(el) | Mouse hover |
user.upload(el, file) | File upload |
Pattern:
const user = userEvent.setup();
render(<Component />);
await user.click(screen.getByRole('button', { name: /save/i }));
findBy — element appears after async workrender(<BlogList />);
expect(await screen.findByText('Blog Title')).toBeInTheDocument();
waitFor — multiple assertions, complex conditionsawait waitFor(() => {
expect(screen.getAllByRole('listitem')).toHaveLength(3);
});
waitForElementToBeRemoved — element disappearsrender(<BlogList />);
await waitForElementToBeRemoved(() => screen.queryByText('Loading...'));
setTimeout or fixed delaysact() directly unless testing hooks outside components — RTL wraps itfindBy is preferred over waitFor + getBy for single-element waitswaitFor retries until the callback passes or times out (default 1000ms)1. Arrange — render the component with props/providers
2. Act — simulate user interaction via userEvent
3. Assert — check what the user would see
Combine all three into a single test when they form one user flow. Don't split Arrange/Act/Assert into separate it() blocks.
Create a local renderComponent function when the component needs providers:
const renderComponent = (props = {}) =>
render(
<MemoryRouter>
<MyComponent defaultProp="value" {...props} />
</MemoryRouter>
);
// queryBy returns null — safe with .not
expect(screen.queryByText('Error')).not.toBeInTheDocument();
withinconst card = screen.getByRole('article');
expect(within(card).getByText('Title')).toBeInTheDocument();
Use renderHook for hooks with complex pure logic only. If a hook just fetches data or manages simple state, test it through the component that uses it instead.
import { renderHook, act } from '@testing-library/react';
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);
For hooks needing providers, pass a wrapper:
renderHook(() => useAuth(), {
wrapper: ({ children }) => <AuthProvider>{children}</AuthProvider>,
});
Components using <Link>, useNavigate, useParams, or useLocation must be wrapped:
// Simple
render(<MemoryRouter><MyComponent /></MemoryRouter>);
// With route params
render(
<MemoryRouter initialEntries={['/blogs/123']}>
<Routes>
<Route path="/blogs/:id" element={<BlogDetail />} />
</Routes>
</MemoryRouter>
);
Intercepts at the network layer. Tests don't couple to HTTP client internals. Most realistic approach.
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
// Default happy-path handlers
http.get('/api/blogs', () => HttpResponse.json({ success: true, blogs: [...] })),
http.post('/api/blogs', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ success: true, blog: { _id: '1', ...body } }, { status: 201 });
}),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Override for specific tests:
it('handles error', async () => {
server.use(
http.get('/api/blogs', () => HttpResponse.json({ success: false }, { status: 500 })),
);
// ...
});
vi.mock) — fallback when MSW is overkillvi.mock('../../api/blogApi', () => ({
getBlogs: vi.fn(),
}));
beforeEach: vi.clearAllMocks()vi.mocked(fn) for type-safe access to mock methodsWrap component in a test provider with controlled values. Don't mock context internals — render with the real provider.
vi.useFakeTimers();
// ... render and trigger timer-dependent code
vi.advanceTimersByTime(3000);
vi.useRealTimers(); // restore in afterEach
Test (as user-visible flows):
Skip:
useState values)| Matcher | Checks |
|---|---|
toBeInTheDocument() | Element is in the DOM |
toBeVisible() | Element is visible to the user |
toBeEnabled() / toBeDisabled() | Enabled/disabled state |
toHaveTextContent(/text/i) | Contains text |
toHaveValue('val') | Input/select current value |
toHaveAttribute('href', '/path') | HTML attribute |
toBeChecked() | Checkbox/radio is checked |
toHaveFocus() | Element has focus |
toBeRequired() | Input is required |
toHaveClass('cls') | Has CSS class (use sparingly) |
toHaveAccessibleDescription() | aria-describedby text |
toBeEmptyDOMElement() | No visible content |
BlogCard.jsx -> BlogCard.test.jsx.test.jsx extension (not .spec.jsx)describe per component/hook"user fills form and sees success message"vi.fn() for all mock functionsuserEvent.setup() before render()screen — never destructure from render()| Anti-Pattern | Fix |
|---|---|
| Many tiny tests with one assertion each | Combine into fewer flow tests that walk through a user journey |
fireEvent.click() | Use await user.click() from userEvent.setup() |
Destructuring from render() | Use screen.getByRole(...) |
getByTestId as first choice | Try getByRole, getByLabelText, getByText first |
Testing useState / hook internals | Test the rendered output instead |
setTimeout / fixed delays | Use findBy or waitFor |
| Snapshot tests replacing behavior tests | Write explicit assertions |
container.querySelector() | Use RTL queries |
| Shared mutable state between tests | Reset in beforeEach |
Importing from jest | Import from vitest (vi.fn(), vi.mock()) |
| Mocking what you're testing | Mock dependencies, not the subject |
act() wrapping RTL calls | RTL handles act() internally |
| Mocking Axios/fetch directly | Use MSW for network-level mocking |
| Testing every prop combination | Test the meaningful user-facing differences only |
Guides completion of development work by verifying tests, detecting environment, and presenting structured options for merge, PR, or cleanup.
Enforces test-driven development: write failing test first, then minimal code to pass. Use when implementing features or bugfixes.
Guides creation and editing of skills using test-driven development with pressure scenarios and subagents to verify agent compliance.
6plugins reuse this skill
First indexed Jul 20, 2026
npx claudepluginhub burnjohn/ai-demo-marketplace --plugin testing-skills