Component Testing with Testing Library and MSW
Write reliable, maintainable component tests that verify user-facing behavior rather than implementation details. This skill makes the model use Testing Library query priorities (role → label → text → placeholder → test-id), render hooks directly for custom hook testing, Mock Service Worker for API mocking, and async patterns to handle loading/error/empty states in rendered components.
TL;DR Checklist
When to Use
Use this skill when:
- Writing unit-level component tests for React, Vue, or Svelte applications
- Testing how a component renders in different states (loading, error, empty, success)
- Mocking API responses for component integration testing with MSW
- Testing custom hooks (
useAuth, useForm, useFetch) in isolation
- Building test suites with Vitest or Jest for frontend component libraries
- Auditing existing component tests for implementation-detail anti-patterns
- Setting up form validation, async data fetching, and user interaction tests
When NOT to Use
Avoid this skill for:
- End-to-end browser testing across real URLs — use Playwright or Cypress instead
- Backend API unit testing — the
testing-unit-integration-e2e skill covers general backend patterns
- Visual regression testing (screenshot diffs) — no existing visual regression skill covers this domain directly
- Accessibility scanning of full rendered pages — axe-core CI integration is a separate concern
- Performance benchmarking or load testing components
Core Workflow
Set Up Test Environment — Configure the test runner (Vitest recommended for 2025+ projects) with Testing Library and MSW. Ensure proper cleanup after each test to prevent state leakage between test runs.
Checkpoint: Verify beforeEach / afterEach hooks call server.resetHandlers() in MSW and that all rendered components are unmounted. Check for memory leak warnings in CI logs.
Write User-Facing Assertions — For each component test, identify what a real user would see and interact with. Select the appropriate Testing Library query method following priority order: role > accessible name > text content > placeholder > test-id.
Checkpoint: Every getBy* or queryBy* call uses a semantic selector. No query targets CSS class names, element IDs used only for styling, or DOM structure (e.g., container.querySelector('.inner-wrapper .title')).
Handle Async States — Components that fetch data must be tested through their complete state lifecycle: loading → success/error → user interaction. Use findBy* queries (which auto-retry) or explicit waitFor() with custom assertions.
Checkpoint: Confirm every async test uses proper async/await and doesn't race between rendering and assertion. Loading states should be explicitly verified before asserting final rendered content.
Mock API Responses with MSW — For components that depend on external APIs, define handlers in MSW that return predictable responses. Use the same handler pattern for success cases, error cases, and network failure simulation.
Checkpoint: Verify MSW handlers cover: (a) successful response, (b) error response with status code, (c) loading state before any response arrives. Ensure server.resetHandlers() runs after each test to prevent cross-test contamination.
Test Custom Hooks in Isolation — For hooks like authentication, data fetching, or form management, use renderHook() to test hook behavior without rendering a component. Force state transitions with act() and verify resulting values.
Checkpoint: Each hook test uses act() for async operations. Verify that the hook properly handles: initial state, successful operation, error recovery, and cleanup (unmount). Test edge cases like concurrent calls.
Verify Accessibility Warnings — Let Testing Library's built-in accessibility checks catch common violations. Check that console warnings from RTL are reviewed and fixed in test output.
Checkpoint: No component renders with known WCAG 2.1 AA violations that Testing Library detects (missing labels, empty buttons, missing alt text). If a warning is intentional (e.g., decorative icon), add an aria-hidden attribute.
Implementation Patterns
Pattern 1: User-Facing Component Tests (React)
Testing Library's core philosophy: test what users see and do, not how the component is implemented. Queries follow a priority system — role queries are most user-friendly because screen readers also use roles.
// ❌ BAD: Testing implementation details — queries by CSS class, tests internal state
import { render, screen } from '@testing-library/react';
import UserProfile from './UserProfile';
test('user profile loads', () => {
// Implementation leak: querying by CSS class
const avatar = document.querySelector('.avatar-image');
expect(avatar).toBeInTheDocument();
// Testing internal state instead of user-visible content
const nameEl = document.querySelector('[class*="profile-name"]');
expect(nameEl?.textContent).toBe('John Doe');
// No loading state verification — race condition with network call
});
// ❌ BAD: Synchronous assertion on async component without handling pending state
test('displays user data', () => {
render(<UserProfile userId={1} />);
// Fails intermittently: network request may not resolve before assertion runs
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
// ✅ GOOD: Testing by user-facing queries with proper async handling
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import UserProfile from './UserProfile';
test('displays user name and email after loading', async () => {
const user = userEvent.setup();
render(<UserProfile userId={1} />);
// Loading state — user sees a spinner or skeleton
expect(screen.getByRole('progressbar')).toBeInTheDocument();
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Async: findBy* auto-retries with built-in waitFor (default 1000ms timeout)
await screen.findByRole('heading', { name: /John Doe/i });
expect(screen.getByText('john@example.com')).toBeInTheDocument();
// User interaction — clicking edit button navigates or opens dialog
await user.click(screen.getByRole('button', { name: /edit profile/i }));
await screen.findByRole('dialog', { name: /edit profile/i });
});
// ✅ GOOD: Testing error state with MSW mock for API failure
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.get('https://api.example.com/users/:id', () => {
return HttpResponse.json(
{ error: 'User not found' },
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
})
);
beforeAll(() => server.listen());
afterEach(() => {
server.resetHandlers();
server.close();
});
test('shows error message when user fetch fails', async () => {
render(<UserProfile userId={999} />);
// Verify loading state appears first
expect(screen.getByRole('progressbar')).toBeInTheDocument();
// Wait for error state to be displayed
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/user not found/i);
});
// Verify retry option is available
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
});
Pattern 2: MSW API Mocking for Complete State Flows
Mock Service Worker intercepts real fetch/XHR requests at the network layer. This means tests hit the actual DOM without needing to inject mock APIs through props or context — components behave as they would in production.
// ✅ GOOD: Complete MSW setup covering all component states
import { http, HttpResponse, graphql } from 'msw';
import { setupServer } from 'msw/node';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
// Define handlers once — reuse across tests via server.resetHandlers()
const server = setupServer(
// REST API — successful response
http.get('https://api.example.com/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Jane Smith',
email: 'jane@example.com',
avatar: '/avatars/jane.png',
role: 'admin' as const,
}, { headers: { 'Content-Type': 'application/json' } });
}),
// REST API — error response
http.get('https://api.example.com/users/:id', ({ params }) => {
if (params.id === 'deleted') {
return HttpResponse.json(
{ error: 'User has been deleted' },
{ status: 410, headers: { 'Content-Type': 'application/json' } }
);
}
return HttpResponse.next(); // Continue to next handler if not matched
}),
// REST API — network failure simulation
http.get('https://api.example.com/users/:id', () => {
return new HttpResponse(null, { status: 503 });
}),
// GraphQL queries (if component uses GraphQL)
graphql.query('GetUser', ({ variables }) => {
if (variables.id === '1') {
return HttpResponse.json({
data: { user: { name: 'Bob Jones', email: 'bob@example.com' } },
});
}
return HttpResponse.json(
{ errors: [{ message: 'User not found' }] },
{ status: 404 }
);
}),
// GraphQL mutations — simulate network delay
graphql.mutation('UpdateUser', async () => {
await new Promise(resolve => setTimeout(resolve, 100)); // Simulate latency
return HttpResponse.json({
data: { updateUser: { name: 'Bob Updated', email: 'bob.new@example.com' } },
});
}),
);
beforeAll(() => server.listen());
afterEach(() => {
server.resetHandlers(); // Critical: prevents test A's handlers from affecting test B
server.close();
});
test('renders user profile with loaded data', async () => {
render(<UserProfile userId="1" />);
// Verify loading state appears immediately
expect(screen.getByRole('progressbar')).toBeInTheDocument();
// Wait for data to resolve
await screen.findByRole('heading', { name: /Jane Smith/i });
expect(screen.getByText('jane@example.com')).toBeInTheDocument();
});
test('handles 410 Gone response gracefully', async () => {
render(<UserProfile userId="deleted" />);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/deleted/i);
});
// Verify user can return to list
expect(screen.getByRole('link', { name: /back to users/i })).toBeInTheDocument();
});
Pattern 3: Custom Hook Testing with renderHook
Custom hooks encapsulate logic (state management, data fetching, form validation) that should be tested independently of any rendering framework. renderHook provides direct access to the hook's return value without DOM rendering overhead.
// ✅ GOOD: Direct hook testing with renderHook — no component needed
import { renderHook, act } from '@testing-library/react';
import { useAuth } from './useAuth';
describe('useAuth', () => {
test('starts in unauthenticated state', async () => {
const { result } = renderHook(() => useAuth());
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBeNull();
expect(result.current.login).toBeInstanceOf(Function);
expect(result.current.logout).toBeInstanceOf(Function);
});
test('logs in user and updates state', async () => {
const { result } = renderHook(() => useAuth());
await act(async () => {
await result.current.login({ id: '1', name: 'Alice' });
});
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user?.name).toBe('Alice');
expect(result.current.user?.id).toBe('1');
});
test('handles login failure without mutating state', async () => {
const { result } = renderHook(() => useAuth());
await act(async () => {
try {
await result.current.login({ id: '999', name: 'Nobody' });
} catch { /* Expected — mock returns error */ }
});
// State should be unchanged after failed login
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBeNull();
});
test('logs out and clears session', async () => {
const { result, unmount } = renderHook(() => useAuth());
// Log in first
await act(async () => {
await result.current.login({ id: '1', name: 'Alice' });
});
expect(result.current.isAuthenticated).toBe(true);
// Now log out
await act(() => {
result.current.logout();
});
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBeNull();
unmount(); // Test cleanup side effects (e.g., token refresh abort)
});
});
// ✅ GOOD: Form hook testing with realistic user interactions
import { renderHook, act } from '@testing-library/react';
import { useForm } from './useForm';
describe('useForm', () => {
test('validates required fields on submit', async () => {
const { result } = renderHook(() => useForm({
schema: { email: 'required|email', password: 'required|min:8' },
}));
// Submit with empty values
await act(async () => {
try {
await result.current.submit();
} catch (errors) { /* Validation errors */ }
});
expect(result.current.errors).toHaveProperty('email');
expect(result.current.errors).toHaveProperty('password');
});
test('updates field value and clears error', async () => {
const { result, rerender } = renderHook(
({ schema }) => useForm({ schema }),
{ initialProps: { schema: { email: 'required|email' } } }
);
// Simulate user typing — setValue triggers re-render in real hook
await act(async () => {
result.current.setValue('email', 'invalid-email');
});
expect(result.current.errors.email).toBeTruthy();
await act(async () => {
result.current.setValue('email', 'valid@email.com');
});
expect(result.current.values.email).toBe('valid@email.com');
expect(result.current.errors.email).toBeFalsy();
});
});
Pattern 4: Form Testing with userEvent and Validation
Forms are the most complex UI interaction pattern. Testing them requires simulating real user input, verifying validation feedback, and ensuring form submission behavior is correct.
// ❌ BAD: Testing form by directly manipulating DOM values
import { render, screen } from '@testing-library/react';
import Loginform from './LoginForm';
test('login form validates email', () => {
const { container } = render(<LoginForm />);
// Directly setting input value bypasses React's state management
const input = container.querySelector('input[type="email"]') as HTMLInputElement;
input.value = 'invalid';
input.dispatchEvent(new Event('change', { bubbles: true }));
// No userEvent, no proper async handling for validation feedback
});
// ✅ GOOD: Testing form with userEvent setup and validation flows
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.post('https://api.example.com/auth/login', async ({ request }) => {
const body = await request.json();
if (body.email === 'test@example.com' && body.password === 'password123') {
return HttpResponse.json({ token: 'mock-jwt-token', user: { id: '1', name: 'Test User' } });
}
return HttpResponse.json(
{ error: 'Invalid credentials' },
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
})
);
beforeAll(() => server.listen());
afterEach(() => {
server.resetHandlers();
server.close();
});
test('login form shows validation errors for invalid input', async () => {
const user = userEvent.setup();
render(<LoginForm />);
// Type into fields using userEvent (simulates real keyboard input)
await user.type(screen.getByLabelText(/email/i), 'not-an-email');
await user.type(screen.getByLabelText(/password/i, { selector: 'input' }), 'short');
// Submit the form
await user.click(screen.getByRole('button', { name: /sign in/i }));
// Validation errors appear — Testing Library catches these as console warnings too
await waitFor(() => {
expect(screen.getByText(/invalid email/i)).toBeInTheDocument();
});
});
test('login form submits successfully with valid credentials', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.type(screen.getByLabelText(/password/i, { selector: 'input' }), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
// Wait for auth response and redirect
await screen.findByText(/welcome back/i);
// Verify token stored (implementation detail — acceptable to assert side effects)
const savedToken = localStorage.getItem('auth_token');
expect(savedToken).toBe('mock-jwt-token');
});
test('login form displays server error on invalid credentials', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), 'wrong@example.com');
await user.type(screen.getByLabelText(/password/i, { selector: 'input' }), 'wrongpass123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
// Server returns 401 — error toast/banner appears
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/invalid credentials/i);
});
});
Pattern 5: Async Component State Testing (Loading → Error → Success)
Components that fetch data have multiple render states. Tests must cover each state explicitly, not just the happy path. This pattern ensures no regressions in error handling or loading UX.
// ✅ GOOD: Complete async state coverage for a data-fetching component
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ArticleList from './ArticleList';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const articles = [
{ id: '1', title: 'Getting Started with Testing Library', published: true },
{ id: '2', title: 'Advanced Hook Patterns', published: false },
];
const server = setupServer(
// Success: paginated article list
http.get('https://api.example.com/articles', () => {
return HttpResponse.json({ data: articles, total: 2, page: 1 });
}),
// Error: server returns 500
http.get('https://api.example.com/articles/fail', () => {
return new HttpResponse(null, { status: 500 });
}),
// Empty: no articles match query
http.get('https://api.example.com/articles/empty', () => {
return HttpResponse.json({ data: [], total: 0, page: 1 });
}),
);
beforeAll(() => server.listen());
afterEach(() => {
server.resetHandlers();
server.close();
});
test('article list shows loading state initially', async () => {
render(<ArticleList endpoint="/articles" />);
// Loading skeleton or spinner appears immediately (synchronously)
expect(screen.getByRole('progressbar')).toBeInTheDocument();
expect(screen.queryByRole('article')).not.toBeInTheDocument();
});
test('article list renders articles after data loads', async () => {
render(<ArticleList endpoint="/articles" />);
// Verify articles appear via findBy* (auto-retry, built-in waitFor)
const article1 = await screen.findByText(/Getting Started with Testing Library/i);
expect(article1).toBeInTheDocument();
const article2 = await screen.findByRole('heading', { name: /Advanced Hook Patterns/i });
expect(article2).toBeInTheDocument();
// Verify only published articles shown (component filters unpublished)
expect(screen.queryByText(/Advanced Hook Patterns/i)).not.toBeInTheDocument();
});
test('article list shows empty state when no results', async () => {
render(<ArticleList endpoint="/articles/empty" />);
await waitFor(() => {
expect(screen.getByText(/no articles found/i)).toBeInTheDocument();
});
// Empty state should have a CTA to adjust filters
const cta = screen.getByRole('button', { name: /browse all articles/i });
expect(cta).toBeInTheDocument();
});
test('article list handles server error and offers retry', async () => {
render(<ArticleList endpoint="/articles/fail" />);
// Verify error state is displayed
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/failed to load/i);
});
// Retry button exists and works
const user = userEvent.setup();
const retryBtn = screen.getByRole('button', { name: /retry/i });
expect(retryBtn).toBeInTheDocument();
await user.click(retryBtn);
// After retry with no new MSW handler, falls through to success handler
await screen.findByText(/Getting Started with Testing Library/i);
});
Constraints
MUST DO
- Always query by semantic role first (
getByRole), then accessible name, then text content — never by CSS class names or DOM structure
- Use
userEvent.setup() for all user interactions (clicks, typing, hover) — never fireEvent for user actions unless testing the event directly
- Handle every async state explicitly: loading → success/error transitions must have dedicated assertions
- Always call
server.resetHandlers() in afterEach to prevent MSW handler leakage between tests
- Use
waitFor() with custom callback assertions (not just waitFor(() => {})) — always assert something meaningful
- Test hooks directly with
renderHook() and use act() for async state updates within hook testing
- Let Testing Library's built-in accessibility warnings surface in test output; fix violations instead of suppressing them
MUST NOT DO
- Never query elements by CSS class names (
container.querySelector('.btn-primary')) — this couples tests to implementation details
- Never assert internal component state (e.g.,
expect(component.state('isLoading')).toBe(false)) — only assert what users see
- Never use synchronous assertions on async components without proper waiting (
screen.findBy* or waitFor())
- Never share MSW handlers across tests without
server.resetHandlers() — this causes false positives and flaky tests
- Never test framework internals (React's setState, Vue's reactivity system) — test the rendered output instead
- Never skip error state testing — if a component makes a network request, it must have a tested failure path
Output Template
When implementing or reviewing component tests, produce:
- Test File Structure — The test file layout with describe blocks organized by component feature (rendering, user interactions, error states)
- MSW Handler Block — All API mock handlers needed to cover the component's state machine (success, error, empty, loading)
- Async Test Cases — Individual test functions covering each render state transition with explicit
findBy* or waitFor() assertions
- Hook Test Suite — Isolated hook tests using
renderHook() and act() for state mutations
- Test Quality Audit — List of any remaining implementation-detail queries, missing async handling, or untested edge states
Related Skills
| Skill |
Purpose |
testing-unit-integration-e2e |
General test strategy and pyramid across all languages — this skill is the frontend-specific complement |
design-systems |
Shared component library — these skills test the components built with design system tokens |
code-review |
Review test quality in pull requests, including assertion clarity and flaky test prevention |
Live References
Authoritative documentation links for this skill's domain. The model follows markdown links at load time to resolve external references and inline content.
1---2name: component-testing-library3description: Tests React, Vue, and Svelte components using Testing Library query priorities, renderHook for hooks, Mock Service Worker API mocking, and async state patterns for reliable, user-facing component tests.4license: MIT5---678910# Component Testing with Testing Library and MSW1112Write reliable, maintainable component tests that verify user-facing behavior rather than implementation details. This skill makes the model use Testing Library query priorities (role → label → text → placeholder → test-id), render hooks directly for custom hook testing, Mock Service Worker for API mocking, and async patterns to handle loading/error/empty states in rendered components.1314## TL;DR Checklist1516- [ ] Query by user-facing attributes: `getByRole`, `getByText`, `getByLabelText` — never by CSS class or DOM structure17- [ ] Use `userEvent.setup()` for all user interactions (clicks, typing, form submissions)18- [ ] Handle async UI updates with `waitFor()`, `findBy*`, or `screen.findBy*` queries19- [ ] Test loading → success and loading → error flows using MSW handlers20- [ ] Render custom hooks directly with `renderHook()` — don't test them indirectly through components21- [ ] Verify accessibility warnings from Testing Library by checking console output2223---2425## When to Use2627Use this skill when:2829- Writing unit-level component tests for React, Vue, or Svelte applications30- Testing how a component renders in different states (loading, error, empty, success)31- Mocking API responses for component integration testing with MSW32- Testing custom hooks (`useAuth`, `useForm`, `useFetch`) in isolation33- Building test suites with Vitest or Jest for frontend component libraries34- Auditing existing component tests for implementation-detail anti-patterns35- Setting up form validation, async data fetching, and user interaction tests3637---3839## When NOT to Use4041Avoid this skill for:4243- End-to-end browser testing across real URLs — use Playwright or Cypress instead44- Backend API unit testing — the `testing-unit-integration-e2e` skill covers general backend patterns45- Visual regression testing (screenshot diffs) — no existing visual regression skill covers this domain directly46- Accessibility scanning of full rendered pages — axe-core CI integration is a separate concern47- Performance benchmarking or load testing components4849---5051## Core Workflow52531. **Set Up Test Environment** — Configure the test runner (Vitest recommended for 2025+ projects) with Testing Library and MSW. Ensure proper cleanup after each test to prevent state leakage between test runs.5455 **Checkpoint:** Verify `beforeEach` / `afterEach` hooks call `server.resetHandlers()` in MSW and that all rendered components are unmounted. Check for memory leak warnings in CI logs.56572. **Write User-Facing Assertions** — For each component test, identify what a real user would see and interact with. Select the appropriate Testing Library query method following priority order: role > accessible name > text content > placeholder > test-id.5859 **Checkpoint:** Every `getBy*` or `queryBy*` call uses a semantic selector. No query targets CSS class names, element IDs used only for styling, or DOM structure (e.g., `container.querySelector('.inner-wrapper .title')`).60613. **Handle Async States** — Components that fetch data must be tested through their complete state lifecycle: loading → success/error → user interaction. Use `findBy*` queries (which auto-retry) or explicit `waitFor()` with custom assertions.6263 **Checkpoint:** Confirm every async test uses proper `async/await` and doesn't race between rendering and assertion. Loading states should be explicitly verified before asserting final rendered content.64654. **Mock API Responses with MSW** — For components that depend on external APIs, define handlers in MSW that return predictable responses. Use the same handler pattern for success cases, error cases, and network failure simulation.6667 **Checkpoint:** Verify MSW handlers cover: (a) successful response, (b) error response with status code, (c) loading state before any response arrives. Ensure `server.resetHandlers()` runs after each test to prevent cross-test contamination.68695. **Test Custom Hooks in Isolation** — For hooks like authentication, data fetching, or form management, use `renderHook()` to test hook behavior without rendering a component. Force state transitions with `act()` and verify resulting values.7071 **Checkpoint:** Each hook test uses `act()` for async operations. Verify that the hook properly handles: initial state, successful operation, error recovery, and cleanup (unmount). Test edge cases like concurrent calls.72736. **Verify Accessibility Warnings** — Let Testing Library's built-in accessibility checks catch common violations. Check that console warnings from RTL are reviewed and fixed in test output.7475 **Checkpoint:** No component renders with known WCAG 2.1 AA violations that Testing Library detects (missing labels, empty buttons, missing alt text). If a warning is intentional (e.g., decorative icon), add an `aria-hidden` attribute.7677---7879## Implementation Patterns8081### Pattern 1: User-Facing Component Tests (React)8283Testing Library's core philosophy: test what users see and do, not how the component is implemented. Queries follow a priority system — role queries are most user-friendly because screen readers also use roles.8485```typescript86// ❌ BAD: Testing implementation details — queries by CSS class, tests internal state87import { render, screen } from '@testing-library/react';88import UserProfile from './UserProfile';8990test('user profile loads', () => {91 // Implementation leak: querying by CSS class92 const avatar = document.querySelector('.avatar-image');93 expect(avatar).toBeInTheDocument();9495 // Testing internal state instead of user-visible content96 const nameEl = document.querySelector('[class*="profile-name"]');97 expect(nameEl?.textContent).toBe('John Doe');9899 // No loading state verification — race condition with network call100});101102// ❌ BAD: Synchronous assertion on async component without handling pending state103test('displays user data', () => {104 render(<UserProfile userId={1} />);105 // Fails intermittently: network request may not resolve before assertion runs106 expect(screen.getByText('John Doe')).toBeInTheDocument();107 expect(screen.getByText('john@example.com')).toBeInTheDocument();108});109110// ✅ GOOD: Testing by user-facing queries with proper async handling111import { render, screen, waitFor } from '@testing-library/react';112import userEvent from '@testing-library/user-event';113import UserProfile from './UserProfile';114115test('displays user name and email after loading', async () => {116 const user = userEvent.setup();117118 render(<UserProfile userId={1} />);119120 // Loading state — user sees a spinner or skeleton121 expect(screen.getByRole('progressbar')).toBeInTheDocument();122 expect(screen.getByText(/loading/i)).toBeInTheDocument();123124 // Async: findBy* auto-retries with built-in waitFor (default 1000ms timeout)125 await screen.findByRole('heading', { name: /John Doe/i });126 expect(screen.getByText('john@example.com')).toBeInTheDocument();127128 // User interaction — clicking edit button navigates or opens dialog129 await user.click(screen.getByRole('button', { name: /edit profile/i }));130 await screen.findByRole('dialog', { name: /edit profile/i });131});132133// ✅ GOOD: Testing error state with MSW mock for API failure134import { render, screen, waitFor } from '@testing-library/react';135import UserProfile from './UserProfile';136import { setupServer } from 'msw/node';137import { http, HttpResponse } from 'msw';138139const server = setupServer(140 http.get('https://api.example.com/users/:id', () => {141 return HttpResponse.json(142 { error: 'User not found' },143 { status: 404, headers: { 'Content-Type': 'application/json' } }144 );145 })146);147148beforeAll(() => server.listen());149afterEach(() => {150 server.resetHandlers();151 server.close();152});153154test('shows error message when user fetch fails', async () => {155 render(<UserProfile userId={999} />);156157 // Verify loading state appears first158 expect(screen.getByRole('progressbar')).toBeInTheDocument();159160 // Wait for error state to be displayed161 await waitFor(() => {162 expect(screen.getByRole('alert')).toHaveTextContent(/user not found/i);163 });164165 // Verify retry option is available166 expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();167});168```169170### Pattern 2: MSW API Mocking for Complete State Flows171172Mock Service Worker intercepts real fetch/XHR requests at the network layer. This means tests hit the actual DOM without needing to inject mock APIs through props or context — components behave as they would in production.173174```typescript175// ✅ GOOD: Complete MSW setup covering all component states176import { http, HttpResponse, graphql } from 'msw';177import { setupServer } from 'msw/node';178import { render, screen, waitFor, fireEvent } from '@testing-library/react';179180// Define handlers once — reuse across tests via server.resetHandlers()181const server = setupServer(182 // REST API — successful response183 http.get('https://api.example.com/users/:id', ({ params }) => {184 return HttpResponse.json({185 id: params.id,186 name: 'Jane Smith',187 email: 'jane@example.com',188 avatar: '/avatars/jane.png',189 role: 'admin' as const,190 }, { headers: { 'Content-Type': 'application/json' } });191 }),192193 // REST API — error response194 http.get('https://api.example.com/users/:id', ({ params }) => {195 if (params.id === 'deleted') {196 return HttpResponse.json(197 { error: 'User has been deleted' },198 { status: 410, headers: { 'Content-Type': 'application/json' } }199 );200 }201 return HttpResponse.next(); // Continue to next handler if not matched202 }),203204 // REST API — network failure simulation205 http.get('https://api.example.com/users/:id', () => {206 return new HttpResponse(null, { status: 503 });207 }),208209 // GraphQL queries (if component uses GraphQL)210 graphql.query('GetUser', ({ variables }) => {211 if (variables.id === '1') {212 return HttpResponse.json({213 data: { user: { name: 'Bob Jones', email: 'bob@example.com' } },214 });215 }216 return HttpResponse.json(217 { errors: [{ message: 'User not found' }] },218 { status: 404 }219 );220 }),221222 // GraphQL mutations — simulate network delay223 graphql.mutation('UpdateUser', async () => {224 await new Promise(resolve => setTimeout(resolve, 100)); // Simulate latency225 return HttpResponse.json({226 data: { updateUser: { name: 'Bob Updated', email: 'bob.new@example.com' } },227 });228 }),229);230231beforeAll(() => server.listen());232afterEach(() => {233 server.resetHandlers(); // Critical: prevents test A's handlers from affecting test B234 server.close();235});236test('renders user profile with loaded data', async () => {237 render(<UserProfile userId="1" />);238239 // Verify loading state appears immediately240 expect(screen.getByRole('progressbar')).toBeInTheDocument();241242 // Wait for data to resolve243 await screen.findByRole('heading', { name: /Jane Smith/i });244 expect(screen.getByText('jane@example.com')).toBeInTheDocument();245});246247test('handles 410 Gone response gracefully', async () => {248 render(<UserProfile userId="deleted" />);249250 await waitFor(() => {251 expect(screen.getByRole('alert')).toHaveTextContent(/deleted/i);252 });253254 // Verify user can return to list255 expect(screen.getByRole('link', { name: /back to users/i })).toBeInTheDocument();256});257```258259### Pattern 3: Custom Hook Testing with `renderHook`260261Custom hooks encapsulate logic (state management, data fetching, form validation) that should be tested independently of any rendering framework. `renderHook` provides direct access to the hook's return value without DOM rendering overhead.262263```typescript264// ✅ GOOD: Direct hook testing with renderHook — no component needed265import { renderHook, act } from '@testing-library/react';266import { useAuth } from './useAuth';267268describe('useAuth', () => {269 test('starts in unauthenticated state', async () => {270 const { result } = renderHook(() => useAuth());271272 expect(result.current.isAuthenticated).toBe(false);273 expect(result.current.user).toBeNull();274 expect(result.current.login).toBeInstanceOf(Function);275 expect(result.current.logout).toBeInstanceOf(Function);276 });277278 test('logs in user and updates state', async () => {279 const { result } = renderHook(() => useAuth());280281 await act(async () => {282 await result.current.login({ id: '1', name: 'Alice' });283 });284285 expect(result.current.isAuthenticated).toBe(true);286 expect(result.current.user?.name).toBe('Alice');287 expect(result.current.user?.id).toBe('1');288 });289290 test('handles login failure without mutating state', async () => {291 const { result } = renderHook(() => useAuth());292293 await act(async () => {294 try {295 await result.current.login({ id: '999', name: 'Nobody' });296 } catch { /* Expected — mock returns error */ }297 });298299 // State should be unchanged after failed login300 expect(result.current.isAuthenticated).toBe(false);301 expect(result.current.user).toBeNull();302 });303304 test('logs out and clears session', async () => {305 const { result, unmount } = renderHook(() => useAuth());306307 // Log in first308 await act(async () => {309 await result.current.login({ id: '1', name: 'Alice' });310 });311 expect(result.current.isAuthenticated).toBe(true);312313 // Now log out314 await act(() => {315 result.current.logout();316 });317318 expect(result.current.isAuthenticated).toBe(false);319 expect(result.current.user).toBeNull();320321 unmount(); // Test cleanup side effects (e.g., token refresh abort)322 });323});324325// ✅ GOOD: Form hook testing with realistic user interactions326import { renderHook, act } from '@testing-library/react';327import { useForm } from './useForm';328329describe('useForm', () => {330 test('validates required fields on submit', async () => {331 const { result } = renderHook(() => useForm({332 schema: { email: 'required|email', password: 'required|min:8' },333 }));334335 // Submit with empty values336 await act(async () => {337 try {338 await result.current.submit();339 } catch (errors) { /* Validation errors */ }340 });341342 expect(result.current.errors).toHaveProperty('email');343 expect(result.current.errors).toHaveProperty('password');344 });345346 test('updates field value and clears error', async () => {347 const { result, rerender } = renderHook(348 ({ schema }) => useForm({ schema }),349 { initialProps: { schema: { email: 'required|email' } } }350 );351352 // Simulate user typing — setValue triggers re-render in real hook353 await act(async () => {354 result.current.setValue('email', 'invalid-email');355 });356357 expect(result.current.errors.email).toBeTruthy();358359 await act(async () => {360 result.current.setValue('email', 'valid@email.com');361 });362363 expect(result.current.values.email).toBe('valid@email.com');364 expect(result.current.errors.email).toBeFalsy();365 });366});367```368369### Pattern 4: Form Testing with `userEvent` and Validation370371Forms are the most complex UI interaction pattern. Testing them requires simulating real user input, verifying validation feedback, and ensuring form submission behavior is correct.372373```typescript374// ❌ BAD: Testing form by directly manipulating DOM values375import { render, screen } from '@testing-library/react';376import Loginform from './LoginForm';377378test('login form validates email', () => {379 const { container } = render(<LoginForm />);380381 // Directly setting input value bypasses React's state management382 const input = container.querySelector('input[type="email"]') as HTMLInputElement;383 input.value = 'invalid';384 input.dispatchEvent(new Event('change', { bubbles: true }));385386 // No userEvent, no proper async handling for validation feedback387});388389// ✅ GOOD: Testing form with userEvent setup and validation flows390import { render, screen, waitFor } from '@testing-library/react';391import userEvent from '@testing-library/user-event';392import LoginForm from './LoginForm';393import { setupServer } from 'msw/node';394import { http, HttpResponse } from 'msw';395396const server = setupServer(397 http.post('https://api.example.com/auth/login', async ({ request }) => {398 const body = await request.json();399 if (body.email === 'test@example.com' && body.password === 'password123') {400 return HttpResponse.json({ token: 'mock-jwt-token', user: { id: '1', name: 'Test User' } });401 }402 return HttpResponse.json(403 { error: 'Invalid credentials' },404 { status: 401, headers: { 'Content-Type': 'application/json' } }405 );406 })407);408409beforeAll(() => server.listen());410afterEach(() => {411 server.resetHandlers();412 server.close();413});414415test('login form shows validation errors for invalid input', async () => {416 const user = userEvent.setup();417418 render(<LoginForm />);419420 // Type into fields using userEvent (simulates real keyboard input)421 await user.type(screen.getByLabelText(/email/i), 'not-an-email');422 await user.type(screen.getByLabelText(/password/i, { selector: 'input' }), 'short');423424 // Submit the form425 await user.click(screen.getByRole('button', { name: /sign in/i }));426427 // Validation errors appear — Testing Library catches these as console warnings too428 await waitFor(() => {429 expect(screen.getByText(/invalid email/i)).toBeInTheDocument();430 });431});432433test('login form submits successfully with valid credentials', async () => {434 const user = userEvent.setup();435436 render(<LoginForm />);437438 await user.type(screen.getByLabelText(/email/i), 'test@example.com');439 await user.type(screen.getByLabelText(/password/i, { selector: 'input' }), 'password123');440 await user.click(screen.getByRole('button', { name: /sign in/i }));441442 // Wait for auth response and redirect443 await screen.findByText(/welcome back/i);444445 // Verify token stored (implementation detail — acceptable to assert side effects)446 const savedToken = localStorage.getItem('auth_token');447 expect(savedToken).toBe('mock-jwt-token');448});449450test('login form displays server error on invalid credentials', async () => {451 const user = userEvent.setup();452453 render(<LoginForm />);454455 await user.type(screen.getByLabelText(/email/i), 'wrong@example.com');456 await user.type(screen.getByLabelText(/password/i, { selector: 'input' }), 'wrongpass123');457 await user.click(screen.getByRole('button', { name: /sign in/i }));458459 // Server returns 401 — error toast/banner appears460 await waitFor(() => {461 expect(screen.getByRole('alert')).toHaveTextContent(/invalid credentials/i);462 });463});464```465466### Pattern 5: Async Component State Testing (Loading → Error → Success)467468Components that fetch data have multiple render states. Tests must cover each state explicitly, not just the happy path. This pattern ensures no regressions in error handling or loading UX.469470```typescript471// ✅ GOOD: Complete async state coverage for a data-fetching component472import { render, screen, waitFor, within } from '@testing-library/react';473import userEvent from '@testing-library/user-event';474import ArticleList from './ArticleList';475import { setupServer } from 'msw/node';476import { http, HttpResponse } from 'msw';477478const articles = [479 { id: '1', title: 'Getting Started with Testing Library', published: true },480 { id: '2', title: 'Advanced Hook Patterns', published: false },481];482483const server = setupServer(484 // Success: paginated article list485 http.get('https://api.example.com/articles', () => {486 return HttpResponse.json({ data: articles, total: 2, page: 1 });487 }),488489 // Error: server returns 500490 http.get('https://api.example.com/articles/fail', () => {491 return new HttpResponse(null, { status: 500 });492 }),493494 // Empty: no articles match query495 http.get('https://api.example.com/articles/empty', () => {496 return HttpResponse.json({ data: [], total: 0, page: 1 });497 }),498);499500beforeAll(() => server.listen());501afterEach(() => {502 server.resetHandlers();503 server.close();504});505506test('article list shows loading state initially', async () => {507 render(<ArticleList endpoint="/articles" />);508509 // Loading skeleton or spinner appears immediately (synchronously)510 expect(screen.getByRole('progressbar')).toBeInTheDocument();511 expect(screen.queryByRole('article')).not.toBeInTheDocument();512});513514test('article list renders articles after data loads', async () => {515 render(<ArticleList endpoint="/articles" />);516517 // Verify articles appear via findBy* (auto-retry, built-in waitFor)518 const article1 = await screen.findByText(/Getting Started with Testing Library/i);519 expect(article1).toBeInTheDocument();520521 const article2 = await screen.findByRole('heading', { name: /Advanced Hook Patterns/i });522 expect(article2).toBeInTheDocument();523524 // Verify only published articles shown (component filters unpublished)525 expect(screen.queryByText(/Advanced Hook Patterns/i)).not.toBeInTheDocument();526});527528test('article list shows empty state when no results', async () => {529 render(<ArticleList endpoint="/articles/empty" />);530531 await waitFor(() => {532 expect(screen.getByText(/no articles found/i)).toBeInTheDocument();533 });534535 // Empty state should have a CTA to adjust filters536 const cta = screen.getByRole('button', { name: /browse all articles/i });537 expect(cta).toBeInTheDocument();538});539540test('article list handles server error and offers retry', async () => {541 render(<ArticleList endpoint="/articles/fail" />);542543 // Verify error state is displayed544 await waitFor(() => {545 expect(screen.getByRole('alert')).toHaveTextContent(/failed to load/i);546 });547548 // Retry button exists and works549 const user = userEvent.setup();550 const retryBtn = screen.getByRole('button', { name: /retry/i });551 expect(retryBtn).toBeInTheDocument();552553 await user.click(retryBtn);554555 // After retry with no new MSW handler, falls through to success handler556 await screen.findByText(/Getting Started with Testing Library/i);557});558```559560---561562## Constraints563564### MUST DO565- Always query by semantic role first (`getByRole`), then accessible name, then text content — never by CSS class names or DOM structure566- Use `userEvent.setup()` for all user interactions (clicks, typing, hover) — never `fireEvent` for user actions unless testing the event directly567- Handle every async state explicitly: loading → success/error transitions must have dedicated assertions568- Always call `server.resetHandlers()` in `afterEach` to prevent MSW handler leakage between tests569- Use `waitFor()` with custom callback assertions (not just `waitFor(() => {})`) — always assert something meaningful570- Test hooks directly with `renderHook()` and use `act()` for async state updates within hook testing571- Let Testing Library's built-in accessibility warnings surface in test output; fix violations instead of suppressing them572573### MUST NOT DO574- Never query elements by CSS class names (`container.querySelector('.btn-primary')`) — this couples tests to implementation details575- Never assert internal component state (e.g., `expect(component.state('isLoading')).toBe(false)`) — only assert what users see576- Never use synchronous assertions on async components without proper waiting (`screen.findBy*` or `waitFor()`)577- Never share MSW handlers across tests without `server.resetHandlers()` — this causes false positives and flaky tests578- Never test framework internals (React's setState, Vue's reactivity system) — test the rendered output instead579- Never skip error state testing — if a component makes a network request, it must have a tested failure path580581---582583## Output Template584585When implementing or reviewing component tests, produce:5865871. **Test File Structure** — The test file layout with describe blocks organized by component feature (rendering, user interactions, error states)5882. **MSW Handler Block** — All API mock handlers needed to cover the component's state machine (success, error, empty, loading)5893. **Async Test Cases** — Individual test functions covering each render state transition with explicit `findBy*` or `waitFor()` assertions5904. **Hook Test Suite** — Isolated hook tests using `renderHook()` and `act()` for state mutations5915. **Test Quality Audit** — List of any remaining implementation-detail queries, missing async handling, or untested edge states592593---594595## Related Skills596597| Skill | Purpose |598|---|---|599| `testing-unit-integration-e2e` | General test strategy and pyramid across all languages — this skill is the frontend-specific complement |600| `design-systems` | Shared component library — these skills test the components built with design system tokens |601| `code-review` | Review test quality in pull requests, including assertion clarity and flaky test prevention |602603---604605## Live References606607> Authoritative documentation links for this skill's domain. The model follows markdown links at load time to resolve external references and inline content.608609- [Testing Library Documentation (React)](https://testing-library.com/docs/react-testing-library/intro/)610- [Mock Service Worker v3 Documentation](https://mswjs.io/docs/)611- [Vitest Component Testing Guide](https://vitest.dev/guide/browser/)612- [MDN: userEvent API Reference](https://testing-library.com/docs/user-event/intro/)613- [React Testing Library API Reference](https://testing-library.com/docs/react-testing-library/api/)