React Testing with Vitest + React Testing Library
Core Principles
- •Test behavior, not implementation details. Never test internal state or component internals.
- •Query elements the way users find them: by role, label, text, placeholder.
- •Prefer
userEventoverfireEventfor realistic interaction simulation.
Component Rendering
tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { UserCard } from './UserCard';
describe('UserCard', () => {
it('renders user name and email', () => {
render(<UserCard name="Alice" email="alice@example.com" />);
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
});
it('renders fallback avatar when no image provided', () => {
render(<UserCard name="Alice" email="alice@example.com" />);
expect(screen.getByRole('img', { name: /alice/i })).toHaveAttribute('src', '/default-avatar.png');
});
});
User Events
tsx
import userEvent from '@testing-library/user-event';
it('calls onSubmit with form data', 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), 'secret123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'secret123',
});
});
Async Testing
tsx
it('displays data after loading', async () => {
render(<UserList />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// waitFor retries until the assertion passes or times out
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
// Use findBy* as shorthand for waitFor + getBy
it('shows error on failure', async () => {
server.use(http.get('/api/users', () => HttpResponse.error()));
render(<UserList />);
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
});
Mocking
tsx
// Mock a module
vi.mock('./api/users', () => ({
fetchUsers: vi.fn().mockResolvedValue([{ id: 1, name: 'Alice' }]),
}));
// Mock a hook
vi.mock('./hooks/useAuth', () => ({
useAuth: () => ({ user: { id: 1, name: 'Alice' }, isAuthenticated: true }),
}));
// Mock with MSW for network requests (preferred)
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
http.get('/api/users', () => {
return HttpResponse.json([{ id: 1, name: 'Alice' }]);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Snapshot Testing
Use sparingly and only for stable UI. Prefer explicit assertions.
tsx
it('matches snapshot for empty state', () => {
const { container } = render(<EmptyState message="No results" />);
expect(container.firstChild).toMatchSnapshot();
});
// Prefer inline snapshots for small outputs
it('renders correct class names', () => {
render(<Badge variant="success">Done</Badge>);
expect(screen.getByText('Done').className).toMatchInlineSnapshot('"badge badge-success"');
});
Accessibility Testing
tsx
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
it('has no accessibility violations', async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Anti-Patterns
- •NEVER use
container.querySelectorwhen a Testing Library query works. - •NEVER test implementation details like state values or component method calls.
- •NEVER use
getByTestIdas a first resort. Use semantic queries:getByRole,getByLabelText,getByText. - •NEVER wrap
act()manually arounduserEventcalls;userEvent.setup()handles it. - •NEVER assert on snapshot length or raw HTML strings.
Wrapper for Providers
tsx
function renderWithProviders(ui: React.ReactElement, options = {}) {
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={new QueryClient()}>
<ThemeProvider>{children}</ThemeProvider>
</QueryClientProvider>
);
}
return render(ui, { wrapper: Wrapper, ...options });
}