Effective testing catches bugs early, improves code quality, and provides confidence for refactoring. Testing Library’s philosophy focuses on testing what matters: the user experience.
The more your tests resemble how users interact with your app, the more confidence they can give you.
Setup and Installation
Installation
npm install –save-dev @testing-library/react @testing-library/jest-dom vitest// Using Create React App (pre-installed)
npm test
Configure Vitest (vitest.config.ts)
import react from ‘@vitejs/plugin-react’;export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: ‘jsdom’,
setupFiles: [‘./src/test/setup.ts’]
}
});
Setup File (src/test/setup.ts)
import { expect, afterEach } from ‘vitest’;
import { cleanup } from ‘@testing-library/react’;// Cleanup after each test
afterEach(() => cleanup());
Finding Elements with Queries
Query Types
| Query Type | When to Use | Returns |
|---|---|---|
| getBy* | Element should exist | Element or throws error |
| queryBy* | Element might not exist | Element or null |
| findBy* | Element appears async | Promise resolves to element |
| getAllBy* | Multiple elements | Array of elements or throws |
Using getByRole
The most user-centric query – finds elements by accessibility role:
import { Button } from ‘./Button’;describe(‘Button’, () => {
it(‘renders a button’, () => {
render(<Button>Click me</Button>);
const button = screen.getByRole(‘button’, {
name: /click me/i
});
expect(button).toBeInTheDocument();
});
});
Other Query Methods
const input = screen.getByLabelText(/username/i);// By Placeholder Text
const searchInput = screen.getByPlaceholderText(/search/i);
// By Text
const heading = screen.getByText(/welcome/i);
// By Test ID (last resort)
const element = screen.getByTestId(‘unique-id’);
Testing User Interactions
Using userEvent
Simulate realistic user interactions:
import userEvent from ‘@testing-library/user-event’;
import { LoginForm } from ‘./LoginForm’;it(‘submits form with valid data’, async () => {
const user = userEvent.setup();
render(<LoginForm />);
// Type in inputs
const emailInput = screen.getByLabelText(/email/i);
await user.type(emailInput, ‘user@example.com’);
// Click button
const submitButton = screen.getByRole(‘button’, {
name: /submit/i
});
await user.click(submitButton);
// Assert result
expect(screen.getByText(/success/i)).toBeInTheDocument();
});
Common User Interactions
await user.click(element);
// Type text
await user.type(input, ‘text’);
// Clear input
await user.clear(input);
// Select option
await user.selectOptions(select, ‘option1’);
// Check checkbox
await user.click(checkbox);
// Keyboard events
await user.keyboard(‘{Enter}’);
Handling Async Operations
Testing Async Data Loading
import { UserList } from ‘./UserList’;it(‘displays users after loading’, async () => {
render(<UserList />);
// Initially shows loading state
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for data to appear
const userName = await screen.findByText(/john doe/i);
expect(userName).toBeInTheDocument();
});
Using waitFor
render(<Counter />);
const button = screen.getByRole(‘button’);
await userEvent.click(button);
// Wait for condition to be true
await waitFor(() => {
expect(screen.getByText(/count: 1/i)).toBeInTheDocument();
});
});
Mocking API Calls
global.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve({
users: [{ id: 1, name: ‘John’ }]
})
})
);
});
afterEach(() => {
vi.clearAllMocks();
});
Testing Custom Hooks
Using renderHook
import { useCounter } from ‘./useCounter’;it(‘increments counter’, async () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
await waitFor(() => {
result.current.increment();
expect(result.current.count).toBe(1);
});
});
Testing Hooks with Context
const wrapper = ({ children }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const { result } = renderHook(
() => useTheme(),
{ wrapper }
);
expect(result.current.theme).toBe(‘light’);
});
Testing Async Hooks
import { useFetchUser } from ‘./useFetchUser’;it(‘fetches user data’, async () => {
const { result } = renderHook(() => useFetchUser(1));
// Initially loading
expect(result.current.loading).toBe(true);
// Wait for data
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.user?.name).toBe(‘John’);
});
});
Mocking and Spying
Mocking Functions
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Click</Button>);
await user.click(screen.getByRole(‘button’));
// Assert function was called
expect(handleClick).toHaveBeenCalled();
expect(handleClick).toHaveBeenCalledTimes(1);
expect(handleClick).toHaveBeenCalledWith(expectedArg);
});
Mocking Modules
fetchUser: vi.fn(() =>
Promise.resolve({ id: 1, name: ‘John’ })
)
}));
it(‘uses mocked API’, async () => {
render(<UserProfile />);
await screen.findByText(/john/i);
expect(fetchUser).toHaveBeenCalled();
});
Spying on Methods
render(<Component />);
expect(consoleSpy).toHaveBeenCalledWith(‘test’);
});
afterEach(() => {
consoleSpy.mockRestore();
});
Testing Best Practices
1. Test User Behavior, Not Implementation
it(‘sets state’, () => {
render(<Counter />);
const instance = new Counter();
instance.state.count = 1;
});// ✅ Do: Test user interactions
it(‘displays updated count after click’, async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole(‘button’));
expect(screen.getByText(/1/)).toBeInTheDocument();
});
2. Use Semantic Queries
// 1. getByRole – most semantic
screen.getByRole(‘button’, { name: /submit/i });// 2. getByLabelText – form inputs
screen.getByLabelText(/username/i);
// 3. getByPlaceholderText
screen.getByPlaceholderText(/search/i);
// 4. getByText – headings, labels
screen.getByText(/welcome/i);
// 5. getByTestId – last resort
screen.getByTestId(‘unique-id’);
3. Avoid Testing Library Details
it(‘renders correctly’, () => {
const { container } = render(<Component />);
expect(container.querySelector(‘.internal-class’)).toBeInTheDocument();
});// ✅ Do: Test from user perspective
it(‘displays welcome message’, () => {
render(<Component />);
expect(screen.getByText(/welcome/i)).toBeInTheDocument();
});
4. Keep Tests Isolated
beforeEach(() => {
// Setup before each test
vi.clearAllMocks();
});afterEach(() => {
// Cleanup after each test
cleanup();
});
it(‘test 1’, () => {});
it(‘test 2’, () => {});
});
Common Testing Patterns
Testing Form Submission
const user = userEvent.setup();
const handleSubmit = vi.fn();render(<Form onSubmit={handleSubmit} />);
// Fill form
await user.type(
screen.getByLabelText(/name/i),
‘John’
);
// Submit
await user.click(screen.getByRole(‘button’, { name: /submit/i }));
expect(handleSubmit).toHaveBeenCalledWith({ name: ‘John’ });
});
Testing Conditional Rendering
render(<Component data={null} />);expect(screen.getByText(/error/i)).toBeInTheDocument();
});
it(‘shows data when valid’, () => {
render(<Component data={{ id: 1 }} />);
expect(screen.queryByText(/error/i)).not.toBeInTheDocument();
});
Testing Modal/Dialog
const user = userEvent.setup();
const handleClose = vi.fn();render(<Modal isOpen={true} onClose={handleClose} />);
await user.click(screen.getByRole(‘button’, { name: /close/i }));
expect(handleClose).toHaveBeenCalled();
});