On this page
Testing with Vitest and Testing Library: Confidence Through Tests
Why Test React Components?
Automated tests give you confidence that your application works correctly as it evolves. They catch regressions — bugs introduced when changing existing code — before users encounter them.
The Testing Trophy (Kent C. Dodds) suggests the ideal test distribution for React:
- Static (TypeScript, ESLint): catches type errors and code quality issues
- Unit: tests individual functions and hooks in isolation
- Integration: tests components working together (the majority of your tests)
- End-to-End: tests the full user journey in a real browser (fewer, slower)
Vitest is the test runner — it executes your tests and reports results. Testing Library provides utilities for rendering components and querying the DOM in a way that mimics how real users interact with the UI.
Setup
Install the necessary packages:
npm install --save-dev vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdomConfigure Vitest in vite.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test-setup.ts',
},
});Create src/test-setup.ts:
import '@testing-library/jest-dom';
// Now you can use matchers like .toBeInTheDocument(), .toBeDisabled(), etc.Writing Your First Test
Let us test this simple component:
// Counter.tsx
interface CounterProps {
initialValue?: number;
min?: number;
max?: number;
onCountChange?: (count: number) => void;
}
function Counter({ initialValue = 0, min = -Infinity, max = Infinity, onCountChange }: CounterProps) {
const [count, setCount] = useState(initialValue);
function change(delta: number) {
const next = count + delta;
if (next < min || next > max) return;
setCount(next);
onCountChange?.(next);
}
return (
<div>
<p>Count: {count}</p>
<button type="button" onClick={() => change(-1)} disabled={count <= min} aria-label="Decrement">
−
</button>
<button type="button" onClick={() => change(1)} disabled={count >= max} aria-label="Increment">
+
</button>
</div>
);
}Core Testing Library Queries
Testing Library provides multiple query strategies:
// Get by accessible role (most preferred)
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('heading', { name: /welcome/i });
// Get by label text
screen.getByLabelText('Email address');
// Get by placeholder
screen.getByPlaceholderText('Search...');
// Get by text content
screen.getByText('No results found.');
// Get by test ID (last resort)
screen.getByTestId('loading-spinner');
// Variants:
// getBy* — throws if not found
// queryBy* — returns null if not found (for asserting absence)
// findBy* — async, waits for element to appearAsync Testing with waitFor
For components that update asynchronously (after fetch, after state updates):
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
it('shows search results after typing', async () => {
const user = userEvent.setup();
render(<SearchInput />);
const input = screen.getByRole('searchbox', { name: /search/i });
await user.type(input, 'React');
// Wait for async results to appear
await waitFor(() => {
expect(screen.getByText(/React Hooks/)).toBeInTheDocument();
});
});
// Or use findBy* which implicitly waits
it('shows loading text then data', async () => {
render(<AsyncDataComponent />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
// findByText waits up to 1000ms by default
const heading = await screen.findByRole('heading', { name: /data loaded/i });
expect(heading).toBeInTheDocument();
});Testing Forms
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('LoginForm', () => {
it('submits with valid credentials', async () => {
const user = userEvent.setup();
const onSuccess = vi.fn();
render(<LoginForm onSuccess={onSuccess} />);
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'securepassword');
await user.click(screen.getByRole('button', { name: /log in/i }));
await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce());
});
it('shows validation error for invalid email', async () => {
const user = userEvent.setup();
render(<LoginForm onSuccess={vi.fn()} />);
await user.type(screen.getByLabelText(/email/i), 'not-an-email');
await user.click(screen.getByRole('button', { name: /log in/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/valid email/i);
});
});Testing Custom Hooks with renderHook
import { renderHook, act } from '@testing-library/react';
import { useToggle } from './useToggle';
describe('useToggle', () => {
it('starts with the initial value', () => {
const { result } = renderHook(() => useToggle(true));
expect(result.current.value).toBe(true);
});
it('toggles the value', () => {
const { result } = renderHook(() => useToggle(false));
act(() => result.current.toggle());
expect(result.current.value).toBe(true);
act(() => result.current.toggle());
expect(result.current.value).toBe(false);
});
it('setTrue always sets to true', () => {
const { result } = renderHook(() => useToggle(false));
act(() => result.current.setTrue());
expect(result.current.value).toBe(true);
act(() => result.current.setTrue()); // idempotent
expect(result.current.value).toBe(true);
});
});Mocking with vi.fn() and vi.mock()
import { vi } from 'vitest';
// Mock a function
const handleSubmit = vi.fn();
// Assert it was called
expect(handleSubmit).toHaveBeenCalledOnce();
expect(handleSubmit).toHaveBeenCalledWith({ email: '[email protected]' });
// Mock a module
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
deleteUser: vi.fn().mockResolvedValue({ success: true }),
}));
// Mock fetch globally
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ items: [1, 2, 3] }),
});Testing with Context Providers
Components that consume context need to be wrapped with their provider in tests:
import { type ReactNode } from 'react';
function renderWithProviders(ui: ReactNode) {
return render(
<QueryClientProvider client={new QueryClient()}>
<AuthProvider>
<ThemeProvider>
{ui}
</ThemeProvider>
</AuthProvider>
</QueryClientProvider>
);
}
it('shows user name when authenticated', async () => {
renderWithProviders(<UserMenu />);
// test against the actual context state
});Accessibility Testing with axe
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
it('has no accessibility violations', async () => {
const { container } = render(<RegisterForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});This automatically checks for common WCAG violations including missing labels, poor contrast ratios, and invalid ARIA attributes.
In the final lesson, you will apply everything you have learned by building a complete Kanban task board with drag-and-drop, filtering, and persistent state.
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
describe('Counter component', () => {
it('renders the initial count', () => {
render(<Counter initialValue={5} />);
expect(screen.getByText('Count: 5')).toBeInTheDocument();
});
it('increments the count when the + button is clicked', async () => {
const user = userEvent.setup();
render(<Counter initialValue={0} />);
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
it('decrements the count when the − button is clicked', async () => {
const user = userEvent.setup();
render(<Counter initialValue={3} />);
await user.click(screen.getByRole('button', { name: /decrement/i }));
expect(screen.getByText('Count: 2')).toBeInTheDocument();
});
it('calls onCountChange with the new value', async () => {
const user = userEvent.setup();
const onCountChange = vi.fn();
render(<Counter initialValue={0} onCountChange={onCountChange} />);
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(onCountChange).toHaveBeenCalledOnce();
expect(onCountChange).toHaveBeenCalledWith(1);
});
it('does not go below min value', async () => {
const user = userEvent.setup();
render(<Counter initialValue={0} min={0} />);
const decrementBtn = screen.getByRole('button', { name: /decrement/i });
expect(decrementBtn).toBeDisabled();
await user.click(decrementBtn);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
});
Sign in to track your progress