component-testing
React component testing with Vitest and Testing Library. Use when writing unit tests for components.
When & Why to Use This Skill
This Claude skill provides a comprehensive framework and standardized patterns for React component testing using Vitest and React Testing Library. It emphasizes the core principle of testing user behavior rather than implementation details, ensuring UI components are robust, accessible, and maintainable. The skill covers everything from basic rendering and user interactions to complex scenarios like form validation, asynchronous API mocking, and custom hook testing.
Use Cases
- Unit Testing UI Components: Verifying that React components render correctly and handle props as expected using accessible queries.
- Simulating User Interactions: Using user-event to test realistic workflows like button clicks, text input, and form submissions.
- Asynchronous Behavior Validation: Testing components that rely on API calls, handling loading states, and verifying data display after promises resolve.
- Testing Custom Hooks and Context: Isolating complex logic in hooks or verifying state propagation through React Context providers.
- Regression Testing during Refactoring: Ensuring that internal code changes do not break existing user-facing functionality.
| name | component-testing |
|---|---|
| description | React component testing with Vitest and Testing Library. Use when writing unit tests for components. |
Component Testing Skill
This skill covers React component testing patterns with Vitest and Testing Library.
When to Use
Use this skill when:
- Writing unit tests for React components
- Testing user interactions
- Verifying component behavior
- Testing hooks and context
Core Principle
TEST USER BEHAVIOR - Test what users see and do, not implementation details. Your tests should not break when you refactor.
Setup
Installation
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
Configuration
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/__tests__/**/*.test.{ts,tsx}'],
coverage: {
provider: 'v8',
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
Basic Component Test
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Button } from '../Button';
describe('Button', () => {
it('renders children correctly', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button')).toHaveTextContent('Click me');
});
it('applies variant class', () => {
render(<Button variant="primary">Primary</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-primary');
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Disabled</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
});
User Interaction Tests
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from '../Counter';
describe('Counter', () => {
it('increments count when button clicked', async () => {
const user = userEvent.setup();
render(<Counter />);
const button = screen.getByRole('button', { name: /increment/i });
await user.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
it('calls onChange with new count', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(<Counter onChange={handleChange} />);
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(handleChange).toHaveBeenCalledWith(1);
});
});
Form Testing
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from '../LoginForm';
describe('LoginForm', () => {
it('submits form with email and password', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
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: /sign in/i }));
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
});
it('shows validation error for invalid email', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={vi.fn()} />);
await user.type(screen.getByLabelText(/email/i), 'invalid-email');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(await screen.findByText(/invalid email/i)).toBeInTheDocument();
});
it('disables submit button while loading', async () => {
render(<LoginForm onSubmit={vi.fn()} isLoading />);
expect(screen.getByRole('button', { name: /sign in/i })).toBeDisabled();
});
});
Query Priority
Use queries in this order of preference:
// 1. Accessible queries (best)
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('heading', { level: 1 });
// 2. Semantic queries
screen.getByLabelText(/email/i);
screen.getByPlaceholderText(/enter email/i);
screen.getByText(/welcome/i);
screen.getByAltText(/profile picture/i);
// 3. Test ID (last resort)
screen.getByTestId('custom-element');
Async Testing
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from '../UserProfile';
// Mock fetch
vi.mock('../api', () => ({
fetchUser: vi.fn(() =>
Promise.resolve({ id: '1', name: 'John Doe' })
),
}));
describe('UserProfile', () => {
it('displays loading state initially', () => {
render(<UserProfile userId="1" />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('displays user data after loading', async () => {
render(<UserProfile userId="1" />);
expect(await screen.findByText('John Doe')).toBeInTheDocument();
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
it('displays error when fetch fails', async () => {
const { fetchUser } = await import('../api');
vi.mocked(fetchUser).mockRejectedValueOnce(new Error('Failed'));
render(<UserProfile userId="1" />);
expect(await screen.findByText(/error/i)).toBeInTheDocument();
});
});
Testing with Context
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../ThemeContext';
import { ThemedButton } from '../ThemedButton';
function renderWithTheme(ui: React.ReactElement, theme = 'light') {
return render(
<ThemeProvider defaultTheme={theme}>
{ui}
</ThemeProvider>
);
}
describe('ThemedButton', () => {
it('renders with light theme styles', () => {
renderWithTheme(<ThemedButton>Click</ThemedButton>, 'light');
expect(screen.getByRole('button')).toHaveClass('bg-white');
});
it('renders with dark theme styles', () => {
renderWithTheme(<ThemedButton>Click</ThemedButton>, 'dark');
expect(screen.getByRole('button')).toHaveClass('bg-gray-900');
});
});
Testing Custom Hooks
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
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 count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('resets to initial value', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(5);
});
});
Mocking
Mocking Modules
import { vi } from 'vitest';
// Mock entire module
vi.mock('../api');
// Mock with implementation
vi.mock('../utils', () => ({
formatDate: vi.fn((date) => '2024-01-01'),
}));
Mocking Functions
const mockFn = vi.fn();
mockFn.mockReturnValue('default');
mockFn.mockReturnValueOnce('first call');
mockFn.mockResolvedValue({ data: 'async result' });
mockFn.mockRejectedValue(new Error('Failed'));
Test Structure
File Organization
src/
├── components/
│ ├── Button/
│ │ ├── Button.tsx
│ │ ├── index.ts
│ │ └── __tests__/
│ │ └── Button.test.tsx
Test Naming
// Use verb-first naming
it('renders children correctly', ...);
it('calls onClick when clicked', ...);
it('throws error for invalid input', ...);
it('displays loading state initially', ...);
Best Practices
- Test behavior, not implementation - Focus on what users experience
- Use accessible queries - getByRole, getByLabelText first
- Avoid testing styles - Test classes/attributes, not computed styles
- Keep tests focused - One behavior per test
- Use userEvent over fireEvent - More realistic interactions
- Wait for async operations - Use findBy, waitFor
Notes
- Testing Library encourages accessible components
- Avoid snapshot tests for components (too brittle)
- Mock at the boundary (API, not internal functions)
- Tests should give confidence to refactor