React Component Testing with Claude Code
The Problem
Your React application has components but limited test coverage. You are not sure what to test, how to test user interactions, or how to handle components that depend on context providers, API calls, or router state. Writing tests feels like duplicating the implementation.
Quick Start
Point Claude Code at a component and ask for tests:
Read src/components/UserProfile.tsx and generate comprehensive tests.
Use React Testing Library. Test:
- Renders user data correctly
- Handles loading state
- Handles error state
- User interaction (edit button, form submission)
- Accessibility (roles, labels, keyboard navigation)
Do not test implementation details.
What’s Happening
React component testing verifies that your components render correctly and respond to user interactions as expected. React Testing Library encourages testing from the user’s perspective: what they see and what they can do, not how the component implements it internally.
Claude Code generates effective tests because it reads your component code, understands the props interface, identifies state transitions, and knows which user interactions trigger behavior changes.
Step-by-Step Guide
Step 1: Set up the testing environment
Ask Claude Code to configure the test setup:
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event jest jest-environment-jsdom @types/jest ts-jest
// jest.config.ts
export default {
testEnvironment: 'jsdom',
setupFilesAfterSetup: ['<rootDir>/tests/setup.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss)$': 'identity-obj-proxy',
},
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
};
// tests/setup.ts
import '@testing-library/jest-dom';
Step 2: Test a basic component
Given a component:
// src/components/UserCard.tsx
interface UserCardProps {
name: string;
email: string;
avatar?: string;
onEdit: () => void;
}
export function UserCard({ name, email, avatar, onEdit }: UserCardProps) {
return (
<article aria-label={`User card for ${name}`}>
{avatar && <img src={avatar} alt={`${name}'s avatar`} />}
<h3>{name}</h3>
<p>{email}</p>
<button onClick={onEdit}>Edit Profile</button>
</article>
);
}
Claude Code generates:
// src/components/UserCard.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserCard } from './UserCard';
describe('UserCard', () => {
const defaultProps = {
name: 'Alice Johnson',
email: '[email protected]',
onEdit: jest.fn(),
};
it('renders name and email', () => {
render(<UserCard {...defaultProps} />);
expect(screen.getByText('Alice Johnson')).toBeInTheDocument();
expect(screen.getByText('[email protected]')).toBeInTheDocument();
});
it('renders avatar when provided', () => {
render(<UserCard {...defaultProps} avatar="https://example.com/avatar.jpg" />);
const img = screen.getByAltText("Alice Johnson's avatar");
expect(img).toHaveAttribute('src', 'https://example.com/avatar.jpg');
});
it('does not render avatar when not provided', () => {
render(<UserCard {...defaultProps} />);
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});
it('calls onEdit when edit button is clicked', async () => {
const user = userEvent.setup();
const onEdit = jest.fn();
render(<UserCard {...defaultProps} onEdit={onEdit} />);
await user.click(screen.getByRole('button', { name: /edit profile/i }));
expect(onEdit).toHaveBeenCalledTimes(1);
});
it('has accessible article label', () => {
render(<UserCard {...defaultProps} />);
expect(
screen.getByRole('article', { name: /user card for alice johnson/i })
).toBeInTheDocument();
});
});
Step 3: Test components with state
For components that manage their own state:
// src/components/SearchInput.tsx
import { useState, useCallback } from 'react';
import { useDebouncedCallback } from 'use-debounce';
interface SearchInputProps {
onSearch: (query: string) => void;
placeholder?: string;
}
export function SearchInput({ onSearch, placeholder = 'Search...' }: SearchInputProps) {
const [value, setValue] = useState('');
const debouncedSearch = useDebouncedCallback((query: string) => {
onSearch(query);
}, 300);
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
debouncedSearch(e.target.value);
}, [debouncedSearch]);
const handleClear = useCallback(() => {
setValue('');
onSearch('');
}, [onSearch]);
return (
<div role="search">
<input
type="text"
value={value}
onChange={handleChange}
placeholder={placeholder}
aria-label="Search"
/>
{value && (
<button onClick={handleClear} aria-label="Clear search">
Clear
</button>
)}
</div>
);
}
Tests:
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchInput } from './SearchInput';
describe('SearchInput', () => {
it('updates input value as user types', async () => {
const user = userEvent.setup();
render(<SearchInput onSearch={jest.fn()} />);
const input = screen.getByRole('textbox', { name: /search/i });
await user.type(input, 'hello');
expect(input).toHaveValue('hello');
});
it('calls onSearch after debounce delay', async () => {
const user = userEvent.setup();
const onSearch = jest.fn();
render(<SearchInput onSearch={onSearch} />);
await user.type(screen.getByRole('textbox'), 'react');
// onSearch should not be called immediately
expect(onSearch).not.toHaveBeenCalled();
// Wait for debounce
await waitFor(() => {
expect(onSearch).toHaveBeenCalledWith('react');
}, { timeout: 500 });
});
it('shows clear button when input has value', async () => {
const user = userEvent.setup();
render(<SearchInput onSearch={jest.fn()} />);
expect(screen.queryByRole('button', { name: /clear/i })).not.toBeInTheDocument();
await user.type(screen.getByRole('textbox'), 'test');
expect(screen.getByRole('button', { name: /clear/i })).toBeInTheDocument();
});
it('clears input and calls onSearch with empty string on clear', async () => {
const user = userEvent.setup();
const onSearch = jest.fn();
render(<SearchInput onSearch={onSearch} />);
await user.type(screen.getByRole('textbox'), 'test');
await user.click(screen.getByRole('button', { name: /clear/i }));
expect(screen.getByRole('textbox')).toHaveValue('');
expect(onSearch).toHaveBeenCalledWith('');
});
});
Step 4: Test components with API calls
Mock API calls at the network level:
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { render, screen, waitFor } from '@testing-library/react';
import { UserList } from './UserList';
const server = setupServer(
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.json([
{ id: '1', name: 'Alice', email: '[email protected]' },
{ id: '2', name: 'Bob', email: '[email protected]' },
])
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('UserList', () => {
it('shows loading state initially', () => {
render(<UserList />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('renders users after loading', async () => {
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
});
});
it('shows error message on API failure', async () => {
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.status(500));
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText(/failed to load users/i)).toBeInTheDocument();
});
});
});
Step 5: Test components with context providers
Create a test wrapper for components that need providers:
// tests/renderWithProviders.tsx
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from '../src/contexts/ThemeContext';
function createTestProviders({ route = '/' } = {}) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[route]}>
<ThemeProvider>
{children}
</ThemeProvider>
</MemoryRouter>
</QueryClientProvider>
);
};
}
export function renderWithProviders(
ui: React.ReactElement,
options?: RenderOptions & { route?: string }
) {
return render(ui, {
wrapper: createTestProviders({ route: options?.route }),
...options,
});
}
Prevention
Add testing rules to your CLAUDE.md:
## React Testing Rules
- Use React Testing Library, not Enzyme
- Query by role, label, or text — never by test ID unless necessary
- Use userEvent, not fireEvent, for user interactions
- Test behavior, not implementation details
- Every component must have at least: render test, interaction test, edge case test
- Mock at the network level (MSW), not at the module level