This document covers all aspects of testing the React/TypeScript frontend, including component tests, integration tests, and testing utilities.
- Overview
- Test Organization
- Running Tests
- Writing Tests
- Test Utilities
- Best Practices
- Debugging
- Coverage
The frontend uses Vitest with React Testing Library:
- Test Framework: Vitest
- Component Testing: React Testing Library
- DOM Environment: jsdom
- Coverage: Built-in Vitest coverage
- User-centric: Test from the user's perspective, not implementation details
- Component isolation: Mock external dependencies
- Accessibility: Include accessibility checks in component tests
- Real behavior: Test actual user interactions
frontend/
├── src/
│ ├── components/
│ │ ├── ui/
│ │ │ └── **/__tests__/ # UI component tests
│ │ ├── layout/
│ │ │ └── **/__tests__/ # Layout component tests
│ │ └── dashboard/
│ │ └── **/__tests__/ # Dashboard component tests
│ ├── hooks/
│ │ └── **/*.test.ts # Hook tests
│ ├── utils/
│ │ └── **/*.test.ts # Utility function tests
│ └── pages/
│ └── **/*.test.tsx # Page component tests
├── tests/
│ ├── setup.ts # Global test setup
│ └── fixtures/ # Shared test data
└── vitest.config.ts # Vitest configuration
- Component tests:
ComponentName.test.tsx - Utility tests:
utilityName.test.ts - Hook tests:
useHookName.test.ts - Test directories:
__tests__/
# Run all frontend tests
make test-frontend # pnpm test -- --run
# Or directly
cd frontend
pnpm test -- --runcd frontend
pnpm test -- --runcd frontend
pnpm testcd frontend
pnpm test src/components/Button.test.tsxcd frontend
pnpm test -- -t "test name pattern"cd frontend
pnpm test -- --uiTest React components in isolation with mocked dependencies:
// src/components/__tests__/Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { Button } from '../Button';
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledOnce();
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>);
expect(screen.getByText('Click me')).toBeDisabled();
});
});Test custom hooks using renderHook:
// src/hooks/__tests__/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useCounter } from '../useCounter';
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('increments counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('accepts initial value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
});Test pure functions directly:
// src/utils/__tests__/formatDate.test.ts
import { describe, it, expect } from 'vitest';
import { formatDate, formatRelativeTime } from '../formatDate';
describe('formatDate', () => {
it('formats date correctly', () => {
const date = new Date('2025-01-15T10:30:00Z');
expect(formatDate(date)).toBe('Jan 15, 2025');
});
it('handles invalid date', () => {
expect(formatDate(null)).toBe('Invalid date');
});
});
describe('formatRelativeTime', () => {
it('shows "just now" for recent dates', () => {
const now = new Date();
expect(formatRelativeTime(now)).toBe('just now');
});
});Wrap components that need context providers:
// tests/utils/renderWithProviders.tsx
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
route?: string;
}
export function renderWithProviders(
ui: React.ReactElement,
{ route = '/', ...options }: CustomRenderOptions = {}
) {
window.history.pushState({}, 'Test page', route);
const queryClient = createTestQueryClient();
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>{children}</BrowserRouter>
</QueryClientProvider>
);
}
return {
...render(ui, { wrapper: Wrapper, ...options }),
queryClient,
};
}Usage:
import { renderWithProviders } from '../tests/utils/renderWithProviders';
it('renders dashboard', () => {
renderWithProviders(<Dashboard />, { route: '/dashboard' });
expect(screen.getByText('Dashboard')).toBeInTheDocument();
});Use MSW (Mock Service Worker) for API mocking:
// tests/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Doe' },
]);
}),
http.post('/api/login', async ({ request }) => {
const body = await request.json();
if (body.email === 'test@example.com') {
return HttpResponse.json({ token: 'fake-token' });
}
return HttpResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}),
];// tests/setup.ts
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';
export const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());The setup file provides:
- DOM mocking (matchMedia, IntersectionObserver, ResizeObserver)
- Automatic cleanup after each test
- CI environment detection
- Test utilities (wait, flushPromises)
// Wait for async operations
await waitFor(() => {
expect(screen.getByText('Loaded')).toBeInTheDocument();
});
// Wait for element to disappear
await waitForElementToBeRemoved(() => screen.queryByText('Loading...'));
// Flush promises
await new Promise((resolve) => setTimeout(resolve, 0));Vitest includes jest-dom matchers:
expect(element).toBeInTheDocument();
expect(element).toBeVisible();
expect(element).toBeDisabled();
expect(element).toHaveTextContent('text');
expect(element).toHaveAttribute('href', '/path');
expect(element).toHaveClass('active');- Test behavior, not implementation
- Use accessible queries (
getByRole,getByLabelText) - Mock external dependencies (API calls, browser APIs)
- Test user interactions with
userEvent - Write descriptive test names
- Test error states and edge cases
- Keep tests focused and small
- Use
screenfor queries (better error messages)
- Test implementation details (internal state, props)
- Use
getByTestIdas primary query (prefer accessible queries) - Mock too much (test real interactions when possible)
- Write tests that depend on each other
- Test third-party library behavior
- Use
fireEventwhenuserEventworks (userEvent is more realistic)
Use queries in this order (most to least preferred):
getByRole- accessible to everyonegetByLabelText- form fieldsgetByPlaceholderText- when label is not availablegetByText- non-interactive contentgetByDisplayValue- current value of form elementsgetByAltText- imagesgetByTitle- title attributegetByTestId- last resort
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { LoginForm } from '../LoginForm';
describe('LoginForm', () => {
it('submits form with valid data', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
// Fill form using accessible queries
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
// Submit
await user.click(screen.getByRole('button', { name: /sign in/i }));
// Verify
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
});
it('shows validation errors for empty fields', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={vi.fn()} />);
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
});
it('disables submit button while loading', () => {
render(<LoginForm onSubmit={vi.fn()} isLoading />);
expect(screen.getByRole('button', { name: /sign in/i })).toBeDisabled();
});
});pnpm test -- --watch --reporter=verboseimport { screen } from '@testing-library/react';
// Print current DOM
screen.debug();
// Print specific element
screen.debug(screen.getByRole('button'));pnpm test -- --uiimport { screen, logRoles } from '@testing-library/react';
// Log all accessible roles
logRoles(screen.getByTestId('container'));cd frontend
pnpm test -- --run --coverageopen coverage/index.html # macOS
xdg-open coverage/index.html # Linux- Lines: 80%+
- Functions: 75%+
- Branches: 75%+
- Statements: 80%+
In vitest.config.ts:
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: ['node_modules/', 'tests/', '**/*.d.ts', '**/*.config.*'],
},
},
});import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
isolate: true,
threads: true,
maxThreads: 4,
clearMocks: true,
mockReset: true,
restoreMocks: true,
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
},
},
});Each test runs in isolated environment:
- Each test runs in isolated environment
- Mocks are reset between tests
- DOM cleanup after each test
- Up to 4 parallel threads
- Component tests: < 200ms per test
- Full suite: < 2 minutes (CI)