Testing Fundamentals
Testing React Components
Test React components the way users interact with them — through rendered output and user events.
React Testing Library Philosophy
React Testing Library is built on one principle: test your components the way users interact with them.
Users don't care about component state. They care about what they see and what they can do. Testing Library encourages you to query the DOM the way a user would — by visible text, by role, by label — not by implementation details like state variables or internal methods.
Setup
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-eventConfigure jsdom environment and extend matchers:
// vitest.config.ts
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
});
// src/test/setup.ts
import '@testing-library/jest-dom';Rendering and Querying
import { render, screen } from '@testing-library/react';
import { Button } from './Button';
it('renders the button text', () => {
render(<Button>Click me</Button>);
// getByText — finds element containing text
expect(screen.getByText('Click me')).toBeInTheDocument();
});Query Priority
Use queries in this order (most to least semantic):
- getByRole — finds by ARIA role (button, heading, textbox, link). Preferred.
- getByLabelText — finds form inputs by their label. Second choice.
- getByText — finds by visible text content.
- getByPlaceholderText — finds input by placeholder.
- getByTestId — last resort, requires adding data-testid attributes.
// Preferred — semantic role queries
screen.getByRole('button', { name: 'Submit' })
screen.getByRole('heading', { name: 'Login' })
screen.getByRole('textbox', { name: 'Email' })
// Good — label-based
screen.getByLabelText('Email address')
// Avoid when semantic queries are possible
screen.getByTestId('submit-button')User Interactions
Use userEvent from @testing-library/user-event for realistic user interactions:
import userEvent from '@testing-library/user-event';
it('submits the login form', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={mockSubmit} />);
await user.type(screen.getByLabelText('Email'), 'alice@example.com');
await user.type(screen.getByLabelText('Password'), 'SecurePass1!');
await user.click(screen.getByRole('button', { name: 'Sign In' }));
expect(mockSubmit).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'SecurePass1!',
});
});Async Testing
Many components make async operations (fetches, timers). Use findBy queries (return promises) or waitFor:
it('shows user data after loading', async () => {
render(<UserProfile userId="123" />);
// Assert loading state
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Wait for the async operation to complete
const username = await screen.findByText('Alice');
expect(username).toBeInTheDocument();
// Loading spinner should be gone
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});Complete Component Test Example
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { TodoItem } from './TodoItem';
const mockTodo = { id: '1', text: 'Buy groceries', completed: false };
describe('TodoItem', () => {
it('renders the todo text', () => {
render(<TodoItem todo={mockTodo} onToggle={vi.fn()} onDelete={vi.fn()} />);
expect(screen.getByText('Buy groceries')).toBeInTheDocument();
});
it('calls onToggle when checkbox is clicked', async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
render(<TodoItem todo={mockTodo} onToggle={onToggle} onDelete={vi.fn()} />);
await user.click(screen.getByRole('checkbox'));
expect(onToggle).toHaveBeenCalledWith('1');
});
it('shows completed style when todo is done', () => {
const completedTodo = { ...mockTodo, completed: true };
render(<TodoItem todo={completedTodo} onToggle={vi.fn()} onDelete={vi.fn()} />);
const text = screen.getByText('Buy groceries');
expect(text).toHaveClass('line-through');
});
});Key Takeaways
- Test what users see and do — not component state or implementation details
- Query priority: getByRole > getByLabelText > getByText > getByTestId
- Use userEvent for interactions — it simulates real browser events more accurately than fireEvent
- Use findBy queries for async content — they return promises and wait for the element to appear
- React's JSX auto-escapes HTML in renders — your test environment (jsdom) simulates the browser DOM
Example
// Complete test for a LoginForm component
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('renders email and password fields', () => {
render(<LoginForm onSubmit={vi.fn()} />);
expect(screen.getByLabelText('Email')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
});
it('submits with entered credentials', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('Email'), 'alice@example.com');
await user.type(screen.getByLabelText('Password'), 'password123');
await user.click(screen.getByRole('button', { name: 'Sign In' }));
expect(onSubmit).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'password123',
});
});
it('shows error for empty submission', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={vi.fn()} />);
await user.click(screen.getByRole('button', { name: 'Sign In' }));
expect(screen.getByText('Email is required')).toBeInTheDocument();
});
});