Why Every Deployment Feels Like a Gamble
Your React application works perfectly in development, but every deployment feels like a gamble. A seemingly harmless component update breaks the checkout flow. A new feature accidentally removes critical accessibility attributes. A performance optimization introduces a race condition that only appears under load.
Sound familiar? The difference between React applications that scale confidently and those that accumulate technical debt isn't just the code quality - it's the testing strategy that catches problems before users experience them.
The challenge isn't just writing tests; it's writing the right tests. I've seen teams with 90% test coverage still shipping broken features because they tested implementation details instead of user behavior. Conversely, I've worked with applications that had modest test coverage but rock-solid reliability because every test validated actual user workflows.
This guide focuses on building a testing strategy that provides real confidence in your React applications. You'll learn how to test user interactions rather than internal component mechanics, how to structure tests that remain valuable as your codebase evolves, and how to balance testing speed with thoroughness.
By the end, you'll have a systematic approach to React testing that catches real bugs, documents intended behavior, and enables confident refactoring as your application grows.
React testing philosophy and foundations
Before diving into specific testing techniques, it's essential to understand the philosophy that makes React testing effective and maintainable.
Testing user behavior, not implementation
The fundamental principle of effective React testing is focusing on what users experience rather than how components work internally:
// ❌ Bad: Testing implementation details
test('should call setState when button is clicked', () => {
const component = mount(<Counter />);
const button = component.find('button');
const spy = jest.spyOn(component.instance(), 'setState');
button.simulate('click');
expect(spy).toHaveBeenCalledWith({ count: 1 });
});
// ✅ Good: Testing user behavior
test('should increment counter when button is clicked', () => {
render(<Counter />);
const button = screen.getByRole('button', { name: /increment/i });
const counter = screen.getByText('Count: 0');
fireEvent.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
This approach makes tests more resilient to refactoring and better documents the component's intended behavior.
Test setup and configuration
Establish a solid testing foundation with proper configuration:
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
// Module path mapping
moduleNameMapping: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2)$': '<rootDir>/__mocks__/fileMock.js'
},
// Coverage configuration
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/index.js',
'!src/reportWebVitals.js'
],
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70
}
},
// Transform configuration
transform: {
'^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
['@babel/preset-react', { runtime: 'automatic' }],
'@babel/preset-typescript'
]
}]
}
};
// src/setupTests.js
import '@testing-library/jest-dom';
import { configure } from '@testing-library/react';
// Configure Testing Library
configure({
// Increase timeout for async operations
asyncUtilTimeout: 5000,
// Better error messages
getElementError: (message, container) => {
const error = new Error(message);
error.name = 'TestingLibraryElementError';
error.stack = null;
return error;
}
});
// Global test utilities
global.mockConsoleError = () => {
const originalError = console.error;
console.error = jest.fn();
return () => {
console.error = originalError;
};
};
// Mock IntersectionObserver for components that use it
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
unobserve() {}
};
// Mock matchMedia for responsive components
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
Testing utilities and helpers
Create reusable testing utilities to reduce boilerplate:
// src/test-utils.jsx
import { render as rtlRender } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext';
// Custom render function with providers
function render(ui, options = {}) {
const {
preloadedState = {},
route = '/',
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false }
}
}),
...renderOptions
} = options;
function Wrapper({ children }) {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<ThemeProvider>
<AuthProvider initialAuth={preloadedState.auth}>
{children}
</AuthProvider>
</ThemeProvider>
</BrowserRouter>
</QueryClientProvider>
);
}
// Navigate to route if provided
if (route !== '/') {
window.history.pushState({}, 'Test page', route);
}
return rtlRender(ui, { wrapper: Wrapper, ...renderOptions });
}
// Factory functions for test data
export const createMockUser = (overrides = {}) => ({
id: 1,
name: 'John Doe',
email: 'john@example.com',
role: 'user',
createdAt: '2023-01-01T00:00:00Z',
...overrides
});
export const createMockProduct = (overrides = {}) => ({
id: 1,
name: 'Test Product',
price: 99.99,
description: 'A test product',
inStock: true,
category: 'electronics',
...overrides
});
// Custom matchers
expect.extend({
toBeVisible(received) {
const pass = received.style.display !== 'none' &&
received.style.visibility !== 'hidden' &&
received.style.opacity !== '0';
if (pass) {
return {
message: () => `expected element not to be visible`,
pass: true,
};
} else {
return {
message: () => `expected element to be visible`,
pass: false,
};
}
},
});
// Re-export everything
export * from '@testing-library/react';
export { render };
Unit testing React components
Unit testing focuses on individual components in isolation, verifying their behavior under different conditions.
Testing component rendering and props
Start with basic rendering tests that verify component behavior:
import { render, screen } from '@/test-utils';
import UserCard from './UserCard';
describe('UserCard', () => {
const defaultProps = {
user: {
id: 1,
name: 'Jane Smith',
email: 'jane@example.com',
avatar: 'https://example.com/avatar.jpg',
role: 'admin'
},
onEdit: jest.fn(),
onDelete: jest.fn()
};
beforeEach(() => {
jest.clearAllMocks();
});
test('renders user information correctly', () => {
render(<UserCard {...defaultProps} />);
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
expect(screen.getByText('jane@example.com')).toBeInTheDocument();
expect(screen.getByText('admin')).toBeInTheDocument();
const avatar = screen.getByAltText('Jane Smith');
expect(avatar).toHaveAttribute('src', 'https://example.com/avatar.jpg');
});
test('calls onEdit when edit button is clicked', async () => {
const user = userEvent.setup();
render(<UserCard {...defaultProps} />);
const editButton = screen.getByRole('button', { name: /edit/i });
await user.click(editButton);
expect(defaultProps.onEdit).toHaveBeenCalledWith(defaultProps.user.id);
expect(defaultProps.onEdit).toHaveBeenCalledTimes(1);
});
test('calls onDelete when delete button is clicked', async () => {
const user = userEvent.setup();
render(<UserCard {...defaultProps} />);
const deleteButton = screen.getByRole('button', { name: /delete/i });
await user.click(deleteButton);
expect(defaultProps.onDelete).toHaveBeenCalledWith(defaultProps.user.id);
});
test('displays fallback avatar when avatar URL is missing', () => {
const userWithoutAvatar = {
...defaultProps.user,
avatar: null
};
render(<UserCard {...defaultProps} user={userWithoutAvatar} />);
const avatar = screen.getByAltText('Jane Smith');
expect(avatar).toHaveAttribute('src', '/default-avatar.png');
});
test('applies correct styling for different roles', () => {
const { rerender } = render(<UserCard {...defaultProps} />);
expect(screen.getByTestId('user-card')).toHaveClass('role-admin');
const regularUser = { ...defaultProps.user, role: 'user' };
rerender(<UserCard {...defaultProps} user={regularUser} />);
expect(screen.getByTestId('user-card')).toHaveClass('role-user');
});
});
Testing component state and interactions
Test components that manage internal state:
import { render, screen, waitFor } from '@/test-utils';
import userEvent from '@testing-library/user-event';
import SearchForm from './SearchForm';
describe('SearchForm', () => {
const defaultProps = {
onSearch: jest.fn(),
placeholder: 'Search products...',
debounceMs: 300
};
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('updates input value when user types', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(<SearchForm {...defaultProps} />);
const searchInput = screen.getByPlaceholderText('Search products...');
await user.type(searchInput, 'laptop');
expect(searchInput).toHaveValue('laptop');
});
test('debounces search calls', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(<SearchForm {...defaultProps} />);
const searchInput = screen.getByPlaceholderText('Search products...');
// Type multiple characters quickly
await user.type(searchInput, 'lap');
// Should not call onSearch immediately
expect(defaultProps.onSearch).not.toHaveBeenCalled();
// Advance timers to trigger debounce
jest.advanceTimersByTime(300);
expect(defaultProps.onSearch).toHaveBeenCalledWith('lap');
expect(defaultProps.onSearch).toHaveBeenCalledTimes(1);
});
test('clears search when clear button is clicked', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(<SearchForm {...defaultProps} />);
const searchInput = screen.getByPlaceholderText('Search products...');
// Type something first
await user.type(searchInput, 'laptop');
jest.advanceTimersByTime(300);
// Clear button should appear
const clearButton = screen.getByRole('button', { name: /clear/i });
await user.click(clearButton);
expect(searchInput).toHaveValue('');
expect(defaultProps.onSearch).toHaveBeenCalledWith('');
});
test('shows loading state during search', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(<SearchForm {...defaultProps} isLoading={true} />);
expect(screen.getByTestId('search-loading')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /search/i })).toBeDisabled();
});
test('handles form submission', async () => {
const user = userEvent.setup();
render(<SearchForm {...defaultProps} />);
const searchInput = screen.getByPlaceholderText('Search products...');
const form = searchInput.closest('form');
await user.type(searchInput, 'laptop');
await user.click(screen.getByRole('button', { name: /search/i }));
expect(defaultProps.onSearch).toHaveBeenCalledWith('laptop');
});
});
Testing conditional rendering
Verify components render correctly under different conditions:
import { render, screen } from '@/test-utils';
import ProductList from './ProductList';
import { createMockProduct } from '@/test-utils';
describe('ProductList', () => {
test('displays loading state', () => {
render(<ProductList loading={true} products={[]} />);
expect(screen.getByTestId('product-list-loading')).toBeInTheDocument();
expect(screen.getByText(/loading products/i)).toBeInTheDocument();
});
test('displays error state', () => {
const error = 'Failed to load products';
render(<ProductList error={error} products={[]} />);
expect(screen.getByTestId('product-list-error')).toBeInTheDocument();
expect(screen.getByText(error)).toBeInTheDocument();
const retryButton = screen.getByRole('button', { name: /retry/i });
expect(retryButton).toBeInTheDocument();
});
test('displays empty state when no products', () => {
render(<ProductList products={[]} />);
expect(screen.getByTestId('product-list-empty')).toBeInTheDocument();
expect(screen.getByText(/no products found/i)).toBeInTheDocument();
});
test('renders product list correctly', () => {
const products = [
createMockProduct({ id: 1, name: 'Laptop' }),
createMockProduct({ id: 2, name: 'Mouse' }),
createMockProduct({ id: 3, name: 'Keyboard' })
];
render(<ProductList products={products} />);
expect(screen.getByText('Laptop')).toBeInTheDocument();
expect(screen.getByText('Mouse')).toBeInTheDocument();
expect(screen.getByText('Keyboard')).toBeInTheDocument();
// Should not show loading, error, or empty states
expect(screen.queryByTestId('product-list-loading')).not.toBeInTheDocument();
expect(screen.queryByTestId('product-list-error')).not.toBeInTheDocument();
expect(screen.queryByTestId('product-list-empty')).not.toBeInTheDocument();
});
test('handles product interactions', async () => {
const user = userEvent.setup();
const onProductClick = jest.fn();
const products = [createMockProduct({ id: 1, name: 'Laptop' })];
render(<ProductList products={products} onProductClick={onProductClick} />);
const productCard = screen.getByText('Laptop').closest('.product-card');
await user.click(productCard);
expect(onProductClick).toHaveBeenCalledWith(products[0]);
});
});
Testing React hooks and custom logic
Building on the patterns from our React hooks guide, let's explore comprehensive hook testing strategies.
Testing custom hooks in isolation
Use renderHook
to test custom hooks independently:
import { renderHook, act, waitFor } from '@testing-library/react';
import { useApi } from './useApi';
// Mock fetch globally
global.fetch = jest.fn();
describe('useApi', () => {
beforeEach(() => {
fetch.mockClear();
});
test('returns initial loading state', () => {
fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: 'test' })
});
const { result } = renderHook(() => useApi('/api/test'));
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe(null);
expect(result.current.error).toBe(null);
});
test('fetches data successfully', async () => {
const mockData = { id: 1, name: 'Test Item' };
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockData
});
const { result } = renderHook(() => useApi('/api/test'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBe(null);
expect(fetch).toHaveBeenCalledWith('/api/test');
});
test('handles fetch errors', async () => {
const errorMessage = 'Network error';
fetch.mockRejectedValueOnce(new Error(errorMessage));
const { result } = renderHook(() => useApi('/api/test'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBe(null);
expect(result.current.error).toBe(errorMessage);
});
test('refetches data when refetch is called', async () => {
const mockData = { id: 1, name: 'Test Item' };
fetch.mockResolvedValue({
ok: true,
json: async () => mockData
});
const { result } = renderHook(() => useApi('/api/test'));
// Wait for initial fetch
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Clear previous calls
fetch.mockClear();
// Trigger refetch
act(() => {
result.current.refetch();
});
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(fetch).toHaveBeenCalledWith('/api/test');
});
test('cancels request when component unmounts', async () => {
fetch.mockImplementationOnce(() =>
new Promise(resolve => setTimeout(resolve, 1000))
);
const { result, unmount } = renderHook(() => useApi('/api/test'));
expect(result.current.loading).toBe(true);
// Unmount before request completes
unmount();
// Wait a bit to ensure no state updates occur
await new Promise(resolve => setTimeout(resolve, 100));
// Should not throw any warnings about state updates on unmounted component
});
});
Testing hooks with dependencies
Test hooks that depend on props or external values:
import { renderHook } from '@testing-library/react';
import { useDebounce } from './useDebounce';
describe('useDebounce', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('returns initial value immediately', () => {
const { result } = renderHook(() => useDebounce('initial', 500));
expect(result.current).toBe('initial');
});
test('debounces value changes', () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'initial', delay: 500 } }
);
expect(result.current).toBe('initial');
// Change the value
rerender({ value: 'updated', delay: 500 });
// Value should not change immediately
expect(result.current).toBe('initial');
// Advance timers by less than delay
jest.advanceTimersByTime(400);
expect(result.current).toBe('initial');
// Advance timers past delay
jest.advanceTimersByTime(100);
expect(result.current).toBe('updated');
});
test('cancels previous timeout when value changes quickly', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: 'initial' } }
);
// Change value multiple times quickly
rerender({ value: 'first' });
jest.advanceTimersByTime(200);
rerender({ value: 'second' });
jest.advanceTimersByTime(200);
rerender({ value: 'final' });
jest.advanceTimersByTime(500);
// Only the final value should be set
expect(result.current).toBe('final');
});
});
Testing hooks with context
Test hooks that consume React context:
import { renderHook } from '@testing-library/react';
import { AuthProvider, useAuth } from './AuthContext';
import { createMockUser } from '@/test-utils';
const createWrapper = (initialAuth = null) => {
return function Wrapper({ children }) {
return (
<AuthProvider initialAuth={initialAuth}>
{children}
</AuthProvider>
);
};
};
describe('useAuth', () => {
test('returns null user when not authenticated', () => {
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper()
});
expect(result.current.user).toBe(null);
expect(result.current.isAuthenticated).toBe(false);
});
test('returns user when authenticated', () => {
const mockUser = createMockUser();
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper(mockUser)
});
expect(result.current.user).toEqual(mockUser);
expect(result.current.isAuthenticated).toBe(true);
});
test('login updates authentication state', async () => {
const mockUser = createMockUser();
// Mock the login API
global.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: async () => mockUser
});
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper()
});
expect(result.current.isAuthenticated).toBe(false);
await act(async () => {
await result.current.login('test@example.com', 'password');
});
expect(result.current.user).toEqual(mockUser);
expect(result.current.isAuthenticated).toBe(true);
});
test('logout clears authentication state', async () => {
const mockUser = createMockUser();
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper(mockUser)
});
expect(result.current.isAuthenticated).toBe(true);
act(() => {
result.current.logout();
});
expect(result.current.user).toBe(null);
expect(result.current.isAuthenticated).toBe(false);
});
});
Integration testing and component interactions
Integration tests verify that multiple components work together correctly, testing the interfaces between components.
Testing component composition
Test how parent and child components interact:
import { render, screen, waitFor } from '@/test-utils';
import userEvent from '@testing-library/user-event';
import ProductPage from './ProductPage';
import { createMockProduct } from '@/test-utils';
// Mock the API module
jest.mock('@/services/api', () => ({
getProduct: jest.fn(),
addToCart: jest.fn(),
getRelatedProducts: jest.fn()
}));
import * as api from '@/services/api';
describe('ProductPage Integration', () => {
const mockProduct = createMockProduct({
id: 1,
name: 'Gaming Laptop',
price: 1299.99,
description: 'High-performance gaming laptop',
images: ['laptop1.jpg', 'laptop2.jpg'],
specifications: {
processor: 'Intel i7',
memory: '16GB RAM',
storage: '512GB SSD'
}
});
const relatedProducts = [
createMockProduct({ id: 2, name: 'Gaming Mouse' }),
createMockProduct({ id: 3, name: 'Gaming Keyboard' })
];
beforeEach(() => {
jest.clearAllMocks();
api.getProduct.mockResolvedValue(mockProduct);
api.getRelatedProducts.mockResolvedValue(relatedProducts);
api.addToCart.mockResolvedValue({ success: true });
});
test('loads and displays product information', async () => {
render(<ProductPage productId="1" />);
// Should show loading initially
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for product to load
await waitFor(() => {
expect(screen.getByText('Gaming Laptop')).toBeInTheDocument();
});
expect(screen.getByText('$1,299.99')).toBeInTheDocument();
expect(screen.getByText('High-performance gaming laptop')).toBeInTheDocument();
// Check specifications are displayed
expect(screen.getByText('Intel i7')).toBeInTheDocument();
expect(screen.getByText('16GB RAM')).toBeInTheDocument();
expect(screen.getByText('512GB SSD')).toBeInTheDocument();
});
test('displays product images with navigation', async () => {
const user = userEvent.setup();
render(<ProductPage productId="1" />);
await waitFor(() => {
expect(screen.getByText('Gaming Laptop')).toBeInTheDocument();
});
// Should display first image by default
const mainImage = screen.getByAltText('Gaming Laptop');
expect(mainImage).toHaveAttribute('src', expect.stringContaining('laptop1.jpg'));
// Should have image navigation
const nextButton = screen.getByRole('button', { name: /next image/i });
await user.click(nextButton);
// Should show second image
expect(mainImage).toHaveAttribute('src', expect.stringContaining('laptop2.jpg'));
});
test('adds product to cart', async () => {
const user = userEvent.setup();
render(<ProductPage productId="1" />);
await waitFor(() => {
expect(screen.getByText('Gaming Laptop')).toBeInTheDocument();
});
const addToCartButton = screen.getByRole('button', { name: /add to cart/i });
await user.click(addToCartButton);
expect(api.addToCart).toHaveBeenCalledWith({
productId: 1,
quantity: 1
});
// Should show success message
await waitFor(() => {
expect(screen.getByText(/added to cart/i)).toBeInTheDocument();
});
});
test('displays related products', async () => {
render(<ProductPage productId="1" />);
await waitFor(() => {
expect(screen.getByText('Gaming Laptop')).toBeInTheDocument();
});
// Should load and display related products
await waitFor(() => {
expect(screen.getByText('Related Products')).toBeInTheDocument();
});
expect(screen.getByText('Gaming Mouse')).toBeInTheDocument();
expect(screen.getByText('Gaming Keyboard')).toBeInTheDocument();
expect(api.getRelatedProducts).toHaveBeenCalledWith(1);
});
test('handles quantity selection', async () => {
const user = userEvent.setup();
render(<ProductPage productId="1" />);
await waitFor(() => {
expect(screen.getByText('Gaming Laptop')).toBeInTheDocument();
});
// Change quantity
const quantitySelect = screen.getByLabelText(/quantity/i);
await user.selectOptions(quantitySelect, '3');
// Add to cart with selected quantity
const addToCartButton = screen.getByRole('button', { name: /add to cart/i });
await user.click(addToCartButton);
expect(api.addToCart).toHaveBeenCalledWith({
productId: 1,
quantity: 3
});
});
test('handles API errors gracefully', async () => {
api.getProduct.mockRejectedValueOnce(new Error('Product not found'));
render(<ProductPage productId="999" />);
await waitFor(() => {
expect(screen.getByText(/error loading product/i)).toBeInTheDocument();
});
expect(screen.getByText(/product not found/i)).toBeInTheDocument();
});
});
Testing form workflows
Test complete form interactions including validation and submission:
import { render, screen, waitFor } from '@/test-utils';
import userEvent from '@testing-library/user-event';
import ContactForm from './ContactForm';
// Mock form submission
const mockSubmit = jest.fn();
describe('ContactForm Workflow', () => {
beforeEach(() => {
mockSubmit.mockClear();
});
test('completes full form submission workflow', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={mockSubmit} />);
// Fill out form fields
await user.type(
screen.getByLabelText(/name/i),
'John Doe'
);
await user.type(
screen.getByLabelText(/email/i),
'john@example.com'
);
await user.selectOptions(
screen.getByLabelText(/subject/i),
'general'
);
await user.type(
screen.getByLabelText(/message/i),
'This is a test message that is long enough to pass validation.'
);
// Submit form
const submitButton = screen.getByRole('button', { name: /send message/i });
await user.click(submitButton);
// Should show loading state
expect(screen.getByText(/sending/i)).toBeInTheDocument();
expect(submitButton).toBeDisabled();
// Wait for submission to complete
await waitFor(() => {
expect(mockSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: 'john@example.com',
subject: 'general',
message: 'This is a test message that is long enough to pass validation.'
});
});
// Should show success message
await waitFor(() => {
expect(screen.getByText(/message sent successfully/i)).toBeInTheDocument();
});
// Form should be reset
expect(screen.getByLabelText(/name/i)).toHaveValue('');
expect(screen.getByLabelText(/email/i)).toHaveValue('');
});
test('validates required fields', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={mockSubmit} />);
// Try to submit empty form
const submitButton = screen.getByRole('button', { name: /send message/i });
await user.click(submitButton);
// Should show validation errors
await waitFor(() => {
expect(screen.getByText(/name is required/i)).toBeInTheDocument();
});
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
expect(screen.getByText(/message is required/i)).toBeInTheDocument();
// Should not submit form
expect(mockSubmit).not.toHaveBeenCalled();
});
test('validates email format', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={mockSubmit} />);
// Enter invalid email
const emailInput = screen.getByLabelText(/email/i);
await user.type(emailInput, 'invalid-email');
await user.tab(); // Trigger blur event
await waitFor(() => {
expect(screen.getByText(/please enter a valid email/i)).toBeInTheDocument();
});
expect(mockSubmit).not.toHaveBeenCalled();
});
test('handles submission errors', async () => {
const user = userEvent.setup();
mockSubmit.mockRejectedValueOnce(new Error('Server error'));
render(<ContactForm onSubmit={mockSubmit} />);
// Fill and submit form
await user.type(screen.getByLabelText(/name/i), 'John Doe');
await user.type(screen.getByLabelText(/email/i), 'john@example.com');
await user.type(screen.getByLabelText(/message/i), 'Test message');
await user.click(screen.getByRole('button', { name: /send message/i }));
// Should show error message
await waitFor(() => {
expect(screen.getByText(/failed to send message/i)).toBeInTheDocument();
});
// Form should remain filled
expect(screen.getByLabelText(/name/i)).toHaveValue('John Doe');
expect(screen.getByLabelText(/email/i)).toHaveValue('john@example.com');
});
});
Testing async operations and side effects
Modern React applications heavily rely on async operations. Testing these properly requires understanding how to handle timing and state changes.
Testing data fetching components
Test components that fetch data on mount or user interaction:
import { render, screen, waitFor } from '@/test-utils';
import userEvent from '@testing-library/user-event';
import UserDashboard from './UserDashboard';
// Mock the data fetching service
jest.mock('@/services/userService', () => ({
getCurrentUser: jest.fn(),
getUserStats: jest.fn(),
getUserNotifications: jest.fn()
}));
import * as userService from '@/services/userService';
describe('UserDashboard Async Operations', () => {
const mockUser = {
id: 1,
name: 'John Doe',
email: 'john@example.com'
};
const mockStats = {
totalOrders: 15,
totalSpent: 1299.99,
favoriteCategory: 'Electronics'
};
const mockNotifications = [
{ id: 1, message: 'Your order has shipped', read: false },
{ id: 2, message: 'New product recommendations', read: true }
];
beforeEach(() => {
jest.clearAllMocks();
});
test('loads user data on mount', async () => {
userService.getCurrentUser.mockResolvedValue(mockUser);
userService.getUserStats.mockResolvedValue(mockStats);
userService.getUserNotifications.mockResolvedValue(mockNotifications);
render(<UserDashboard />);
// Should show loading initially
expect(screen.getByText(/loading dashboard/i)).toBeInTheDocument();
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('Welcome, John Doe')).toBeInTheDocument();
});
// Verify all data is displayed
expect(screen.getByText('15 orders')).toBeInTheDocument();
expect(screen.getByText('$1,299.99 spent')).toBeInTheDocument();
expect(screen.getByText('Electronics')).toBeInTheDocument();
// Verify notifications
expect(screen.getByText('Your order has shipped')).toBeInTheDocument();
expect(screen.getByText('New product recommendations')).toBeInTheDocument();
// Verify API calls
expect(userService.getCurrentUser).toHaveBeenCalledTimes(1);
expect(userService.getUserStats).toHaveBeenCalledTimes(1);
expect(userService.getUserNotifications).toHaveBeenCalledTimes(1);
});
test('handles parallel data loading errors', async () => {
userService.getCurrentUser.mockResolvedValue(mockUser);
userService.getUserStats.mockRejectedValue(new Error('Stats service unavailable'));
userService.getUserNotifications.mockResolvedValue(mockNotifications);
render(<UserDashboard />);
await waitFor(() => {
expect(screen.getByText('Welcome, John Doe')).toBeInTheDocument();
});
// User and notifications should load successfully
expect(screen.getByText('Your order has shipped')).toBeInTheDocument();
// Stats should show error state
expect(screen.getByText(/unable to load stats/i)).toBeInTheDocument();
// Should have retry button for stats
expect(screen.getByRole('button', { name: /retry stats/i })).toBeInTheDocument();
});
test('refreshes data when refresh button is clicked', async () => {
const user = userEvent.setup();
// Initial load
userService.getCurrentUser.mockResolvedValue(mockUser);
userService.getUserStats.mockResolvedValue(mockStats);
userService.getUserNotifications.mockResolvedValue(mockNotifications);
render(<UserDashboard />);
await waitFor(() => {
expect(screen.getByText('Welcome, John Doe')).toBeInTheDocument();
});
// Clear mocks to verify refresh calls
jest.clearAllMocks();
// Updated data for refresh
const updatedStats = { ...mockStats, totalOrders: 16 };
userService.getUserStats.mockResolvedValue(updatedStats);
userService.getUserNotifications.mockResolvedValue([
...mockNotifications,
{ id: 3, message: 'New message', read: false }
]);
// Click refresh button
const refreshButton = screen.getByRole('button', { name: /refresh/i });
await user.click(refreshButton);
// Should show refreshing state
expect(screen.getByText(/refreshing/i)).toBeInTheDocument();
// Wait for refresh to complete
await waitFor(() => {
expect(screen.getByText('16 orders')).toBeInTheDocument();
});
expect(screen.getByText('New message')).toBeInTheDocument();
// Should have called the APIs again (but not getCurrentUser since it doesn't change)
expect(userService.getUserStats).toHaveBeenCalledTimes(1);
expect(userService.getUserNotifications).toHaveBeenCalledTimes(1);
});
test('handles race conditions in rapid refreshes', async () => {
const user = userEvent.setup();
userService.getCurrentUser.mockResolvedValue(mockUser);
userService.getUserStats.mockResolvedValue(mockStats);
userService.getUserNotifications.mockResolvedValue(mockNotifications);
render(<UserDashboard />);
await waitFor(() => {
expect(screen.getByText('Welcome, John Doe')).toBeInTheDocument();
});
// Mock slow API responses
let resolveFirstCall, resolveSecondCall;
userService.getUserStats
.mockReturnValueOnce(new Promise(resolve => { resolveFirstCall = resolve; }))
.mockReturnValueOnce(new Promise(resolve => { resolveSecondCall = resolve; }));
const refreshButton = screen.getByRole('button', { name: /refresh/i });
// Trigger two rapid refreshes
await user.click(refreshButton);
await user.click(refreshButton);
// Resolve second call first (race condition)
resolveSecondCall({ ...mockStats, totalOrders: 20 });
await waitFor(() => {
expect(screen.getByText('20 orders')).toBeInTheDocument();
});
// Resolve first call later - should not override newer data
resolveFirstCall({ ...mockStats, totalOrders: 18 });
// Give time for potential state update
await new Promise(resolve => setTimeout(resolve, 100));
// Should still show the newer data
expect(screen.getByText('20 orders')).toBeInTheDocument();
expect(screen.queryByText('18 orders')).not.toBeInTheDocument();
});
});
Testing components with side effects
Test components that trigger side effects like analytics or notifications:
import { render, screen, waitFor } from '@/test-utils';
import userEvent from '@testing-library/user-event';
import PurchaseButton from './PurchaseButton';
// Mock analytics and notification services
jest.mock('@/services/analytics', () => ({
track: jest.fn()
}));
jest.mock('@/services/notifications', () => ({
show: jest.fn()
}));
import * as analytics from '@/services/analytics';
import * as notifications from '@/services/notifications';
describe('PurchaseButton Side Effects', () => {
const defaultProps = {
productId: 123,
productName: 'Gaming Laptop',
price: 1299.99,
onPurchaseComplete: jest.fn()
};
beforeEach(() => {
jest.clearAllMocks();
// Mock successful purchase API
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ orderId: 'ORD-123', success: true })
});
});
test('tracks analytics events during purchase flow', async () => {
const user = userEvent.setup();
render(<PurchaseButton {...defaultProps} />);
const purchaseButton = screen.getByRole('button', { name: /purchase/i });
// Track button click
await user.click(purchaseButton);
expect(analytics.track).toHaveBeenCalledWith('purchase_initiated', {
productId: 123,
productName: 'Gaming Laptop',
price: 1299.99
});
// Wait for purchase to complete
await waitFor(() => {
expect(screen.getByText(/purchase successful/i)).toBeInTheDocument();
});
// Track successful purchase
expect(analytics.track).toHaveBeenCalledWith('purchase_completed', {
productId: 123,
orderId: 'ORD-123',
price: 1299.99
});
expect(analytics.track).toHaveBeenCalledTimes(2);
});
test('shows notification on successful purchase', async () => {
const user = userEvent.setup();
render(<PurchaseButton {...defaultProps} />);
await user.click(screen.getByRole('button', { name: /purchase/i }));
await waitFor(() => {
expect(notifications.show).toHaveBeenCalledWith({
type: 'success',
title: 'Purchase Successful!',
message: 'Your order for Gaming Laptop has been placed.'
});
});
});
test('tracks errors and shows error notifications', async () => {
const user = userEvent.setup();
// Mock API failure
global.fetch = jest.fn().mockRejectedValue(new Error('Payment failed'));
render(<PurchaseButton {...defaultProps} />);
await user.click(screen.getByRole('button', { name: /purchase/i }));
await waitFor(() => {
expect(analytics.track).toHaveBeenCalledWith('purchase_failed', {
productId: 123,
error: 'Payment failed'
});
});
expect(notifications.show).toHaveBeenCalledWith({
type: 'error',
title: 'Purchase Failed',
message: 'Payment failed. Please try again.'
});
});
test('calls onPurchaseComplete callback with order data', async () => {
const user = userEvent.setup();
const orderData = { orderId: 'ORD-123', success: true };
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => orderData
});
render(<PurchaseButton {...defaultProps} />);
await user.click(screen.getByRole('button', { name: /purchase/i }));
await waitFor(() => {
expect(defaultProps.onPurchaseComplete).toHaveBeenCalledWith(orderData);
});
});
});
End-to-end testing strategies
E2E tests verify complete user workflows across your entire application stack. These complement our comprehensive component and integration testing.
Setting up Playwright for React applications
Configure Playwright for reliable E2E testing:
// playwright.config.js
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
],
webServer: {
command: 'npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
Testing complete user workflows
Test critical user journeys from start to finish:
// e2e/user-journey.spec.js
import { test, expect } from '@playwright/test';
test.describe('Complete User Journey', () => {
test.beforeEach(async ({ page }) => {
// Set up test data
await page.route('**/api/products', async route => {
await route.fulfill({
json: [
{ id: 1, name: 'Gaming Laptop', price: 1299.99, inStock: true },
{ id: 2, name: 'Wireless Mouse', price: 79.99, inStock: true }
]
});
});
});
test('user can complete purchase flow', async ({ page }) => {
// 1. Navigate to homepage
await page.goto('/');
await expect(page).toHaveTitle(/E-commerce App/);
// 2. Search for product
const searchInput = page.getByPlaceholder('Search products...');
await searchInput.fill('Gaming Laptop');
await searchInput.press('Enter');
// 3. Navigate to product page
await page.getByText('Gaming Laptop').click();
await expect(page.getByText('$1,299.99')).toBeVisible();
// 4. Add to cart
await page.getByRole('button', { name: 'Add to Cart' }).click();
await expect(page.getByText('Added to cart')).toBeVisible();
// 5. View cart
await page.getByRole('link', { name: 'Cart' }).click();
await expect(page.getByText('Gaming Laptop')).toBeVisible();
await expect(page.getByText('$1,299.99')).toBeVisible();
// 6. Proceed to checkout
await page.getByRole('button', { name: 'Checkout' }).click();
// 7. Fill shipping information
await page.getByLabel('Full Name').fill('John Doe');
await page.getByLabel('Email').fill('john@example.com');
await page.getByLabel('Address').fill('123 Main St');
await page.getByLabel('City').fill('New York');
await page.getByLabel('ZIP Code').fill('10001');
// 8. Fill payment information
await page.getByLabel('Card Number').fill('4111111111111111');
await page.getByLabel('Expiry Date').fill('12/25');
await page.getByLabel('CVV').fill('123');
// 9. Complete purchase
await page.getByRole('button', { name: 'Complete Purchase' }).click();
// 10. Verify success
await expect(page.getByText('Order Confirmed')).toBeVisible();
await expect(page.getByText(/Order #ORD-/)).toBeVisible();
});
test('user registration and login flow', async ({ page }) => {
// 1. Navigate to registration
await page.goto('/');
await page.getByRole('link', { name: 'Sign Up' }).click();
// 2. Fill registration form
await page.getByLabel('Full Name').fill('Jane Smith');
await page.getByLabel('Email').fill('jane@example.com');
await page.getByLabel('Password').fill('SecurePassword123!');
await page.getByLabel('Confirm Password').fill('SecurePassword123!');
// 3. Submit registration
await page.getByRole('button', { name: 'Create Account' }).click();
// 4. Verify welcome message
await expect(page.getByText('Welcome, Jane Smith!')).toBeVisible();
// 5. Logout
await page.getByRole('button', { name: 'Account' }).click();
await page.getByRole('button', { name: 'Logout' }).click();
// 6. Login with new account
await page.getByRole('link', { name: 'Login' }).click();
await page.getByLabel('Email').fill('jane@example.com');
await page.getByLabel('Password').fill('SecurePassword123!');
await page.getByRole('button', { name: 'Login' }).click();
// 7. Verify successful login
await expect(page.getByText('Welcome back, Jane!')).toBeVisible();
});
test('responsive design on mobile', async ({ page }) => {
// Set mobile viewport
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
// 1. Mobile navigation menu
const menuButton = page.getByRole('button', { name: 'Menu' });
await expect(menuButton).toBeVisible();
await menuButton.click();
const mobileMenu = page.getByTestId('mobile-menu');
await expect(mobileMenu).toBeVisible();
// 2. Navigate to products
await page.getByRole('link', { name: 'Products' }).click();
await expect(mobileMenu).not.toBeVisible();
// 3. Product grid layout on mobile
const productGrid = page.getByTestId('product-grid');
await expect(productGrid).toHaveClass(/mobile-grid/);
// 4. Touch interactions
const firstProduct = page.getByTestId('product-card').first();
await firstProduct.tap();
// 5. Mobile-specific UI elements
await expect(page.getByTestId('mobile-quantity-selector')).toBeVisible();
await expect(page.getByTestId('mobile-add-to-cart')).toBeVisible();
});
});
Testing performance in E2E scenarios
Monitor performance metrics during E2E tests:
// e2e/performance.spec.js
import { test, expect } from '@playwright/test';
test.describe('Performance Tests', () => {
test('homepage meets performance budgets', async ({ page }) => {
// Start monitoring performance
await page.goto('/', { waitUntil: 'networkidle' });
// Measure Core Web Vitals
const metrics = await page.evaluate(() => {
return new Promise(resolve => {
const observer = new PerformanceObserver(list => {
const entries = list.getEntries();
const vitals = {};
entries.forEach(entry => {
if (entry.entryType === 'largest-contentful-paint') {
vitals.LCP = entry.startTime;
}
if (entry.entryType === 'layout-shift' && !entry.hadRecentInput) {
vitals.CLS = (vitals.CLS || 0) + entry.value;
}
});
resolve(vitals);
});
observer.observe({ entryTypes: ['largest-contentful-paint', 'layout-shift'] });
// Fallback timeout
setTimeout(() => resolve({}), 5000);
});
});
// Assert performance budgets
if (metrics.LCP) {
expect(metrics.LCP).toBeLessThan(2500); // 2.5s budget
}
if (metrics.CLS) {
expect(metrics.CLS).toBeLessThan(0.1); // 0.1 budget
}
// Measure bundle size
const responses = [];
page.on('response', response => {
if (response.url().includes('.js') && response.status() === 200) {
responses.push(response);
}
});
await page.reload();
let totalBundleSize = 0;
for (const response of responses) {
const buffer = await response.body();
totalBundleSize += buffer.length;
}
// Assert bundle size budget (250KB)
expect(totalBundleSize).toBeLessThan(250 * 1024);
});
test('search performance under load', async ({ page }) => {
await page.goto('/');
const searchInput = page.getByPlaceholder('Search products...');
// Measure search response time
const startTime = Date.now();
await searchInput.fill('gaming');
await page.waitForResponse('**/api/search**');
const endTime = Date.now();
const searchTime = endTime - startTime;
// Search should respond within 1 second
expect(searchTime).toBeLessThan(1000);
// Results should be visible quickly
await expect(page.getByTestId('search-results')).toBeVisible({ timeout: 500 });
});
});
Advanced testing patterns and optimization
As applications grow more complex, these advanced patterns help maintain test quality and execution speed.
Testing with TypeScript integration
Leverage TypeScript for better test reliability, building on our TypeScript React patterns:
// src/test-utils.ts
import { render as rtlRender, RenderOptions } from '@testing-library/react';
import { ReactElement } from 'react';
import { QueryClient } from '@tanstack/react-query';
// Type-safe test data factories
interface TestUser {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'moderator';
}
export const createTestUser = (overrides: Partial<TestUser> = {}): TestUser => ({
id: Math.floor(Math.random() * 1000),
name: 'Test User',
email: 'test@example.com',
role: 'user',
...overrides
});
// Type-safe render options
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
preloadedState?: {
auth?: TestUser | null;
cart?: any[];
};
queryClient?: QueryClient;
}
// Type-safe custom render function
export function render(
ui: ReactElement,
options: CustomRenderOptions = {}
): ReturnType<typeof rtlRender> {
const { preloadedState = {}, queryClient, ...renderOptions } = options;
// Implementation with proper TypeScript typing...
return rtlRender(ui, renderOptions);
}
// Type-safe mock generators
export function createMockApiResponse<T>(
data: T,
options: { delay?: number; error?: boolean } = {}
): Promise<{ ok: boolean; json: () => Promise<T> }> {
const { delay = 0, error = false } = options;
return new Promise((resolve, reject) => {
setTimeout(() => {
if (error) {
reject(new Error('Mock API error'));
} else {
resolve({
ok: true,
json: async () => data
});
}
}, delay);
});
}
Optimizing test performance
Implement strategies to keep test suites fast and reliable:
// jest.config.js - Performance optimizations
module.exports = {
// Run tests in parallel
maxWorkers: '50%',
// Cache test results
cache: true,
cacheDirectory: '<rootDir>/.jest-cache',
// Optimize for faster re-runs
watchman: true,
// Selective test execution
testPathIgnorePatterns: [
'/node_modules/',
'/build/',
'/coverage/'
],
// Setup test timeouts
testTimeout: 10000,
// Optimize module resolution
modulePathIgnorePatterns: ['<rootDir>/build/'],
// Setup files for performance
setupFilesAfterEnv: [
'<rootDir>/src/setupTests.js',
'<rootDir>/src/setupPerformanceTests.js'
]
};
// src/setupPerformanceTests.js
// Optimize React Testing Library
import { configure } from '@testing-library/react';
configure({
// Reduce query timeouts for faster failures
asyncUtilTimeout: 2000,
// Optimize DOM cleanup
asyncWrapper: async (cb) => {
const result = await cb();
// Force garbage collection if available
if (global.gc) {
global.gc();
}
return result;
}
});
// Mock expensive operations
jest.mock('@/services/analytics', () => ({
track: jest.fn()
}));
// Reduce timer precision for faster tests
jest.mock('lodash/debounce', () => {
return jest.fn(fn => {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), 0);
};
});
});
// Optimize image loading in tests
Object.defineProperty(HTMLImageElement.prototype, 'loading', {
get() { return 'eager'; },
set() { /* noop */ }
});
Test data management and factories
Create maintainable test data with factory patterns:
// src/__tests__/factories/index.js
import { faker } from '@faker-js/faker';
class TestDataFactory {
constructor() {
this.sequences = new Map();
}
sequence(name, fn) {
if (!this.sequences.has(name)) {
this.sequences.set(name, 0);
}
const current = this.sequences.get(name);
this.sequences.set(name, current + 1);
return fn(current);
}
user(overrides = {}) {
return {
id: this.sequence('user', n => n + 1),
name: faker.person.fullName(),
email: faker.internet.email(),
role: 'user',
createdAt: faker.date.past().toISOString(),
avatar: faker.image.avatar(),
isActive: true,
...overrides
};
}
product(overrides = {}) {
return {
id: this.sequence('product', n => n + 1),
name: faker.commerce.productName(),
description: faker.commerce.productDescription(),
price: parseFloat(faker.commerce.price()),
category: faker.commerce.department(),
inStock: faker.datatype.boolean(),
images: [faker.image.url(), faker.image.url()],
rating: faker.number.float({ min: 1, max: 5, precision: 0.1 }),
...overrides
};
}
order(overrides = {}) {
return {
id: this.sequence('order', n => `ORD-${String(n).padStart(6, '0')}`),
userId: this.sequence('user', n => n + 1),
items: [this.product(), this.product()],
total: parseFloat(faker.commerce.price({ min: 100, max: 1000 })),
status: faker.helpers.arrayElement(['pending', 'confirmed', 'shipped', 'delivered']),
createdAt: faker.date.recent().toISOString(),
...overrides
};
}
// Batch creation methods
userList(count = 5, overrides = {}) {
return Array.from({ length: count }, () => this.user(overrides));
}
productList(count = 10, overrides = {}) {
return Array.from({ length: count }, () => this.product(overrides));
}
// Relationship helpers
userWithOrders(orderCount = 3) {
const user = this.user();
const orders = Array.from({ length: orderCount }, () =>
this.order({ userId: user.id })
);
return { user, orders };
}
}
export const factory = new TestDataFactory();
// Usage in tests
describe('UserDashboard', () => {
test('displays user orders', () => {
const { user, orders } = factory.userWithOrders(5);
render(<UserDashboard user={user} orders={orders} />);
expect(screen.getByText(user.name)).toBeInTheDocument();
orders.forEach(order => {
expect(screen.getByText(order.id)).toBeInTheDocument();
});
});
});
When building applications that require comprehensive testing strategies to ensure reliability and user satisfaction, our team specializes in creating robust testing frameworks that provide confidence in deployments while maintaining development velocity.
Testing best practices and continuous integration
Implementing systematic testing practices ensures your React applications remain reliable as they evolve.
Test organization and structure
Structure tests for maintainability and clarity:
// Example test file structure
describe('ProductCard Component', () => {
// Group related functionality
describe('rendering', () => {
test('displays product information correctly');
test('shows sale badge when product is on sale');
test('handles missing product images gracefully');
});
describe('user interactions', () => {
test('calls onAddToCart when add button is clicked');
test('navigates to product page when card is clicked');
test('updates quantity when quantity selector changes');
});
describe('accessibility', () => {
test('has proper ARIA labels');
test('supports keyboard navigation');
test('announces status changes to screen readers');
});
describe('error states', () => {
test('displays error message when product fails to load');
test('disables interactions when product is out of stock');
});
});
CI/CD integration and automated testing
Configure testing pipelines for reliable deployments:
# .github/workflows/test.yml
name: Test Suite
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:unit -- --coverage --watchAll=false
- name: Upload coverage reports
uses: codecov/codecov-action@v3
integration-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run integration tests
run: npm run test:integration
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Build application
run: npm run build
- name: Run E2E tests
run: npm run test:e2e
- name: Upload test results
uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
visual-regression:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run visual tests
run: npm run test:visual
env:
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
Quality gates and deployment confidence
Establish quality gates that ensure reliable deployments:
// scripts/quality-gate.js
const fs = require('fs');
const path = require('path');
class QualityGate {
constructor() {
this.criteria = {
coverage: {
statements: 80,
branches: 70,
functions: 80,
lines: 80
},
performance: {
bundleSize: 250 * 1024, // 250KB
testSpeed: 60000, // 60 seconds max
},
accessibility: {
minScore: 95
}
};
}
async checkCoverage() {
const coveragePath = path.join(__dirname, '../coverage/coverage-summary.json');
if (!fs.existsSync(coveragePath)) {
throw new Error('Coverage report not found. Run tests with --coverage flag.');
}
const coverage = JSON.parse(fs.readFileSync(coveragePath, 'utf8'));
const total = coverage.total;
const failures = [];
Object.entries(this.criteria.coverage).forEach(([metric, threshold]) => {
const actual = total[metric].pct;
if (actual < threshold) {
failures.push(`${metric}: ${actual}% (required: ${threshold}%)`);
}
});
if (failures.length > 0) {
throw new Error(`Coverage requirements not met:\n${failures.join('\n')}`);
}
console.log('✅ Coverage requirements met');
return true;
}
async checkBundleSize() {
const buildPath = path.join(__dirname, '../build/static/js');
if (!fs.existsSync(buildPath)) {
throw new Error('Build directory not found. Run npm run build first.');
}
const jsFiles = fs.readdirSync(buildPath)
.filter(file => file.endsWith('.js') && !file.includes('.map'));
let totalSize = 0;
jsFiles.forEach(file => {
const stats = fs.statSync(path.join(buildPath, file));
totalSize += stats.size;
});
if (totalSize > this.criteria.performance.bundleSize) {
throw new Error(
`Bundle size ${Math.round(totalSize / 1024)}KB exceeds limit of ${Math.round(this.criteria.performance.bundleSize / 1024)}KB`
);
}
console.log(`✅ Bundle size ${Math.round(totalSize / 1024)}KB within limits`);
return true;
}
async checkAccessibility() {
// This would integrate with axe-core or similar tools
console.log('✅ Accessibility requirements met');
return true;
}
async run() {
console.log('Running quality gate checks...\n');
try {
await this.checkCoverage();
await this.checkBundleSize();
await this.checkAccessibility();
console.log('\n🎉 All quality gate checks passed!');
process.exit(0);
} catch (error) {
console.error(`\n❌ Quality gate failed: ${error.message}`);
process.exit(1);
}
}
}
if (require.main === module) {
new QualityGate().run();
}
module.exports = QualityGate;
Building confidence through comprehensive testing
React testing is fundamentally about building confidence in your application's behavior under real-world conditions. The strategies we've explored - from unit testing individual components to end-to-end workflow validation - create a safety net that enables rapid development without sacrificing reliability.
The key insight is that effective testing isn't about achieving 100% coverage; it's about testing the right things in the right way. By focusing on user behavior rather than implementation details, using proper async testing patterns, and maintaining fast, reliable test suites, you create documentation that lives with your code and catches regressions before they reach production.
The testing patterns we've covered integrate seamlessly with modern React development practices. From the memoization techniques in our performance optimization guide to the type-safe patterns from our TypeScript integration guide, proper testing validates that these optimizations work correctly across different scenarios and user interactions.
Whether you're implementing a comprehensive testing strategy for a new React application or improving the reliability of an existing codebase, the systematic approach we've outlined provides the foundation for confident deployments and sustainable development practices.
Ready to implement a robust testing strategy that ensures your React applications perform reliably in production? Start your project brief to discuss how we can help you build applications with the testing infrastructure and confidence needed for successful long-term growth.