Testing patterns using Vitest and React Testing Library.
| Tool | Purpose |
|---|---|
| Vitest | Test runner (Vite-native, fast, Jest-compatible API) |
| React Testing Library | Component testing (behavior-focused, not implementation) |
| jsdom | Browser environment for tests |
| @testing-library/user-event | Simulates real user interactions |
npm test # Watch mode (re-runs on file changes)
npm run test:run # Single run (CI-friendly)
npm run test:coverage # Coverage reportWraps components with all necessary providers (Router, Theme, Toast):
// src/test-utils/render.jsx
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from '../contexts/ThemeContext';
import { ToastProvider } from '../contexts/ToastContext';
export function renderWithProviders(ui, options = {}) {
const { route = '/', ...renderOptions } = options;
return render(ui, {
wrapper: ({ children }) => (
<MemoryRouter initialEntries={[route]}>
<ThemeProvider>
<ToastProvider>
{children}
</ToastProvider>
</ThemeProvider>
</MemoryRouter>
),
...renderOptions,
});
}import { renderWithProviders } from '../test-utils/render';
test('renders feature page', () => {
renderWithProviders(<CounterPage />);
expect(screen.getByText('Counter')).toBeInTheDocument();
});// Good -- tests what the user sees and does
test('increments counter when + button is clicked', async () => {
renderWithProviders(<CounterPage />);
await userEvent.click(screen.getByText('+'));
expect(screen.getByText('1')).toBeInTheDocument();
});
// Bad -- tests implementation details
test('calls setCount with incremented value', () => {
const setCount = vi.fn();
// ... testing internal hook calls
});For complex logic, test the hook without rendering UI:
import { renderHook, act } from '@testing-library/react';
import { useCounter } from '../hooks/useCounter';
test('increments and decrements', () => {
const { result } = renderHook(() => useCounter(0));
act(() => result.current.increment());
expect(result.current.count).toBe(1);
act(() => result.current.decrement());
expect(result.current.count).toBe(0);
});The test setup clears localStorage before each test:
beforeEach(() => {
localStorage.clear();
});
test('persists value to localStorage', () => {
const { result } = renderHook(() => useLocalStorage('test-key', 'default'));
act(() => result.current[1]('new value'));
expect(JSON.parse(localStorage.getItem('test-key'))).toBe('new value');
});test('loads data asynchronously', async () => {
renderWithProviders(<MyFeaturePage />);
// Wait for loading to complete
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
expect(screen.getByText('Data loaded')).toBeInTheDocument();
});When testing features that use setInterval or setTimeout:
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
test('updates every second', () => {
renderWithProviders(<TimerFeature />);
act(() => vi.advanceTimersByTime(1000));
expect(screen.getByText('1s')).toBeInTheDocument();
});The Clipboard API requires a secure context. The test setup mocks it:
// In setup.js
Object.assign(navigator, {
clipboard: {
writeText: vi.fn().mockResolvedValue(undefined),
readText: vi.fn().mockResolvedValue(''),
},
});Some components use ResizeObserver. Mock it in setup:
global.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};