React Testing and Testing Library
1. React Testing Library (RTL) Best Practices
| Practice | Description | Example | Rationale |
|---|---|---|---|
| Test User Behavior | Test components as users interact with them, not implementation details | screen.getByRole('button') |
Tests are resilient to refactoring and reflect real usage |
| Query Priority | Use accessible queries first: getByRole, getByLabelText, getByPlaceholderText, getByText | getByRole('textbox', {name: /email/i}) |
Ensures accessibility and semantic HTML |
| Avoid Test IDs | Use data-testid only as last resort when no semantic query available | getByTestId('custom-widget') |
Encourages better accessibility practices |
| User-Event Library | Use @testing-library/user-event for realistic interactions | await user.click(button) |
Simulates real browser events more accurately than fireEvent |
| Async Utilities | Use waitFor, findBy queries for async operations | await screen.findByText('Loaded') |
Handles async updates and avoids race conditions |
| Avoid act() Warnings | Use RTL's async utilities instead of manual act() wrapping | await waitFor(() => expect(...)) |
Built-in utilities handle act() automatically |
Example: Basic RTL test with best practices
// UserGreeting.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import UserGreeting from './UserGreeting';
describe('UserGreeting', () => {
it('displays greeting after user enters name', async () => {
const user = userEvent.setup();
render(<UserGreeting />);
// Use accessible query
const input = screen.getByRole('textbox', { name: /your name/i });
const button = screen.getByRole('button', { name: /submit/i });
// Simulate user interaction
await user.type(input, 'Alice');
await user.click(button);
// Assert user-visible outcome
expect(await screen.findByText(/hello, alice/i)).toBeInTheDocument();
});
});
Example: Query priority guide
// Preferred: Accessible to everyone
screen.getByRole('button', { name: /submit/i })
screen.getByLabelText(/email address/i)
screen.getByPlaceholderText(/search.../i)
screen.getByText(/welcome back/i)
// Semantic queries
screen.getByAltText(/company logo/i)
screen.getByTitle(/close dialog/i)
// Test IDs (last resort)
screen.getByTestId('custom-element')
2. Component Testing and User Interactions (fireEvent, userEvent)
| Test Type | Focus | Query Method | Example |
|---|---|---|---|
| Form Input | User typing and form submission | user.type(), user.click() |
Type into input, submit form, verify result |
| Button Clicks | Click handlers and state changes | user.click() |
Click button, verify UI update or API call |
| Async Data Loading | Fetch and display data | findBy*, waitFor() |
Wait for loading state, then verify data displayed |
| Conditional Rendering | Show/hide elements based on state | queryBy*, expect().not.toBeInTheDocument() |
Verify element appears/disappears correctly |
| Keyboard Navigation | Tab, Enter, Escape key handling | user.keyboard(), user.tab() |
Navigate with keyboard, verify focus management |
| Selection Controls | Radio, checkbox, select interactions | user.selectOptions(), user.click() |
Select options, verify state and behavior |
Example: Testing form with validation
// LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
describe('LoginForm', () => {
it('shows validation errors for invalid inputs', async () => {
const user = userEvent.setup();
const mockSubmit = jest.fn();
render(<LoginForm onSubmit={mockSubmit} />);
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole('button', { name: /log in/i });
// Submit with invalid email
await user.type(emailInput, 'invalid-email');
await user.type(passwordInput, '123');
await user.click(submitButton);
// Verify validation errors appear
expect(await screen.findByText(/invalid email format/i)).toBeInTheDocument();
expect(await screen.findByText(/password must be at least 8 characters/i)).toBeInTheDocument();
expect(mockSubmit).not.toHaveBeenCalled();
});
it('submits form with valid inputs', async () => {
const user = userEvent.setup();
const mockSubmit = jest.fn();
render(<LoginForm onSubmit={mockSubmit} />);
await user.type(screen.getByLabelText(/email/i), 'user@example.com');
await user.type(screen.getByLabelText(/password/i), 'securePassword123');
await user.click(screen.getByRole('button', { name: /log in/i }));
expect(mockSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'securePassword123'
});
});
});
Example: Testing async data fetching
// UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';
import { fetchUser } from './api';
jest.mock('./api');
describe('UserProfile', () => {
it('displays loading state then user data', async () => {
fetchUser.mockResolvedValue({
id: 1,
name: 'Alice',
email: 'alice@example.com'
});
render(<UserProfile userId={1} />);
// Verify loading state
expect(screen.getByText(/loading.../i)).toBeInTheDocument();
// Wait for data to load
expect(await screen.findByText('Alice')).toBeInTheDocument();
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
expect(screen.queryByText(/loading.../i)).not.toBeInTheDocument();
});
it('displays error message on fetch failure', async () => {
fetchUser.mockRejectedValue(new Error('Network error'));
render(<UserProfile userId={1} />);
expect(await screen.findByText(/failed to load user/i)).toBeInTheDocument();
});
});
3. Hook Testing with renderHook API
| Hook Type | Testing Approach | Key Method | Example |
|---|---|---|---|
| Custom Hooks | Test hook logic in isolation | renderHook() |
Test custom hook without wrapping component |
| State Updates | Test state changes and side effects | result.current, act() |
Access hook values and trigger updates |
| Async Hooks | Test async operations in hooks | waitFor(() => expect(...)) |
Wait for async state updates |
| Hook Dependencies | Test effect dependencies and re-runs | rerender() |
Change props and verify effect behavior |
| Context Hooks | Test hooks requiring context | wrapper: Provider |
Wrap hook with necessary providers |
| Cleanup Testing | Verify cleanup functions run | unmount() |
Test effect cleanup and subscriptions |
Example: Testing custom hook with state
// useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('decrements count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
it('resets count to initial value', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
Example: Testing async hook with API calls
// useFetchUser.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import useFetchUser from './useFetchUser';
import { fetchUser } from './api';
jest.mock('./api');
describe('useFetchUser', () => {
it('fetches user data successfully', async () => {
const mockUser = { id: 1, name: 'Alice' };
fetchUser.mockResolvedValue(mockUser);
const { result } = renderHook(() => useFetchUser(1));
// Initial state
expect(result.current.loading).toBe(true);
expect(result.current.data).toBeNull();
expect(result.current.error).toBeNull();
// Wait for data to load
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockUser);
expect(result.current.error).toBeNull();
});
it('handles fetch errors', async () => {
fetchUser.mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => useFetchUser(1));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBeNull();
expect(result.current.error).toEqual(new Error('Network error'));
});
it('refetches when userId changes', async () => {
fetchUser.mockResolvedValue({ id: 1, name: 'Alice' });
const { result, rerender } = renderHook(
({ userId }) => useFetchUser(userId),
{ initialProps: { userId: 1 } }
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(fetchUser).toHaveBeenCalledWith(1);
// Change userId
fetchUser.mockResolvedValue({ id: 2, name: 'Bob' });
rerender({ userId: 2 });
await waitFor(() => expect(result.current.data.name).toBe('Bob'));
expect(fetchUser).toHaveBeenCalledWith(2);
});
});
Example: Testing hook with context
// useAuth.test.tsx
import { renderHook } from '@testing-library/react';
import { AuthProvider } from './AuthContext';
import useAuth from './useAuth';
const wrapper = ({ children }) => (
<AuthProvider initialUser={{ id: 1, name: 'Alice' }}>
{children}
</AuthProvider>
);
describe('useAuth', () => {
it('provides auth context values', () => {
const { result } = renderHook(() => useAuth(), { wrapper });
expect(result.current.user).toEqual({ id: 1, name: 'Alice' });
expect(result.current.isAuthenticated).toBe(true);
});
it('throws error when used outside provider', () => {
// Suppress console.error for this test
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => {
renderHook(() => useAuth());
}).toThrow('useAuth must be used within AuthProvider');
spy.mockRestore();
});
});
4. Mocking Components, Props, and API Calls
| Mock Type | Use Case | Implementation | Example |
|---|---|---|---|
| Module Mocking | Replace entire module with mock | jest.mock('./module') |
Mock API clients, utilities |
| Function Mocking | Mock specific functions | jest.fn() |
Mock callbacks, event handlers |
| Component Mocking | Replace child components | jest.mock('./Component') |
Isolate component under test |
| Spy Functions | Track function calls without replacing | jest.spyOn(obj, 'method') |
Verify method calls on objects |
| Timer Mocking | Control setTimeout, setInterval | jest.useFakeTimers() |
Test debounce, throttle, delays |
| Network Mocking | Mock HTTP requests | MSW (Mock Service Worker) |
Mock fetch, axios requests |
Example: Mocking API calls
// TodoList.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoList from './TodoList';
import * as api from './api';
// Mock the entire API module
jest.mock('./api');
describe('TodoList', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('loads and displays todos', async () => {
const mockTodos = [
{ id: 1, title: 'Buy milk', completed: false },
{ id: 2, title: 'Walk dog', completed: true }
];
api.fetchTodos.mockResolvedValue(mockTodos);
render(<TodoList />);
expect(await screen.findByText('Buy milk')).toBeInTheDocument();
expect(screen.getByText('Walk dog')).toBeInTheDocument();
expect(api.fetchTodos).toHaveBeenCalledTimes(1);
});
it('adds new todo', async () => {
const user = userEvent.setup();
api.fetchTodos.mockResolvedValue([]);
api.createTodo.mockResolvedValue({ id: 3, title: 'New task', completed: false });
render(<TodoList />);
const input = screen.getByPlaceholderText(/new todo/i);
await user.type(input, 'New task');
await user.click(screen.getByRole('button', { name: /add/i }));
await waitFor(() => {
expect(api.createTodo).toHaveBeenCalledWith({ title: 'New task' });
});
expect(await screen.findByText('New task')).toBeInTheDocument();
});
});
Example: Mocking child components
// Dashboard.test.tsx
import { render, screen } from '@testing-library/react';
import Dashboard from './Dashboard';
// Mock complex child components
jest.mock('./UserProfile', () => {
return function MockUserProfile({ userId }) {
return <div>User Profile: {userId}</div>;
};
});
jest.mock('./ActivityFeed', () => {
return function MockActivityFeed() {
return <div>Activity Feed</div>;
};
});
describe('Dashboard', () => {
it('renders main layout with mocked children', () => {
render(<Dashboard userId={123} />);
expect(screen.getByText('User Profile: 123')).toBeInTheDocument();
expect(screen.getByText('Activity Feed')).toBeInTheDocument();
});
});
Example: Using Mock Service Worker (MSW)
// setupTests.ts
import { setupServer } from 'msw/node';
import { rest } from 'msw';
const handlers = [
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
])
);
}),
];
export const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// UserList.test.tsx
import { render, screen } from '@testing-library/react';
import { server } from './setupTests';
import { rest } from 'msw';
import UserList from './UserList';
describe('UserList', () => {
it('displays users from API', async () => {
render(<UserList />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
});
it('handles API errors', async () => {
// Override handler for this test
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Server error' }));
})
);
render(<UserList />);
expect(await screen.findByText(/failed to load users/i)).toBeInTheDocument();
});
});
Example: Testing with fake timers
// SearchInput.test.tsx - testing debounced input
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SearchInput from './SearchInput';
describe('SearchInput with debounce', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('debounces search callback', async () => {
const mockSearch = jest.fn();
const user = userEvent.setup({ delay: null }); // Disable userEvent delays
render(<SearchInput onSearch={mockSearch} debounceMs={500} />);
const input = screen.getByRole('textbox');
// Type quickly
await user.type(input, 'test');
// Callback not called yet
expect(mockSearch).not.toHaveBeenCalled();
// Fast-forward time
jest.advanceTimersByTime(500);
// Now callback is called once with final value
expect(mockSearch).toHaveBeenCalledTimes(1);
expect(mockSearch).toHaveBeenCalledWith('test');
});
});
5. Integration Testing with React Router and Context
| Test Scenario | Setup | Key Utilities | Example |
|---|---|---|---|
| Route Rendering | Wrap with MemoryRouter | MemoryRouter |
Test component renders at specific route |
| Navigation | Simulate link clicks | user.click(), screen queries |
Click link, verify new route content |
| Route Parameters | Initialize with initialEntries | initialEntries: ['/user/123'] |
Test dynamic route params |
| Protected Routes | Test auth redirects | Navigate, conditional rendering |
Verify redirect when unauthorized |
| History Manipulation | Create test history | createMemoryHistory() |
Test back/forward navigation |
| Search Params | Include query strings | initialEntries: ['?tab=2'] |
Test URL search params handling |
Example: Testing navigation and routes
// App.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import App from './App';
describe('App Navigation', () => {
it('navigates from home to about page', async () => {
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>
);
// Verify home page
expect(screen.getByText(/welcome home/i)).toBeInTheDocument();
// Click about link
const aboutLink = screen.getByRole('link', { name: /about/i });
await user.click(aboutLink);
// Verify about page
expect(screen.getByText(/about us/i)).toBeInTheDocument();
expect(screen.queryByText(/welcome home/i)).not.toBeInTheDocument();
});
it('renders 404 page for unknown routes', () => {
render(
<MemoryRouter initialEntries={['/unknown-route']}>
<App />
</MemoryRouter>
);
expect(screen.getByText(/404.*not found/i)).toBeInTheDocument();
});
});
Example: Testing dynamic routes with params
// UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import UserProfile from './UserProfile';
import * as api from './api';
jest.mock('./api');
describe('UserProfile', () => {
it('loads user data based on route param', async () => {
const mockUser = { id: '123', name: 'Alice', email: 'alice@example.com' };
api.fetchUser.mockResolvedValue(mockUser);
render(
<MemoryRouter initialEntries={['/user/123']}>
<Routes>
<Route path="/user/:userId" element={<UserProfile />} />
</Routes>
</MemoryRouter>
);
await waitFor(() => {
expect(api.fetchUser).toHaveBeenCalledWith('123');
});
expect(await screen.findByText('Alice')).toBeInTheDocument();
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
});
});
Example: Testing protected routes
// ProtectedRoute.test.tsx
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import App from './App';
import { AuthProvider } from './AuthContext';
describe('Protected Routes', () => {
it('redirects to login when not authenticated', () => {
render(
<MemoryRouter initialEntries={['/dashboard']}>
<AuthProvider initialAuth={{ user: null, isAuthenticated: false }}>
<App />
</AuthProvider>
</MemoryRouter>
);
// Should be redirected to login
expect(screen.getByText(/please log in/i)).toBeInTheDocument();
expect(screen.queryByText(/dashboard/i)).not.toBeInTheDocument();
});
it('allows access when authenticated', () => {
render(
<MemoryRouter initialEntries={['/dashboard']}>
<AuthProvider initialAuth={{
user: { id: 1, name: 'Alice' },
isAuthenticated: true
}}>
<App />
</AuthProvider>
</MemoryRouter>
);
// Should show dashboard
expect(screen.getByText(/dashboard/i)).toBeInTheDocument();
expect(screen.queryByText(/please log in/i)).not.toBeInTheDocument();
});
});
6. End-to-End Testing (Cypress, Playwright, Vitest)
| Tool | Strengths | Test Pattern | Use Case |
|---|---|---|---|
| Cypress | Developer-friendly, time-travel debugging, real-time reloading | cy.visit(), cy.get(), cy.click() |
Full user flows, visual testing, network mocking |
| Playwright | Multi-browser, parallel execution, mobile emulation, auto-wait | page.goto(), page.click(), page.fill() |
Cross-browser testing, mobile testing, API testing |
| Cypress Commands | Custom reusable commands | Cypress.Commands.add() |
Login flows, common interactions |
| Network Interception | Mock/stub API responses | cy.intercept(), page.route() |
Test error states, loading states |
| Visual Testing | Screenshot comparison | cy.screenshot(), expect(screenshot) |
Detect visual regressions |
| Component Testing | Mount React components in test runner | cy.mount(), mount() |
Component isolation with real browser |
Example: Cypress E2E test
// cypress/e2e/login.cy.js
describe('Login Flow', () => {
beforeEach(() => {
cy.visit('http://localhost:3000');
});
it('allows user to log in with valid credentials', () => {
// Intercept API call
cy.intercept('POST', '/api/login', {
statusCode: 200,
body: {
user: { id: 1, name: 'Alice', email: 'alice@example.com' },
token: 'fake-jwt-token'
}
}).as('loginRequest');
// Fill login form
cy.get('input[name="email"]').type('alice@example.com');
cy.get('input[name="password"]').type('password123');
cy.get('button[type="submit"]').click();
// Wait for API call
cy.wait('@loginRequest');
// Verify redirect to dashboard
cy.url().should('include', '/dashboard');
cy.contains('Welcome, Alice').should('be.visible');
// Verify token stored
cy.window().its('localStorage.token').should('exist');
});
it('shows error message for invalid credentials', () => {
cy.intercept('POST', '/api/login', {
statusCode: 401,
body: { error: 'Invalid credentials' }
}).as('loginRequest');
cy.get('input[name="email"]').type('wrong@example.com');
cy.get('input[name="password"]').type('wrongpassword');
cy.get('button[type="submit"]').click();
cy.wait('@loginRequest');
// Verify error message
cy.contains('Invalid credentials').should('be.visible');
cy.url().should('include', '/login');
});
it('validates form inputs', () => {
cy.get('button[type="submit"]').click();
// Check validation errors
cy.contains('Email is required').should('be.visible');
cy.contains('Password is required').should('be.visible');
// Fill invalid email
cy.get('input[name="email"]').type('invalid-email');
cy.get('button[type="submit"]').click();
cy.contains('Invalid email format').should('be.visible');
});
});
Example: Cypress custom commands
// cypress/support/commands.js
Cypress.Commands.add('login', (email, password) => {
cy.visit('/login');
cy.get('input[name="email"]').type(email);
cy.get('input[name="password"]').type(password);
cy.get('button[type="submit"]').click();
cy.url().should('include', '/dashboard');
});
Cypress.Commands.add('logout', () => {
cy.get('[data-testid="user-menu"]').click();
cy.contains('Logout').click();
cy.url().should('include', '/login');
});
// Usage in tests
describe('Dashboard', () => {
beforeEach(() => {
cy.login('alice@example.com', 'password123');
});
it('displays user data', () => {
cy.contains('Welcome, Alice').should('be.visible');
});
it('allows user to logout', () => {
cy.logout();
});
});
Example: Playwright E2E test
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Login Flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:3000');
});
test('allows user to log in with valid credentials', async ({ page }) => {
// Mock API response
await page.route('**/api/login', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
user: { id: 1, name: 'Alice', email: 'alice@example.com' },
token: 'fake-jwt-token'
})
});
});
// Fill and submit form
await page.fill('input[name="email"]', 'alice@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
// Verify redirect
await expect(page).toHaveURL(/.*dashboard/);
await expect(page.locator('text=Welcome, Alice')).toBeVisible();
});
test('handles network errors gracefully', async ({ page }) => {
// Simulate network failure
await page.route('**/api/login', (route) => route.abort());
await page.fill('input[name="email"]', 'alice@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
// Verify error message
await expect(page.locator('text=/network error/i')).toBeVisible();
});
});
Example: Playwright with fixtures and page objects
// tests/fixtures.ts
import { test as base } from '@playwright/test';
// Create custom fixture for authenticated user
export const test = base.extend({
authenticatedPage: async ({ page }, use) => {
await page.goto('http://localhost:3000/login');
await page.fill('input[name="email"]', 'alice@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard');
await use(page);
}
});
// tests/pages/DashboardPage.ts
export class DashboardPage {
constructor(private page) {}
async navigateToSettings() {
await this.page.click('[data-testid="settings-link"]');
}
async getUserName() {
return await this.page.locator('[data-testid="user-name"]').textContent();
}
async createNewProject(name: string) {
await this.page.click('button:has-text("New Project")');
await this.page.fill('input[name="project-name"]', name);
await this.page.click('button:has-text("Create")');
}
}
// tests/dashboard.spec.ts
import { test } from './fixtures';
import { DashboardPage } from './pages/DashboardPage';
import { expect } from '@playwright/test';
test('user can create new project', async ({ authenticatedPage }) => {
const dashboard = new DashboardPage(authenticatedPage);
await dashboard.createNewProject('My New Project');
await expect(authenticatedPage.locator('text=My New Project')).toBeVisible();
});
E2E Testing Best Practices: Start server before tests, use baseURL configuration, avoid
hard-coded waits (use auto-waiting), test critical user paths only, run in CI/CD pipeline, use parallel
execution, implement proper cleanup, mock external services, use data-testid for stable selectors, organize
tests by feature, maintain test independence.