| name | react-testing |
| description | Complete React testing system. PROACTIVELY activate for: (1) Vitest/Jest setup and configuration, (2) React Testing Library patterns, (3) Component testing with userEvent, (4) Custom hook testing with renderHook, (5) Mocking modules and components, (6) Async component testing, (7) Context and provider testing, (8) Accessibility testing with jest-axe. Provides: Test setup, query priority, user simulation, mock patterns, integration testing. Ensures reliable tests that focus on user behavior. |
Quick Reference
| Tool | Purpose | Install |
|---|
| Vitest | Test runner | npm i -D vitest |
| @testing-library/react | Component testing | npm i -D @testing-library/react |
| @testing-library/user-event | User simulation | npm i -D @testing-library/user-event |
| jest-axe | Accessibility testing | npm i -D jest-axe |
| Query | When to Use |
|---|
getByRole | Best - accessible elements |
getByLabelText | Form inputs |
getByText | Static text |
getByTestId | Last resort |
| Pattern | Example |
|---|
| Setup user | const user = userEvent.setup() |
| Click | await user.click(button) |
| Type | await user.type(input, 'text') |
| Wait for async | await waitFor(() => expect(...)) |
When to Use This Skill
Use for React testing implementation:
- Setting up Vitest or Jest with React
- Writing component tests with Testing Library
- Testing forms, async operations, hooks
- Mocking API calls and modules
- Testing components with context/providers
- Adding accessibility tests
For component patterns: see react-patterns
React Testing Guide
Testing Tools
Setup with Vitest
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
css: true,
},
});
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});
Setup with Jest
npm install -D jest @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-environment-jsdom
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/test/setup.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
transform: {
'^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: 'tsconfig.json' }],
},
};
Component Testing
Basic Component Test
interface ButtonProps {
onClick: () => void;
children: React.ReactNode;
disabled?: boolean;
}
export function Button({ onClick, children, disabled = false }: ButtonProps) {
return (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
);
}
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';
describe('Button', () => {
it('renders children correctly', () => {
render(<Button onClick={() => {}}>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole());
(handleClick).();
});
(, {
();
(screen.()).();
});
(, () => {
handleClick = vi.();
user = userEvent.();
();
user.(screen.());
(handleClick)..();
});
});
Testing Forms
interface LoginFormProps {
onSubmit: (data: { email: string; password: string }) => Promise<void>;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsSubmitting(true);
try {
await onSubmit({ email, password });
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
();
}
};
(
);
}
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('submits form with email and password', async () => {
const handleSubmit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /login/i }));
await waitFor( {
(handleSubmit).({
: ,
: ,
});
});
});
(, () => {
handleSubmit = vi.().( ());
user = userEvent.();
();
user.(screen.(), );
user.(screen.(), );
user.(screen.(, { : }));
( {
(screen.()).();
});
});
(, () => {
: ;
handleSubmit = vi.().(
( { resolveSubmit = resolve; })
);
user = userEvent.();
();
user.(screen.(), );
user.(screen.(), );
user.(screen.(, { : }));
(screen.()).();
(screen.()).();
resolveSubmit!();
( {
(screen.())..();
});
});
});
Testing Async Components
export function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetchUser(userId)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!user) return <div>User not found</div>;
return (
<div>
<h1>{user.name}</h1>
{user.email}
);
}
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserProfile } from './UserProfile';
import * as api from './api';
vi.mock('./api');
describe('UserProfile', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('shows loading state initially', () => {
vi.mocked(api.fetchUser).mockImplementation(
() => new Promise(() => {})
);
render(<UserProfile userId="123" />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('displays user data when loaded', async () => {
vi.mocked(api.fetchUser).mockResolvedValue({
: ,
: ,
: ,
});
();
( {
(screen.()).();
});
(screen.()).();
});
(, () => {
vi.(api.).( ());
();
( {
(screen.()).();
});
});
});
Testing Hooks
Custom Hook Testing
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => setCount((c) => c + 1), []);
const decrement = useCallback(() => setCount((c) => c - 1), []);
const reset = useCallback(() => setCount(initialValue), [initialValue]);
return { count, increment, decrement, reset };
}
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('initializes with provided value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increments counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result..).();
});
(, {
{ result } = ( ());
( {
result..();
});
(result..).();
});
(, {
{ result } = ( ());
( {
result..();
result..();
});
(result..).();
( {
result..();
});
(result..).();
});
});
Testing Hooks with Dependencies
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useFetch } from './useFetch';
describe('useFetch', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('fetches data successfully', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: 'Test' }),
});
const { result } = renderHook(() => useFetch('/api/data'));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe();
});
(result..).({ : , : });
(result..).();
});
(, () => {
. = vi.().({
: ,
: ,
});
{ result } = ( ());
( {
(result..).();
});
(result..).();
(result..).();
});
(, () => {
. = vi.().({
: ,
: .({ : }),
});
{ result, rerender } = (
(url),
{ : { : } }
);
( {
(result..).({ : });
});
. = vi.().({
: ,
: .({ : }),
});
({ : });
( {
(result..).({ : });
});
});
});
Testing with Context
const ThemeContext = createContext<{
theme: 'light' | 'dark';
toggle: () => void;
} | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggle = () => setTheme((t) => (t === 'light' ? 'dark' : 'light'));
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used within ThemeProvider');
return context;
}
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { ThemeProvider } from './ThemeContext';
import { ThemedButton } from './ThemedButton';
function renderWithTheme(ui: React.ReactElement) {
return render(<ThemeProvider>{ui}</ThemeProvider>);
}
describe('ThemedButton', () => {
it('uses light theme by default', () => {
renderWithTheme(<ThemedButton>Click me</ThemedButton>);
expect(screen.getByRole('button')).toHaveClass('light');
});
it('toggles theme on click', async () => {
user = userEvent.();
();
button = screen.();
(button).();
user.(button);
(button).();
user.(button);
(button).();
});
});
Mocking
Mocking Modules
export async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
import { vi } from 'vitest';
vi.mock('./api', () => ({
fetchUser: vi.fn(),
}));
vi.mock('./api', async (importOriginal) => {
const actual = await importOriginal<typeof import('./api')>();
return {
...actual,
fetchUser: vi.fn(),
};
});
Mocking Components
vi.mock('./ExpensiveChart', () => ({
ExpensiveChart: ({ data }: { data: number[] }) => (
<div data-testid="mock-chart">Chart with {data.length} points</div>
),
}));
Mocking Timers
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
describe('Debounced Input', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('debounces input changes', async () => {
const onChange = vi.fn();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<DebouncedInput onChange={onChange} delay={300} />);
await user.type(screen.getByRole('textbox'), 'hello');
expect(onChange).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(onChange).toHaveBeenCalledWith('hello');
expect(onChange).toHaveBeenCalledTimes(1);
});
});
Integration Testing
Testing Data Flow
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { TodoApp } from './TodoApp';
describe('TodoApp Integration', () => {
it('allows adding, completing, and deleting todos', async () => {
const user = userEvent.setup();
render(<TodoApp />);
const input = screen.getByPlaceholderText(/add todo/i);
await user.type(input, 'Buy groceries{Enter}');
const todo = screen.getByText('Buy groceries');
expect(todo).toBeInTheDocument();
const checkbox = screen.getByRole('checkbox');
await user.click(checkbox);
expect(checkbox).toBeChecked();
deleteButton = screen.(, { : });
user.(deleteButton);
(screen.())..();
});
(, () => {
user = userEvent.();
();
input = screen.();
user.(input, );
user.(input, );
checkboxes = screen.();
user.(checkboxes[]);
user.(screen.(, { : }));
(screen.())..();
(screen.()).();
user.(screen.(, { : }));
(screen.()).();
(screen.())..();
});
});
Accessibility Testing
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { describe, it, expect } from 'vitest';
expect.extend(toHaveNoViolations);
describe('Accessibility', () => {
it('Form has no accessibility violations', async () => {
const { container } = render(<LoginForm onSubmit={() => {}} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('Navigation has no accessibility violations', async () => {
const { container } = render(<Navigation />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
Best Practices
1. Query Priority
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText(/email/i);
screen.getByPlaceholderText(/search/i);
screen.getByText(/welcome/i);
screen.getByDisplayValue(/john/i);
screen.getByTestId('custom-element');
2. Avoid Implementation Details
expect(component.state.isOpen).toBe(true);
expect(screen.getByRole('dialog')).toBeVisible();
3. Test User Behavior
fireEvent.click(button);
const user = userEvent.setup();
await user.click(button);
4. Use Test Utilities
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from './ThemeContext';
function AllProviders({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>{children}</ThemeProvider>
</QueryClientProvider>
);
}
export function renderWithProviders(
ui: React.ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) {
return render(ui, { wrapper: AllProviders, ...options });
}
export * ;
{ renderWithProviders render };
Testing Video Components
Mocking HTMLMediaElement in jsdom
jsdom does not implement the HTML5 media API. Methods like play(), pause(), and load() throw "Not implemented" errors. Mock them in your test setup:
Object.defineProperty(window.HTMLMediaElement.prototype, 'play', {
configurable: true,
value: vi.fn().mockResolvedValue(undefined),
});
Object.defineProperty(window.HTMLMediaElement.prototype, 'pause', {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(window.HTMLMediaElement.prototype, 'load', {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(window.HTMLMediaElement.prototype, 'duration', {
configurable: true,
get() { return 120; },
});
.(.., , {
: ,
: ,
: ,
});
Testing Video State Transitions
import { useRef, useState, useCallback } from 'react';
interface VideoPlayerProps {
src: string;
onPlay?: () => void;
onPause?: () => void;
onEnded?: () => void;
}
export function VideoPlayer({ src, onPlay, onPause, onEnded }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [status, setStatus] = useState<'idle' | 'loading' | 'playing' | 'paused' | 'ended'>('idle');
const handlePlay = useCallback(() => {
setStatus('playing');
onPlay?.();
}, [onPlay]);
const handlePause = useCallback(() => {
setStatus('paused');
onPause?.();
}, [onPause]);
const handleEnded = useCallback(() => {
setStatus('ended');
onEnded?.();
}, [onEnded]);
const togglePlayback = useCallback( {
video = videoRef.;
(!video) ;
(video.) {
video.();
} {
video.();
}
}, []);
(
);
}
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { VideoPlayer } from './VideoPlayer';
describe('VideoPlayer', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders video element with correct src', () => {
render(<VideoPlayer src="test-video.mp4" />);
const video = screen.getByTestId('video-element') as HTMLVideoElement;
expect(video).toBeInTheDocument();
expect(video).toHaveAttribute('src', 'test-video.mp4');
});
it('calls play() on the video element when Play button is clicked', async () => {
const user = userEvent.setup();
render(<VideoPlayer = />);
playButton = screen.(, { : });
user.(playButton);
video = screen.() ;
(video.).();
});
(, {
();
video = screen.();
fireEvent.(video);
(screen.()).();
});
(, {
();
video = screen.();
fireEvent.(video);
(screen.()).();
fireEvent.(video);
(screen.()).();
});
(, {
();
video = screen.();
fireEvent.(video);
fireEvent.(video);
(screen.()).();
});
(, {
handlePlay = vi.();
();
fireEvent.(screen.());
(handlePlay).();
});
(, {
handleEnded = vi.();
();
fireEvent.(screen.());
(handleEnded).();
});
(, {
();
fireEvent.(screen.());
(screen.()).();
});
});
Mocking IntersectionObserver for Lazy-Loaded Video
export function mockIntersectionObserver() {
const observerMap = new Map<Element, (entries: IntersectionObserverEntry[]) => void>();
const MockIntersectionObserver = vi.fn((callback: IntersectionObserverCallback) => ({
observe: vi.fn((element: Element) => {
observerMap.set(element, (entries) => callback(entries, {} as IntersectionObserver));
}),
unobserve: vi.fn((element: Element) => {
observerMap.delete(element);
}),
disconnect: vi.fn(() => {
observerMap.clear();
}),
}));
window.IntersectionObserver = MockIntersectionObserver as any;
() {
callback = observerMap.(element);
(callback) {
([{ isIntersecting, : element } ]);
}
}
{ , simulateIntersection };
}
import { render, screen, act } from '@testing-library/react';
import { describe, it, expect, beforeEach } from 'vitest';
import { LazyVideo } from './LazyVideo';
import { mockIntersectionObserver } from '../test/mocks/intersection-observer';
describe('LazyVideo', () => {
let simulateIntersection: (element: Element, isIntersecting: boolean) => void;
beforeEach(() => {
const mock = mockIntersectionObserver();
simulateIntersection = mock.simulateIntersection;
});
it('does not load video source until visible', () => {
render(<LazyVideo src="lazy-video.mp4" />);
const video = screen.getByTestId('video-element') as HTMLVideoElement;
expect(video).not.toHaveAttribute('src', );
});
(, {
();
container = screen.();
( {
(container, );
});
video = screen.() ;
(video).(, );
});
(, {
();
container = screen.();
( {
(container, );
});
( {
(container, );
});
video = screen.() ;
(video.).();
});
});
Testing Video Event Handlers
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { VideoPlayer } from './VideoPlayer';
describe('Video event handlers', () => {
it('handles onTimeUpdate events', () => {
const handleTimeUpdate = vi.fn();
render(
<VideoPlayer src="test.mp4" onTimeUpdate={handleTimeUpdate} />
);
const video = screen.getByTestId('video-element');
Object.defineProperty(video, 'currentTime', {
writable: true,
value: 30.5,
});
fireEvent.timeUpdate(video);
expect(handleTimeUpdate).toHaveBeenCalled();
});
it('handles onLoadedMetadata to get video dimensions and duration', () => {
const handleMetadata = vi.fn();
render(
);
video = screen.();
.(video, , { : });
.(video, , { : });
.(video, , { : });
fireEvent.(video);
(handleMetadata).();
});
(, {
handleError = vi.();
(
);
video = screen.();
fireEvent.(video);
(handleError).();
});
});
Additional References
For comprehensive testing patterns and recipes, see:
references/testing-recipes.md - Complete testing patterns for forms, async, hooks, mocking, and accessibility