
React Testing
- 54 installs
- 50 repo stars
- Updated June 18, 2026
- josiahsiegel/claude-plugin-marketplace
Helps with testing & qa tasks.
About
react-testing is a Claude Code skill for testing & qa. It helps solo builders move faster with AI-assisted development.
- react-testing
- Testing & QA
- AI-coding skill
React Testing by the numbers
- 54 all-time installs (skills.sh)
- +4 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #1,200 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/josiahsiegel/claude-plugin-marketplace --skill react-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 54 |
|---|---|
| repo stars | ★ 50 |
| Last updated | June 18, 2026 |
| Repository | josiahsiegel/claude-plugin-marketplace ↗ |
What it does
Helps with testing & qa tasks.
Files
React Testing Skill
Use this skill for React tests that focus on user behavior: Vitest/Jest setup, Testing Library queries, user-event flows, async UI, hooks, providers, mocks, integration tests, accessibility checks, and media component tests.
When to Use This Skill
Use when the user asks for tasks covered by the frontmatter triggers, especially implementation guidance, debugging, architecture choices, production hardening, or performance-sensitive decisions in this domain. Start from this orchestrator, then load the focused reference file that matches the requested detail level.
Core Workflow
1. Set up the runner and jsdom environment first, then centralize @testing-library/jest-dom, cleanup, and browser API mocks in test setup. 2. Write tests from the user perspective using role, label, text, placeholder, and display-value queries before falling back to test IDs. 3. Use userEvent.setup() for interactions and waitFor or findBy queries for async state changes. 4. Mock at module or network boundaries, not internal component state, and reset mocks between tests. 5. Create provider-aware render utilities for context, routers, query clients, themes, and other app-wide wrappers. 6. For media components, mock jsdom gaps such as HTMLMediaElement.play, pause, load, metadata properties, and IntersectionObserver.
Key Gotchas
- Avoid asserting implementation details such as component state; assert visible behavior and accessibility state.
fireEventis lower-level thanuserEvent; preferuserEventunless directly firing browser/media events is necessary.- Async assertions should wait for UI changes, not arbitrary timers.
- jsdom does not implement real media playback, layout, or browser codecs; mock those APIs deliberately.
- Provider state can leak between tests unless render utilities create fresh clients and cleanup runs after each test.
Reference Map
- references/react-testing-complete-guide.md - Full original guide covering setup, component tests, forms, async components, hooks, context, mocks, integration tests, accessibility tests, testing utilities, and video/media testing.
- references/testing-recipes.md - Additional testing recipes already maintained for this skill.
Response Guidance
- Preserve the user's existing framework, library, and tooling choices unless there is a clear compatibility or performance reason to suggest an alternative.
- Give copy-pasteable code only for the exact task at hand; otherwise point to the relevant reference section.
- Call out tradeoffs, failure modes, and verification steps for production workflows.
- Prefer accessible, maintainable, measurable solutions over clever micro-optimizations.
React Testing Complete Guide
This reference preserves the detailed guide content extracted from SKILL.md so the top-level skill can remain a lean orchestrator while retaining all examples, tables, recipes, and troubleshooting guidance.
---
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// vitest.config.ts
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,
},
});// src/test/setup.ts
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// jest.config.js
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
// Button.tsx
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>
);
}// Button.test.tsx
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('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('is disabled when disabled prop is true', () => {
render(<Button onClick={() => {}} disabled>Click me</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('does not call onClick when disabled', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick} disabled>Click me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
});
});Testing Forms
// LoginForm.tsx
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 {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Logging in...' : 'Login'}
</button>
</form>
);
}// LoginForm.test.tsx
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(() => {
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
});
it('displays error message on failed submission', async () => {
const handleSubmit = vi.fn().mockRejectedValue(new Error('Invalid credentials'));
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), 'wrong');
await user.click(screen.getByRole('button', { name: /login/i }));
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Invalid credentials');
});
});
it('disables submit button while submitting', async () => {
let resolveSubmit: () => void;
const handleSubmit = vi.fn().mockImplementation(
() => new Promise((resolve) => { resolveSubmit = resolve; })
);
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), 'password');
await user.click(screen.getByRole('button', { name: /login/i }));
expect(screen.getByRole('button')).toBeDisabled();
expect(screen.getByRole('button')).toHaveTextContent('Logging in...');
resolveSubmit!();
await waitFor(() => {
expect(screen.getByRole('button')).not.toBeDisabled();
});
});
});Testing Async Components
// UserProfile.tsx
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>
<p>{user.email}</p>
</div>
);
}// UserProfile.test.tsx
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(() => {}) // Never resolves
);
render(<UserProfile userId="123" />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('displays user data when loaded', async () => {
vi.mocked(api.fetchUser).mockResolvedValue({
id: '123',
name: 'John Doe',
email: 'john@example.com',
});
render(<UserProfile userId="123" />);
await waitFor(() => {
expect(screen.getByRole('heading')).toHaveTextContent('John Doe');
});
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
it('displays error when fetch fails', async () => {
vi.mocked(api.fetchUser).mockRejectedValue(new Error('Network error'));
render(<UserProfile userId="123" />);
await waitFor(() => {
expect(screen.getByText(/error: network error/i)).toBeInTheDocument();
});
});
});Testing Hooks
Custom Hook Testing
// useCounter.ts
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 };
}// useCounter.test.ts
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.current.count).toBe(1);
});
it('decrements counter', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
it('resets counter', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(7);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(5);
});
});Testing Hooks with Dependencies
// useFetch.test.ts
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(false);
});
expect(result.current.data).toEqual({ id: 1, name: 'Test' });
expect(result.current.error).toBeNull();
});
it('handles fetch error', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
});
const { result } = renderHook(() => useFetch('/api/data'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBeNull();
expect(result.current.error).toBeInstanceOf(Error);
});
it('refetches when URL changes', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1 }),
});
const { result, rerender } = renderHook(
({ url }) => useFetch(url),
{ initialProps: { url: '/api/data/1' } }
);
await waitFor(() => {
expect(result.current.data).toEqual({ id: 1 });
});
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 2 }),
});
rerender({ url: '/api/data/2' });
await waitFor(() => {
expect(result.current.data).toEqual({ id: 2 });
});
});
});Testing with Context
// ThemeContext.tsx
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;
}// ThemedButton.test.tsx
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';
// Custom render function with providers
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 () => {
const user = userEvent.setup();
renderWithTheme(<ThemedButton>Toggle</ThemedButton>);
const button = screen.getByRole('button');
expect(button).toHaveClass('light');
await user.click(button);
expect(button).toHaveClass('dark');
await user.click(button);
expect(button).toHaveClass('light');
});
});Mocking
Mocking Modules
// api.ts
export async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
// UserProfile.test.tsx
import { vi } from 'vitest';
// Mock the entire module
vi.mock('./api', () => ({
fetchUser: vi.fn(),
}));
// Or mock specific exports
vi.mock('./api', async (importOriginal) => {
const actual = await importOriginal<typeof import('./api')>();
return {
...actual,
fetchUser: vi.fn(),
};
});Mocking Components
// Mock a child component
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');
// Not called yet (within debounce window)
expect(onChange).not.toHaveBeenCalled();
// Advance time past debounce delay
vi.advanceTimersByTime(300);
expect(onChange).toHaveBeenCalledWith('hello');
expect(onChange).toHaveBeenCalledTimes(1);
});
});Integration Testing
Testing Data Flow
// TodoApp.test.tsx
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 />);
// Add a todo
const input = screen.getByPlaceholderText(/add todo/i);
await user.type(input, 'Buy groceries{Enter}');
// Verify todo was added
const todo = screen.getByText('Buy groceries');
expect(todo).toBeInTheDocument();
// Complete the todo
const checkbox = screen.getByRole('checkbox');
await user.click(checkbox);
expect(checkbox).toBeChecked();
// Delete the todo
const deleteButton = screen.getByRole('button', { name: /delete/i });
await user.click(deleteButton);
expect(screen.queryByText('Buy groceries')).not.toBeInTheDocument();
});
it('filters todos correctly', async () => {
const user = userEvent.setup();
render(<TodoApp />);
// Add todos
const input = screen.getByPlaceholderText(/add todo/i);
await user.type(input, 'Task 1{Enter}');
await user.type(input, 'Task 2{Enter}');
// Complete first task
const checkboxes = screen.getAllByRole('checkbox');
await user.click(checkboxes[0]);
// Filter by active
await user.click(screen.getByRole('button', { name: /active/i }));
expect(screen.queryByText('Task 1')).not.toBeInTheDocument();
expect(screen.getByText('Task 2')).toBeInTheDocument();
// Filter by completed
await user.click(screen.getByRole('button', { name: /completed/i }));
expect(screen.getByText('Task 1')).toBeInTheDocument();
expect(screen.queryByText('Task 2')).not.toBeInTheDocument();
});
});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
// Best - accessible queries
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText(/email/i);
screen.getByPlaceholderText(/search/i);
screen.getByText(/welcome/i);
screen.getByDisplayValue(/john/i);
// Fallback - test IDs (last resort)
screen.getByTestId('custom-element');2. Avoid Implementation Details
// Bad - testing implementation
expect(component.state.isOpen).toBe(true);
// Good - testing behavior
expect(screen.getByRole('dialog')).toBeVisible();3. Test User Behavior
// Bad - testing clicks
fireEvent.click(button);
// Good - simulating real user
const user = userEvent.setup();
await user.click(button);4. Use Test Utilities
// test-utils.tsx
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 * from '@testing-library/react';
export { renderWithProviders as 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:
// src/test/setup.ts (add to existing setup file)
// Mock HTMLMediaElement methods not implemented by jsdom
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(),
});
// Mock read-only media properties
Object.defineProperty(window.HTMLMediaElement.prototype, 'duration', {
configurable: true,
get() { return 120; }, // 2 minutes
});
Object.defineProperty(window.HTMLMediaElement.prototype, 'paused', {
configurable: true,
writable: true,
value: true,
});Testing Video State Transitions
// VideoPlayer.tsx
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(() => {
const video = videoRef.current;
if (!video) return;
if (video.paused) {
video.play();
} else {
video.pause();
}
}, []);
return (
<div>
<video
ref={videoRef}
src={src}
data-testid="video-element"
onLoadedMetadata={() => setStatus('idle')}
onPlay={handlePlay}
onPause={handlePause}
onEnded={handleEnded}
onWaiting={() => setStatus('loading')}
playsInline
/>
<button onClick={togglePlayback}>
{status === 'playing' ? 'Pause' : 'Play'}
</button>
<span data-testid="status">{status}</span>
</div>
);
}// VideoPlayer.test.tsx
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 src="test-video.mp4" />);
const playButton = screen.getByRole('button', { name: /play/i });
await user.click(playButton);
const video = screen.getByTestId('video-element') as HTMLVideoElement;
expect(video.play).toHaveBeenCalled();
});
it('transitions to playing state on play event', () => {
render(<VideoPlayer src="test-video.mp4" />);
const video = screen.getByTestId('video-element');
fireEvent.play(video);
expect(screen.getByTestId('status')).toHaveTextContent('playing');
});
it('transitions to paused state on pause event', () => {
render(<VideoPlayer src="test-video.mp4" />);
const video = screen.getByTestId('video-element');
fireEvent.play(video);
expect(screen.getByTestId('status')).toHaveTextContent('playing');
fireEvent.pause(video);
expect(screen.getByTestId('status')).toHaveTextContent('paused');
});
it('transitions to ended state on ended event', () => {
render(<VideoPlayer src="test-video.mp4" />);
const video = screen.getByTestId('video-element');
fireEvent.play(video);
fireEvent.ended(video);
expect(screen.getByTestId('status')).toHaveTextContent('ended');
});
it('calls onPlay callback when video starts playing', () => {
const handlePlay = vi.fn();
render(<VideoPlayer src="test-video.mp4" onPlay={handlePlay} />);
fireEvent.play(screen.getByTestId('video-element'));
expect(handlePlay).toHaveBeenCalledTimes(1);
});
it('calls onEnded callback when video finishes', () => {
const handleEnded = vi.fn();
render(<VideoPlayer src="test-video.mp4" onEnded={handleEnded} />);
fireEvent.ended(screen.getByTestId('video-element'));
expect(handleEnded).toHaveBeenCalledTimes(1);
});
it('shows loading state during buffering', () => {
render(<VideoPlayer src="test-video.mp4" />);
fireEvent.waiting(screen.getByTestId('video-element'));
expect(screen.getByTestId('status')).toHaveTextContent('loading');
});
});Mocking IntersectionObserver for Lazy-Loaded Video
// src/test/mocks/intersection-observer.ts
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;
// Helper to simulate an element entering the viewport
function simulateIntersection(element: Element, isIntersecting: boolean) {
const callback = observerMap.get(element);
if (callback) {
callback([{ isIntersecting, target: element } as IntersectionObserverEntry]);
}
}
return { MockIntersectionObserver, simulateIntersection };
}// LazyVideo.test.tsx
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;
// Video should not have src set yet
expect(video).not.toHaveAttribute('src', 'lazy-video.mp4');
});
it('loads video source when element enters viewport', () => {
render(<LazyVideo src="lazy-video.mp4" />);
const container = screen.getByTestId('video-container');
act(() => {
simulateIntersection(container, true);
});
const video = screen.getByTestId('video-element') as HTMLVideoElement;
expect(video).toHaveAttribute('src', 'lazy-video.mp4');
});
it('pauses video when scrolled out of viewport', () => {
render(<LazyVideo src="lazy-video.mp4" />);
const container = screen.getByTestId('video-container');
// Simulate entering viewport
act(() => {
simulateIntersection(container, true);
});
// Simulate leaving viewport
act(() => {
simulateIntersection(container, false);
});
const video = screen.getByTestId('video-element') as HTMLVideoElement;
expect(video.pause).toHaveBeenCalled();
});
});Testing Video Event Handlers
// Test onTimeUpdate and onLoadedMetadata 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');
// Simulate time update with a specific currentTime
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(
<VideoPlayer src="test.mp4" onLoadedMetadata={handleMetadata} />
);
const video = screen.getByTestId('video-element');
// Set video metadata properties before firing event
Object.defineProperty(video, 'videoWidth', { value: 1920 });
Object.defineProperty(video, 'videoHeight', { value: 1080 });
Object.defineProperty(video, 'duration', { value: 120 });
fireEvent.loadedMetadata(video);
expect(handleMetadata).toHaveBeenCalled();
});
it('handles onError events', () => {
const handleError = vi.fn();
render(
<VideoPlayer src="nonexistent.mp4" onError={handleError} />
);
const video = screen.getByTestId('video-element');
fireEvent.error(video);
expect(handleError).toHaveBeenCalled();
});
});Additional References
For comprehensive testing patterns and recipes, see:
references/testing-recipes.md- Complete testing patterns for forms, async, hooks, mocking, and accessibility
React Testing Recipes
Complete testing patterns and solutions for common React scenarios.
Test Setup
Vitest Configuration
// vitest.config.ts
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'],
include: ['**/*.test.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'src/test/'],
},
},
});Test Setup File
// src/test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
// Cleanup after each test
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock IntersectionObserver
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));Custom Render with Providers
// src/test/utils.tsx
import { ReactElement, ReactNode } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
interface WrapperProps {
children: ReactNode;
}
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
});
}
function AllProviders({ children }: WrapperProps) {
const queryClient = createTestQueryClient();
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
{children}
</BrowserRouter>
</QueryClientProvider>
);
}
function customRender(
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) {
return render(ui, { wrapper: AllProviders, ...options });
}
// Re-export everything
export * from '@testing-library/react';
export { customRender as render };Component Testing Patterns
Basic Component Test
import { render, screen } from '@/test/utils';
import { describe, it, expect } from 'vitest';
import { UserCard } from './UserCard';
describe('UserCard', () => {
const defaultProps = {
name: 'John Doe',
email: 'john@example.com',
role: 'Developer',
};
it('renders user information', () => {
render(<UserCard {...defaultProps} />);
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('john@example.com')).toBeInTheDocument();
expect(screen.getByText('Developer')).toBeInTheDocument();
});
it('renders avatar when provided', () => {
render(<UserCard {...defaultProps} avatar="/avatar.jpg" />);
const avatar = screen.getByRole('img', { name: /john doe/i });
expect(avatar).toHaveAttribute('src', '/avatar.jpg');
});
it('shows placeholder when no avatar', () => {
render(<UserCard {...defaultProps} />);
expect(screen.getByTestId('avatar-placeholder')).toBeInTheDocument();
});
});Testing User Interactions
import { render, screen } from '@/test/utils';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { Counter } from './Counter';
describe('Counter', () => {
it('increments count when plus button is clicked', async () => {
const user = userEvent.setup();
render(<Counter initialCount={0} />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
it('decrements count when minus button is clicked', async () => {
const user = userEvent.setup();
render(<Counter initialCount={5} />);
await user.click(screen.getByRole('button', { name: /decrement/i }));
expect(screen.getByText('Count: 4')).toBeInTheDocument();
});
it('calls onChange when count changes', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(<Counter initialCount={0} onChange={handleChange} />);
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(handleChange).toHaveBeenCalledWith(1);
});
});Testing Forms
import { render, screen, waitFor } from '@/test/utils';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { ContactForm } from './ContactForm';
describe('ContactForm', () => {
it('submits form with entered data', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<ContactForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/name/i), 'John Doe');
await user.type(screen.getByLabelText(/email/i), 'john@example.com');
await user.type(screen.getByLabelText(/message/i), 'Hello world');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(handleSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: 'john@example.com',
message: 'Hello world',
});
});
it('shows validation errors for empty fields', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={vi.fn()} />);
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(screen.getByText(/name is required/i)).toBeInTheDocument();
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
});
it('shows error for invalid email', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={vi.fn()} />);
await user.type(screen.getByLabelText(/email/i), 'invalid-email');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(screen.getByText(/invalid email/i)).toBeInTheDocument();
});
it('disables submit button while submitting', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn(() => new Promise((r) => setTimeout(r, 100)));
render(<ContactForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/name/i), 'John');
await user.type(screen.getByLabelText(/email/i), 'john@example.com');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(screen.getByRole('button', { name: /submitting/i })).toBeDisabled();
await waitFor(() => {
expect(screen.getByRole('button', { name: /submit/i })).toBeEnabled();
});
});
});Testing with Select and Radio
import { render, screen } from '@/test/utils';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { FilterForm } from './FilterForm';
describe('FilterForm', () => {
it('selects option from dropdown', async () => {
const user = userEvent.setup();
render(<FilterForm />);
await user.selectOptions(screen.getByLabelText(/category/i), 'electronics');
expect(screen.getByRole('option', { name: /electronics/i })).toBeSelected();
});
it('selects radio option', async () => {
const user = userEvent.setup();
render(<FilterForm />);
await user.click(screen.getByLabelText(/high to low/i));
expect(screen.getByLabelText(/high to low/i)).toBeChecked();
});
it('toggles checkbox', async () => {
const user = userEvent.setup();
render(<FilterForm />);
const checkbox = screen.getByLabelText(/in stock only/i);
expect(checkbox).not.toBeChecked();
await user.click(checkbox);
expect(checkbox).toBeChecked();
await user.click(checkbox);
expect(checkbox).not.toBeChecked();
});
});Async Testing Patterns
Testing Loading States
import { render, screen, waitFor } from '@/test/utils';
import { describe, it, expect, vi } from 'vitest';
import { UserList } from './UserList';
// Mock fetch
global.fetch = vi.fn();
describe('UserList', () => {
it('shows loading state initially', () => {
(fetch as any).mockImplementation(() => new Promise(() => {})); // Never resolves
render(<UserList />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('displays users after loading', async () => {
(fetch as any).mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve([
{ id: '1', name: 'John' },
{ id: '2', name: 'Jane' },
]),
});
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('John')).toBeInTheDocument();
expect(screen.getByText('Jane')).toBeInTheDocument();
});
});
it('shows error message on failure', async () => {
(fetch as any).mockRejectedValueOnce(new Error('Network error'));
render(<UserList />);
await waitFor(() => {
expect(screen.getByText(/error loading users/i)).toBeInTheDocument();
});
});
});Testing with waitFor
import { render, screen, waitFor, waitForElementToBeRemoved } from '@/test/utils';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { AsyncSearch } from './AsyncSearch';
describe('AsyncSearch', () => {
it('shows results after typing', async () => {
const user = userEvent.setup();
render(<AsyncSearch />);
await user.type(screen.getByRole('searchbox'), 'react');
// Wait for loading to appear and disappear
await waitFor(() => {
expect(screen.getByText(/searching/i)).toBeInTheDocument();
});
await waitForElementToBeRemoved(() => screen.queryByText(/searching/i));
expect(screen.getByText(/results for "react"/i)).toBeInTheDocument();
});
it('debounces search input', async () => {
const user = userEvent.setup();
const searchFn = vi.fn();
render(<AsyncSearch onSearch={searchFn} />);
// Type quickly
await user.type(screen.getByRole('searchbox'), 'react');
// Should only call once after debounce
await waitFor(() => {
expect(searchFn).toHaveBeenCalledTimes(1);
expect(searchFn).toHaveBeenCalledWith('react');
});
});
});Testing with findBy Queries
import { render, screen } from '@/test/utils';
import { describe, it, expect } from 'vitest';
import { Dashboard } from './Dashboard';
describe('Dashboard', () => {
it('loads and displays data', async () => {
render(<Dashboard />);
// findBy* waits for element (combines waitFor + getBy)
const greeting = await screen.findByText(/welcome, john/i);
expect(greeting).toBeInTheDocument();
// Multiple findBy calls
const stats = await screen.findByTestId('stats-card');
expect(stats).toBeInTheDocument();
});
it('handles findBy timeout', async () => {
render(<Dashboard />);
// Increase timeout for slow operations
const element = await screen.findByText(/complex data/i, {}, { timeout: 5000 });
expect(element).toBeInTheDocument();
});
});Testing Custom Hooks
Basic Hook Testing
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('returns initial count', () => {
const { result } = renderHook(() => useCounter(5));
expect(result.current.count).toBe(5);
});
it('increments count', () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('decrements count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
it('resets count', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});Testing Async Hooks
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useFetch } from './useFetch';
describe('useFetch', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('fetches data successfully', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ name: 'John' }),
});
const { result } = renderHook(() => useFetch('/api/user'));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual({ name: 'John' });
expect(result.current.error).toBeNull();
});
it('handles fetch error', async () => {
global.fetch = vi.fn().mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useFetch('/api/user'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBeNull();
expect(result.current.error?.message).toBe('Network error');
});
it('refetches when url changes', async () => {
global.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ id: 1 }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ id: 2 }),
});
const { result, rerender } = renderHook(({ url }) => useFetch(url), {
initialProps: { url: '/api/users/1' },
});
await waitFor(() => {
expect(result.current.data).toEqual({ id: 1 });
});
rerender({ url: '/api/users/2' });
await waitFor(() => {
expect(result.current.data).toEqual({ id: 2 });
});
expect(fetch).toHaveBeenCalledTimes(2);
});
});Testing Hooks with Context
import { renderHook } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useAuth } from './useAuth';
import { AuthProvider } from './AuthContext';
function wrapper({ children }: { children: React.ReactNode }) {
return <AuthProvider>{children}</AuthProvider>;
}
describe('useAuth', () => {
it('provides user from context', () => {
const { result } = renderHook(() => useAuth(), { wrapper });
expect(result.current.user).toBeDefined();
});
it('throws when used outside provider', () => {
// Suppress console.error for this test
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => {
renderHook(() => useAuth());
}).toThrow('useAuth must be used within AuthProvider');
consoleSpy.mockRestore();
});
});Mocking Patterns
Mocking Modules
import { vi } from 'vitest';
// Mock entire module
vi.mock('@/lib/api', () => ({
fetchUsers: vi.fn(),
createUser: vi.fn(),
}));
// Import after mocking
import { fetchUsers, createUser } from '@/lib/api';
describe('UserService', () => {
it('calls fetchUsers', async () => {
(fetchUsers as any).mockResolvedValueOnce([{ id: 1, name: 'John' }]);
const users = await fetchUsers();
expect(users).toHaveLength(1);
});
});Mocking External Libraries
// Mock react-router-dom
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => vi.fn(),
useParams: () => ({ id: '123' }),
};
});
// Mock date-fns
vi.mock('date-fns', () => ({
format: vi.fn((date) => '2024-01-01'),
parseISO: vi.fn((str) => new Date(str)),
}));Mocking Timers
import { render, screen, act } from '@/test/utils';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { CountdownTimer } from './CountdownTimer';
describe('CountdownTimer', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('counts down every second', () => {
render(<CountdownTimer seconds={5} />);
expect(screen.getByText('5')).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(1000);
});
expect(screen.getByText('4')).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(2000);
});
expect(screen.getByText('2')).toBeInTheDocument();
});
it('calls onComplete when finished', () => {
const handleComplete = vi.fn();
render(<CountdownTimer seconds={3} onComplete={handleComplete} />);
act(() => {
vi.advanceTimersByTime(3000);
});
expect(handleComplete).toHaveBeenCalled();
});
});Mocking API with MSW
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
]);
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: 3, ...body }, { status: 201 });
}),
http.delete('/api/users/:id', ({ params }) => {
return HttpResponse.json({ deleted: params.id });
}),
];
// src/test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// src/test/setup.ts
import { server } from './mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// In tests - override handlers
import { server } from '@/test/mocks/server';
import { http, HttpResponse } from 'msw';
it('handles error response', async () => {
server.use(
http.get('/api/users', () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<UserList />);
expect(await screen.findByText(/error/i)).toBeInTheDocument();
});Accessibility Testing
Basic A11y Tests
import { render, screen } from '@/test/utils';
import { describe, it, expect } from 'vitest';
import { axe, toHaveNoViolations } from 'jest-axe';
import { ContactForm } from './ContactForm';
expect.extend(toHaveNoViolations);
describe('ContactForm accessibility', () => {
it('has no accessibility violations', async () => {
const { container } = render(<ContactForm onSubmit={vi.fn()} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has proper form labels', () => {
render(<ContactForm onSubmit={vi.fn()} />);
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/message/i)).toBeInTheDocument();
});
it('has accessible button', () => {
render(<ContactForm onSubmit={vi.fn()} />);
const button = screen.getByRole('button', { name: /submit/i });
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('type', 'submit');
});
it('marks required fields', () => {
render(<ContactForm onSubmit={vi.fn()} />);
expect(screen.getByLabelText(/name/i)).toBeRequired();
expect(screen.getByLabelText(/email/i)).toBeRequired();
});
it('associates error messages with inputs', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={vi.fn()} />);
await user.click(screen.getByRole('button', { name: /submit/i }));
const emailInput = screen.getByLabelText(/email/i);
const errorId = emailInput.getAttribute('aria-describedby');
expect(errorId).toBeTruthy();
expect(document.getElementById(errorId!)).toHaveTextContent(/required/i);
});
});Testing Keyboard Navigation
import { render, screen } from '@/test/utils';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { Modal } from './Modal';
describe('Modal keyboard navigation', () => {
it('traps focus within modal', async () => {
const user = userEvent.setup();
render(
<Modal isOpen onClose={vi.fn()}>
<button>First</button>
<button>Second</button>
<button>Close</button>
</Modal>
);
const firstButton = screen.getByRole('button', { name: /first/i });
const closeButton = screen.getByRole('button', { name: /close/i });
expect(firstButton).toHaveFocus();
// Tab through all buttons
await user.tab();
expect(screen.getByRole('button', { name: /second/i })).toHaveFocus();
await user.tab();
expect(closeButton).toHaveFocus();
// Tab should wrap to first
await user.tab();
expect(firstButton).toHaveFocus();
});
it('closes on Escape key', async () => {
const user = userEvent.setup();
const handleClose = vi.fn();
render(
<Modal isOpen onClose={handleClose}>
Content
</Modal>
);
await user.keyboard('{Escape}');
expect(handleClose).toHaveBeenCalled();
});
});