Developing Unit Tests for Components (React Testing Library)
React Testing Library is built on one idea: tests should check component behavior from user perspective, not implementation details. No direct state access, no checking instance variables, no wrapper.find(MyInternalComponent). Only what actually renders in DOM and how user interacts with it.
This is both advantage and limitation. Tests don't break on internal refactoring, but require well-designed components—with accessible roles, clear data-testid and predictable behavior.
Installation and Configuration
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event vitest jsdom
Vitest configuration with jsdom support:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true,
},
});
// src/test/setup.ts
import '@testing-library/jest-dom';
Basic Patterns
First thing to understand—difference between getBy, queryBy and findBy:
-
getBy—synchronous, throws if not found -
queryBy—synchronous, returns null if not found (for checking absence) -
findBy—asynchronous, waits for element to appear (for async operations)
// components/LoginForm/LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('renders email and password fields', () => {
render(<LoginForm onSubmit={vi.fn()} />);
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
});
it('calls onSubmit with entered data', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /login/i }));
expect(handleSubmit).toHaveBeenCalledWith({
email: '[email protected]',
password: 'password123',
});
});
it('shows error on empty email', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={vi.fn()} />);
await user.click(screen.getByRole('button', { name: /login/i }));
expect(screen.getByText(/enter email/i)).toBeInTheDocument();
});
});
Mocks and Providers
Real app components depend on context—router, store, i18n, query client. Common pattern—custom render:
// src/test/render.tsx
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { ReactNode } from 'react';
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
}
export function renderWithProviders(
ui: React.ReactElement,
options?: RenderOptions & { initialEntries?: string[] }
) {
const { initialEntries = ['/'], ...rest } = options ?? {};
const queryClient = createTestQueryClient();
function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={initialEntries}>
{children}
</MemoryRouter>
</QueryClientProvider>
);
}
return render(ui, { wrapper: Wrapper, ...rest });
}
Testing Async Components
// components/UserProfile/UserProfile.test.tsx
import { renderWithProviders } from '@/test/render';
import { screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';
it('loads and displays user data', async () => {
renderWithProviders(<UserProfile userId="42" />);
// First should show loading
expect(screen.getByRole('progressbar')).toBeInTheDocument();
// Wait for data to appear
expect(await screen.findByText('Test User')).toBeInTheDocument();
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
Testing Forms with React Hook Form
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProductForm } from './ProductForm';
describe('ProductForm', () => {
it('validates required fields before submit', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<ProductForm onSubmit={onSubmit} />);
// Empty submit
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(screen.getByText(/name required/i)).toBeInTheDocument();
});
expect(onSubmit).not.toHaveBeenCalled();
});
it('submits correct data', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<ProductForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/name/i), 'New product');
await user.type(screen.getByLabelText(/price/i), '1500');
await user.selectOptions(screen.getByLabelText(/category/i), 'electronics');
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
name: 'New product',
price: 1500,
category: 'electronics',
});
});
});
});
Timeline
Write first tests for existing project from scratch: setup environment + basic tests for key components—3–5 days. Cover medium complexity component (form, list, dialog) with tests—4–8 hours depending on states count.







