Testing - Component Level
Unit and integration tests for React components using React Testing Library and Vitest.
Quick Reference
Run tests:
npm run testWatch mode:
npm run test:watchCoverage:
npm run test:coverageCore Principles
✓ Do
- Test user-visible behavior
- Use semantic queries (getByRole, getByLabelText)
- Test accessibility with each component
- Mock external dependencies
- Write tests that resemble how users interact
✗ Don't
- Test implementation details
- Use getByTestId as first choice
- Test internal state directly
- Snapshot test everything
- Write tests that break on refactors
Query Priority
Use queries in this order of preference (most accessible first):
| Priority | Query | Use Case |
|---|---|---|
| 1st | getByRole | Buttons, links, headings, form elements |
| 2nd | getByLabelText | Form inputs with labels |
| 3rd | getByPlaceholderText | Inputs without visible labels |
| 4th | getByText | Non-interactive text content |
| Last | getByTestId | Only when nothing else works |
Example: StandaloneDashboard Tests
Complete test file for the StandaloneDashboard component demonstrating best practices.
src/components/examples/__tests__/StandaloneDashboard.test.tsxView on GitHub →
import React from 'react';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { StandaloneDashboard } from '../StandaloneDashboard';
describe('StandaloneDashboard', () => {
// ===========================================================================
// RENDERING TESTS
// ===========================================================================
describe('rendering', () => {
it('renders the dashboard header', () => {
// Arrange & Act
render(<StandaloneDashboard />);
// Assert
expect(screen.getByRole('heading', { name: /research dashboard/i }))
.toBeInTheDocument();
});
it('renders stats cards with correct values', () => {
render(<StandaloneDashboard />);
expect(screen.getByText('Active Experiments')).toBeInTheDocument();
expect(screen.getByText('12')).toBeInTheDocument();
});
it('renders task queue with all status sections', () => {
render(<StandaloneDashboard />);
expect(screen.getByText('Requires Your Action')).toBeInTheDocument();
expect(screen.getByText('In Progress')).toBeInTheDocument();
expect(screen.getByText('Blocked')).toBeInTheDocument();
});
});
// ===========================================================================
// STATUS BADGE TESTS
// ===========================================================================
describe('status badges', () => {
it('displays correct status badges for tasks', () => {
render(<StandaloneDashboard />);
const doneButtons = screen.getAllByText('Done');
expect(doneButtons.length).toBeGreaterThan(0);
});
it('shows blocked reason for blocked tasks', () => {
render(<StandaloneDashboard />);
expect(screen.getByText(/waiting for structural analysis/i))
.toBeInTheDocument();
});
});
// ===========================================================================
// INTERACTION TESTS
// ===========================================================================
describe('interactions', () => {
it('refresh button is clickable', async () => {
const user = userEvent.setup();
render(<StandaloneDashboard />);
const refreshButton = screen.getByRole('button', { name: /refresh/i });
await user.click(refreshButton);
expect(refreshButton).toBeInTheDocument();
});
it('checkboxes are rendered for tasks', () => {
render(<StandaloneDashboard />);
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes.length).toBeGreaterThan(0);
});
});
// ===========================================================================
// ACCESSIBILITY TESTS
// ===========================================================================
describe('accessibility', () => {
it('has proper heading hierarchy', () => {
render(<StandaloneDashboard />);
const h1 = screen.getByRole('heading', { level: 1 });
expect(h1).toHaveTextContent(/research dashboard/i);
});
it('buttons have accessible names', () => {
render(<StandaloneDashboard />);
const buttons = screen.getAllByRole('button');
buttons.forEach(button => {
expect(button).toHaveAccessibleName();
});
});
});
});Common Test Patterns
Pattern: Arrange → Act → Assert
it('submits form with user input', async () => {
// Arrange
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
// Act
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
// Assert
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});Pattern: Async Operations
it('loads and displays data', async () => {
render(<DataList />);
// Wait for loading to complete
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for data to appear
await screen.findByText('First Item');
// Assert data is displayed
expect(screen.getByText('First Item')).toBeInTheDocument();
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});Pattern: User Interactions
it('toggles dropdown on click', async () => {
const user = userEvent.setup();
render(<Dropdown options={['A', 'B', 'C']} />);
// Initially closed
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
// Open dropdown
await user.click(screen.getByRole('button', { name: /select/i }));
expect(screen.getByRole('listbox')).toBeVisible();
// Select option
await user.click(screen.getByRole('option', { name: 'B' }));
expect(screen.getByRole('button')).toHaveTextContent('B');
});Pattern: Mocking with MSW
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
http.get('/api/experiments', () => {
return HttpResponse.json([
{ id: 1, name: 'Experiment A' },
{ id: 2, name: 'Experiment B' },
]);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it('displays experiments from API', async () => {
render(<ExperimentList />);
await screen.findByText('Experiment A');
expect(screen.getByText('Experiment B')).toBeInTheDocument();
});
it('handles API errors', async () => {
server.use(
http.get('/api/experiments', () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<ExperimentList />);
await screen.findByText(/error loading/i);
});Dependencies
npm install -D vitest @testing-library/react @testing-library/user-event
npm install -D @testing-library/jest-dom jsdom
npm install -D msw # For API mockingVitest Configuration
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
},
});Test Setup File
// src/test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});