Complete Guide to JavaScript Testing: Unit, Integration, and E2E
Rachel Green

Master JavaScript testing with Jest, React Testing Library, and Playwright. Learn best practices for unit tests, integration tests, and end-to-end testing.
Testing is a critical part of modern JavaScript development, ensuring code reliability and maintainability. This comprehensive guide covers everything from unit testing to end-to-end testing, with practical examples and best practices.
Testing Pyramid and Strategy
The testing pyramid helps us understand the different levels of testing and their appropriate balance.
"text-[#66d9ef]">class="text-[#75715e] italic">/*
Testing Pyramid:
/ E2E Tests (Few)
/ - Test complete user workflows
/____ - Slow, expensive, brittle
/
/________ Integration Tests (Some)
- Test component interactions
- Medium speed and cost
Unit Tests (Many)
- Test individual functions/components
- Fast, cheap, reliable
*/
"text-[#66d9ef]">class="text-[#75715e] italic">// Example test distribution "text-[#66d9ef]">for a typical project:
"text-[#66d9ef]">class="text-[#75715e] italic">// - 70% Unit tests
"text-[#66d9ef]">class="text-[#75715e] italic">// - 20% Integration tests
"text-[#66d9ef]">class="text-[#75715e] italic">// - 10% E2E tests
Unit Testing with Jest
Jest is the most popular JavaScript testing framework, providing everything needed for unit testing.
Basic Jest Setup and Configuration
"text-[#66d9ef]">class="text-[#75715e] italic">// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['/src/setupTests.js' ],
moduleNameMapping: {
'\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\.(jpg|jpeg|png|gif|svg)$': '/__mocks__/fileMock.js' ,
},
collectCoverageFrom: [
'src"text-[#66d9ef]">class="text-[#75715e] italic">/**/*.{js,jsx,ts,tsx}',
'!src/index.js',
'!src/reportWebVitals.js',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
testMatch: [
'/src"text-[#66d9ef]">class ="text-[#75715e] italic">/**/__tests__"text-[#66d9ef]">class="text-[#75715e] italic">/**/*.{js,jsx,ts,tsx}',
'/src"text-[#66d9ef]">class ="text-[#75715e] italic">/**/*.{test,spec}.{js,jsx,ts,tsx}',
],
};
"text-[#66d9ef]">class="text-[#75715e] italic">// setupTests.js
"text-[#66d9ef]">import '@testing-library/jest-dom';
"text-[#66d9ef]">class="text-[#75715e] italic">// Mock global objects
global.fetch = jest.fn();
global.IntersectionObserver = jest.fn(() => ({
observe: jest.fn(),
disconnect: jest.fn(),
}));
"text-[#66d9ef]">class="text-[#75715e] italic">// Custom matchers
expect.extend({
toBeWithinRange(received, floor, ceiling) {
"text-[#66d9ef]">const pass = received >= floor && received <= ceiling;
"text-[#66d9ef]">if (pass) {
"text-[#66d9ef]">return {
message: () => `expected ${received} not to be within range ${floor} - ${ceiling}`,
pass: "text-[#ae81ff]">true,
};
} "text-[#66d9ef]">else {
"text-[#66d9ef]">return {
message: () => `expected ${received} to be within range ${floor} - ${ceiling}`,
pass: "text-[#ae81ff]">false,
};
}
},
});
Testing Pure Functions
"text-[#66d9ef]">class="text-[#75715e] italic">// utils.js
"text-[#66d9ef]">export "text-[#66d9ef]">function formatCurrency(amount, currency = 'USD') {
"text-[#66d9ef]">if (typeof amount !== 'number' || isNaN(amount)) {
"text-[#66d9ef]">throw "text-[#66d9ef]">new Error('Amount must be a valid number');
}
"text-[#66d9ef]">return "text-[#66d9ef]">new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
}
"text-[#66d9ef]">export "text-[#66d9ef]">function debounce(func, wait) {
"text-[#66d9ef]">let timeout;
"text-[#66d9ef]">return "text-[#66d9ef]">function executedFunction(...args) {
"text-[#66d9ef]">const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
"text-[#66d9ef]">export "text-[#66d9ef]">function validateEmail(email) {
"text-[#66d9ef]">const emailRegex = /^[^s@]+@[^s@]+.[^s@]+$/;
"text-[#66d9ef]">return emailRegex.test(email);
}
"text-[#66d9ef]">class="text-[#75715e] italic">// utils.test.js
"text-[#66d9ef]">import { formatCurrency, debounce, validateEmail } "text-[#66d9ef]">from './utils';
describe('formatCurrency', () => {
test('formats positive numbers correctly', () => {
expect(formatCurrency(1234.56)).toBe('$1,234.56');
expect(formatCurrency(0)).toBe('$0.00');
});
test('formats negative numbers correctly', () => {
expect(formatCurrency(-1234.56)).toBe('-$1,234.56');
});
test('handles different currencies', () => {
expect(formatCurrency(1234.56, 'EUR')).toBe('€1,234.56');
});
test('throws error "text-[#66d9ef]">for invalid input', () => {
expect(() => formatCurrency('invalid')).toThrow('Amount must be a valid number');
expect(() => formatCurrency(NaN)).toThrow('Amount must be a valid number');
});
});
describe('debounce', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('delays "text-[#66d9ef]">function execution', () => {
"text-[#66d9ef]">const mockFn = jest.fn();
"text-[#66d9ef]">const debouncedFn = debounce(mockFn, 100);
debouncedFn('test');
expect(mockFn).not.toHaveBeenCalled();
jest.advanceTimersByTime(100);
expect(mockFn).toHaveBeenCalledWith('test');
});
test('cancels previous calls', () => {
"text-[#66d9ef]">const mockFn = jest.fn();
"text-[#66d9ef]">const debouncedFn = debounce(mockFn, 100);
debouncedFn('first');
debouncedFn('second');
jest.advanceTimersByTime(100);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith('second');
});
});
describe('validateEmail', () => {
test('validates correct email addresses', () => {
expect(validateEmail('test@example.com')).toBe("text-[#ae81ff]">true);
expect(validateEmail('user.name@domain.co.uk')).toBe("text-[#ae81ff]">true);
});
test('rejects invalid email addresses', () => {
expect(validateEmail('invalid-email')).toBe("text-[#ae81ff]">false);
expect(validateEmail('@domain.com')).toBe("text-[#ae81ff]">false);
expect(validateEmail('user@')).toBe("text-[#ae81ff]">false);
});
});
Testing Async Code
"text-[#66d9ef]">class="text-[#75715e] italic">// api.js
"text-[#66d9ef]">export "text-[#66d9ef]">async "text-[#66d9ef]">function fetchUser(id) {
"text-[#66d9ef]">const response = "text-[#66d9ef]">await fetch(`/api/users/${id}`);
"text-[#66d9ef]">if (!response.ok) {
"text-[#66d9ef]">throw "text-[#66d9ef]">new Error(`HTTP error! status: ${response.status}`);
}
"text-[#66d9ef]">return response.json();
}
"text-[#66d9ef]">export "text-[#66d9ef]">function fetchUserWithCallback(id, callback) {
fetch(`/api/users/${id}`)
.then(response => response.json())
.then(data => callback("text-[#ae81ff]">null, data))
."text-[#66d9ef]">catch(error => callback(error, "text-[#ae81ff]">null));
}
"text-[#66d9ef]">class="text-[#75715e] italic">// api.test.js
"text-[#66d9ef]">import { fetchUser, fetchUserWithCallback } "text-[#66d9ef]">from './api';
"text-[#66d9ef]">class="text-[#75715e] italic">// Mock fetch globally
global.fetch = jest.fn();
describe('fetchUser', () => {
beforeEach(() => {
fetch.mockClear();
});
test('fetches user successfully', "text-[#66d9ef]">async () => {
"text-[#66d9ef]">const mockUser = { id: 1, name: 'John Doe' };
fetch.mockResolvedValueOnce({
ok: "text-[#ae81ff]">true,
json: "text-[#66d9ef]">async () => mockUser,
});
"text-[#66d9ef]">const user = "text-[#66d9ef]">await fetchUser(1);
expect(fetch).toHaveBeenCalledWith('/api/users/1');
expect(user).toEqual(mockUser);
});
test('throws error on HTTP error', "text-[#66d9ef]">async () => {
fetch.mockResolvedValueOnce({
ok: "text-[#ae81ff]">false,
status: 404,
});
"text-[#66d9ef]">await expect(fetchUser(1)).rejects.toThrow('HTTP error! status: 404');
});
test('throws error on network failure', "text-[#66d9ef]">async () => {
fetch.mockRejectedValueOnce("text-[#66d9ef]">new Error('Network error'));
"text-[#66d9ef]">await expect(fetchUser(1)).rejects.toThrow('Network error');
});
});
describe('fetchUserWithCallback', () => {
test('calls callback with data on success', (done) => {
"text-[#66d9ef]">const mockUser = { id: 1, name: 'John Doe' };
fetch.mockResolvedValueOnce({
ok: "text-[#ae81ff]">true,
json: "text-[#66d9ef]">async () => mockUser,
});
fetchUserWithCallback(1, (error, data) => {
expect(error).toBeNull();
expect(data).toEqual(mockUser);
done();
});
});
test('calls callback with error on failure', (done) => {
fetch.mockRejectedValueOnce("text-[#66d9ef]">new Error('Network error'));
fetchUserWithCallback(1, (error, data) => {
expect(error).toBeInstanceOf(Error);
expect(data).toBeNull();
done();
});
});
});
React Component Testing
Testing React components requires special considerations for props, state, and user interactions.
"text-[#66d9ef]">class="text-[#75715e] italic">// UserCard.jsx
"text-[#66d9ef]">import React, { useState } "text-[#66d9ef]">from 'react';
"text-[#66d9ef]">export "text-[#66d9ef]">default "text-[#66d9ef]">function UserCard({ user, onEdit, onDelete }) {
"text-[#66d9ef]">const [isExpanded, setIsExpanded] = useState("text-[#ae81ff]">false);
"text-[#66d9ef]">if (!user) {
"text-[#66d9ef]">return "user-card-empty">No user data;
}
"text-[#66d9ef]">return (
"user-card" className="user-card">
"user-header">
{user.name}
"user-email">{user.email}
{isExpanded && (
"user-details" className="user-details">
Phone: {user.phone}
Address: {user.address}
"user-actions">
)}
);
}
"text-[#66d9ef]">class="text-[#75715e] italic">// UserCard.test.jsx
"text-[#66d9ef]">import React "text-[#66d9ef]">from 'react';
"text-[#66d9ef]">import { render, screen, fireEvent } "text-[#66d9ef]">from '@testing-library/react';
"text-[#66d9ef]">import userEvent "text-[#66d9ef]">from '@testing-library/user-event';
"text-[#66d9ef]">import UserCard "text-[#66d9ef]">from './UserCard';
"text-[#66d9ef]">const mockUser = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
phone: '123-456-7890',
address: '123 Main St',
};
describe('UserCard', () => {
test('renders user information', () => {
render(<UserCard user={mockUser} />);
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
test('renders empty state when no user provided', () => {
render(<UserCard user={"text-[#ae81ff]">null} />);
expect(screen.getByTestId('user-card-empty')).toBeInTheDocument();
expect(screen.getByText('No user data')).toBeInTheDocument();
});
test('expands and collapses user details', "text-[#66d9ef]">async () => {
"text-[#66d9ef]">const user = userEvent.setup();
render(<UserCard user={mockUser} />);
"text-[#66d9ef]">class="text-[#75715e] italic">// Initially collapsed
expect(screen.queryByTestId('user-details')).not.toBeInTheDocument();
"text-[#66d9ef]">class="text-[#75715e] italic">// Expand
"text-[#66d9ef]">await user.click(screen.getByTestId('expand-button'));
expect(screen.getByTestId('user-details')).toBeInTheDocument();
expect(screen.getByText('Phone: 123-456-7890')).toBeInTheDocument();
"text-[#66d9ef]">class="text-[#75715e] italic">// Collapse
"text-[#66d9ef]">await user.click(screen.getByTestId('expand-button'));
expect(screen.queryByTestId('user-details')).not.toBeInTheDocument();
});
test('calls onEdit when edit button is clicked', "text-[#66d9ef]">async () => {
"text-[#66d9ef]">const user = userEvent.setup();
"text-[#66d9ef]">const mockOnEdit = jest.fn();
render(<UserCard user={mockUser} onEdit={mockOnEdit} />);
"text-[#66d9ef]">class="text-[#75715e] italic">// Expand to show buttons
"text-[#66d9ef]">await user.click(screen.getByTestId('expand-button'));
"text-[#66d9ef]">await user.click(screen.getByTestId('edit-button'));
expect(mockOnEdit).toHaveBeenCalledWith(1);
});
test('calls onDelete when delete button is clicked', "text-[#66d9ef]">async () => {
"text-[#66d9ef]">const user = userEvent.setup();
"text-[#66d9ef]">const mockOnDelete = jest.fn();
render(<UserCard user={mockUser} onDelete={mockOnDelete} />);
"text-[#66d9ef]">class="text-[#75715e] italic">// Expand to show buttons
"text-[#66d9ef]">await user.click(screen.getByTestId('expand-button'));
"text-[#66d9ef]">await user.click(screen.getByTestId('delete-button'));
expect(mockOnDelete).toHaveBeenCalledWith(1);
});
test('has correct accessibility attributes', () => {
render(<UserCard user={mockUser} />);
"text-[#66d9ef]">const expandButton = screen.getByTestId('expand-button');
expect(expandButton).toHaveAttribute('aria-expanded', '"text-[#ae81ff]">false');
});
});
Testing Custom Hooks
"text-[#66d9ef]">class="text-[#75715e] italic">// useLocalStorage.js
"text-[#66d9ef]">import { useState, useEffect } "text-[#66d9ef]">from 'react';
"text-[#66d9ef]">export "text-[#66d9ef]">function useLocalStorage(key, initialValue) {
"text-[#66d9ef]">const [storedValue, setStoredValue] = useState(() => {
"text-[#66d9ef]">try {
"text-[#66d9ef]">const item = window.localStorage.getItem(key);
"text-[#66d9ef]">return item ? JSON.parse(item) : initialValue;
} "text-[#66d9ef]">catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
"text-[#66d9ef]">return initialValue;
}
});
"text-[#66d9ef]">const setValue = (value) => {
"text-[#66d9ef]">try {
"text-[#66d9ef]">const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} "text-[#66d9ef]">catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
};
"text-[#66d9ef]">return [storedValue, setValue];
}
"text-[#66d9ef]">class="text-[#75715e] italic">// useLocalStorage.test.js
"text-[#66d9ef]">import { renderHook, act } "text-[#66d9ef]">from '@testing-library/react';
"text-[#66d9ef]">import { useLocalStorage } "text-[#66d9ef]">from './useLocalStorage';
"text-[#66d9ef]">class="text-[#75715e] italic">// Mock localStorage
"text-[#66d9ef]">const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
};
global.localStorage = localStorageMock;
describe('useLocalStorage', () => {
beforeEach(() => {
localStorageMock.getItem.mockClear();
localStorageMock.setItem.mockClear();
});
test('returns initial value when localStorage is empty', () => {
localStorageMock.getItem.mockReturnValue("text-[#ae81ff]">null);
"text-[#66d9ef]">const { result } = renderHook(() => useLocalStorage('test-key', 'initial'));
expect(result.current[0]).toBe('initial');
expect(localStorageMock.getItem).toHaveBeenCalledWith('test-key');
});
test('returns stored value "text-[#66d9ef]">from localStorage', () => {
localStorageMock.getItem.mockReturnValue(JSON.stringify('stored-value'));
"text-[#66d9ef]">const { result } = renderHook(() => useLocalStorage('test-key', 'initial'));
expect(result.current[0]).toBe('stored-value');
});
test('updates localStorage when value changes', () => {
localStorageMock.getItem.mockReturnValue("text-[#ae81ff]">null);
"text-[#66d9ef]">const { result } = renderHook(() => useLocalStorage('test-key', 'initial'));
act(() => {
result.current[1]('"text-[#66d9ef]">new-value');
});
expect(result.current[0]).toBe('"text-[#66d9ef]">new-value');
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'test-key',
JSON.stringify('"text-[#66d9ef]">new-value')
);
});
test('handles "text-[#66d9ef]">function updates', () => {
localStorageMock.getItem.mockReturnValue(JSON.stringify(5));
"text-[#66d9ef]">const { result } = renderHook(() => useLocalStorage('counter', 0));
act(() => {
result.current[1](prev => prev + 1);
});
expect(result.current[0]).toBe(6);
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'counter',
JSON.stringify(6)
);
});
test('handles localStorage errors gracefully', () => {
"text-[#66d9ef]">const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
localStorageMock.getItem.mockImplementation(() => {
"text-[#66d9ef]">throw "text-[#66d9ef]">new Error('localStorage error');
});
"text-[#66d9ef]">const { result } = renderHook(() => useLocalStorage('test-key', 'fallback'));
expect(result.current[0]).toBe('fallback');
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
Integration Testing
Integration tests verify that multiple components work together correctly.
"text-[#66d9ef]">class="text-[#75715e] italic">// UserList.jsx
"text-[#66d9ef]">import React, { useState, useEffect } "text-[#66d9ef]">from 'react';
"text-[#66d9ef]">import UserCard "text-[#66d9ef]">from './UserCard';
"text-[#66d9ef]">import { fetchUsers, deleteUser } "text-[#66d9ef]">from '../api/users';
"text-[#66d9ef]">export "text-[#66d9ef]">default "text-[#66d9ef]">function UserList() {
"text-[#66d9ef]">const [users, setUsers] = useState([]);
"text-[#66d9ef]">const [loading, setLoading] = useState("text-[#ae81ff]">true);
"text-[#66d9ef]">const [error, setError] = useState("text-[#ae81ff]">null);
useEffect(() => {
loadUsers();
}, []);
"text-[#66d9ef]">const loadUsers = "text-[#66d9ef]">async () => {
"text-[#66d9ef]">try {
setLoading("text-[#ae81ff]">true);
"text-[#66d9ef]">const userData = "text-[#66d9ef]">await fetchUsers();
setUsers(userData);
} "text-[#66d9ef]">catch (err) {
setError(err.message);
} finally {
setLoading("text-[#ae81ff]">false);
}
};
"text-[#66d9ef]">const handleDelete = "text-[#66d9ef]">async (userId) => {
"text-[#66d9ef]">try {
"text-[#66d9ef]">await deleteUser(userId);
setUsers(users.filter(user => user.id !== userId));
} "text-[#66d9ef]">catch (err) {
setError(err.message);
}
};
"text-[#66d9ef]">const handleEdit = (userId) => {
"text-[#66d9ef]">class="text-[#75715e] italic">// Navigate to edit page or open modal
console.log('Edit user:', userId);
};
"text-[#66d9ef]">if (loading) "text-[#66d9ef]">return "loading">Loading...;
"text-[#66d9ef]">if (error) "text-[#66d9ef]">return "error">Error: {error};
"text-[#66d9ef]">return (
"user-list">
Users
{users.length === 0 ? (
"no-users">No users found
) : (
users.map(user => (
<UserCard
key={user.id}
user={user}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))
)}
);
}
"text-[#66d9ef]">class="text-[#75715e] italic">// UserList.test.jsx
"text-[#66d9ef]">import React "text-[#66d9ef]">from 'react';
"text-[#66d9ef]">import { render, screen, waitFor } "text-[#66d9ef]">from '@testing-library/react';
"text-[#66d9ef]">import userEvent "text-[#66d9ef]">from '@testing-library/user-event';
"text-[#66d9ef]">import UserList "text-[#66d9ef]">from './UserList';
"text-[#66d9ef]">import * as usersApi "text-[#66d9ef]">from '../api/users';
"text-[#66d9ef]">class="text-[#75715e] italic">// Mock the API module
jest.mock('../api/users');
"text-[#66d9ef]">const mockUsers = [
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phone: '123-456-7890',
address: '123 Main St',
},
{
id: 2,
name: 'Jane Smith',
email: 'jane@example.com',
phone: '098-765-4321',
address: '456 Oak Ave',
},
];
describe('UserList Integration', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('loads and displays users', "text-[#66d9ef]">async () => {
usersApi.fetchUsers.mockResolvedValue(mockUsers);
render(<UserList />);
"text-[#66d9ef]">class="text-[#75715e] italic">// Initially shows loading
expect(screen.getByTestId('loading')).toBeInTheDocument();
"text-[#66d9ef]">class="text-[#75715e] italic">// Wait "text-[#66d9ef]">for users to load
"text-[#66d9ef]">await waitFor(() => {
expect(screen.getByTestId('user-list')).toBeInTheDocument();
});
"text-[#66d9ef]">class="text-[#75715e] italic">// Check that users are displayed
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
expect(usersApi.fetchUsers).toHaveBeenCalledTimes(1);
});
test('displays error when fetch fails', "text-[#66d9ef]">async () => {
usersApi.fetchUsers.mockRejectedValue("text-[#66d9ef]">new Error('Network error'));
render(<UserList />);
"text-[#66d9ef]">await waitFor(() => {
expect(screen.getByTestId('error')).toBeInTheDocument();
});
expect(screen.getByText('Error: Network error')).toBeInTheDocument();
});
test('displays no users message when list is empty', "text-[#66d9ef]">async () => {
usersApi.fetchUsers.mockResolvedValue([]);
render(<UserList />);
"text-[#66d9ef]">await waitFor(() => {
expect(screen.getByTestId('no-users')).toBeInTheDocument();
});
expect(screen.getByText('No users found')).toBeInTheDocument();
});
test('deletes user when delete button is clicked', "text-[#66d9ef]">async () => {
"text-[#66d9ef]">const user = userEvent.setup();
usersApi.fetchUsers.mockResolvedValue(mockUsers);
usersApi.deleteUser.mockResolvedValue();
render(<UserList />);
"text-[#66d9ef]">class="text-[#75715e] italic">// Wait "text-[#66d9ef]">for users to load
"text-[#66d9ef]">await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
"text-[#66d9ef]">class="text-[#75715e] italic">// Find and expand the first user card
"text-[#66d9ef]">const userCards = screen.getAllByTestId('user-card');
"text-[#66d9ef]">const expandButton = userCards[0].querySelector('[data-testid="expand-button"]');
"text-[#66d9ef]">await user.click(expandButton);
"text-[#66d9ef]">class="text-[#75715e] italic">// Click delete button
"text-[#66d9ef]">const deleteButton = userCards[0].querySelector('[data-testid="delete-button"]');
"text-[#66d9ef]">await user.click(deleteButton);
"text-[#66d9ef]">class="text-[#75715e] italic">// Verify API was called and user was removed
expect(usersApi.deleteUser).toHaveBeenCalledWith(1);
"text-[#66d9ef]">await waitFor(() => {
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
});
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
test('handles delete error gracefully', "text-[#66d9ef]">async () => {
"text-[#66d9ef]">const user = userEvent.setup();
usersApi.fetchUsers.mockResolvedValue(mockUsers);
usersApi.deleteUser.mockRejectedValue("text-[#66d9ef]">new Error('Delete failed'));
render(<UserList />);
"text-[#66d9ef]">await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
"text-[#66d9ef]">class="text-[#75715e] italic">// Expand and delete
"text-[#66d9ef]">const userCards = screen.getAllByTestId('user-card');
"text-[#66d9ef]">const expandButton = userCards[0].querySelector('[data-testid="expand-button"]');
"text-[#66d9ef]">await user.click(expandButton);
"text-[#66d9ef]">const deleteButton = userCards[0].querySelector('[data-testid="delete-button"]');
"text-[#66d9ef]">await user.click(deleteButton);
"text-[#66d9ef]">class="text-[#75715e] italic">// Should show error and keep user in list
"text-[#66d9ef]">await waitFor(() => {
expect(screen.getByTestId('error')).toBeInTheDocument();
});
expect(screen.getByText('Error: Delete failed')).toBeInTheDocument();
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
});
End-to-End Testing with Playwright
E2E tests verify complete user workflows across the entire application.
"text-[#66d9ef]">class="text-[#75715e] italic">// playwright.config.js
"text-[#66d9ef]">import { defineConfig, devices } "text-[#66d9ef]">from '@playwright/test';
"text-[#66d9ef]">export "text-[#66d9ef]">default defineConfig({
testDir: './e2e',
fullyParallel: "text-[#ae81ff]">true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : "text-[#ae81ff]">undefined,
reporter: 'html',
use: {
baseURL: 'http:"text-[#66d9ef]">class="text-[#75715e] italic">//localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-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:"text-[#66d9ef]">class="text-[#75715e] italic">//localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
"text-[#66d9ef]">class="text-[#75715e] italic">// e2e/user-management.spec.js
"text-[#66d9ef]">import { test, expect } "text-[#66d9ef]">from '@playwright/test';
test.describe('User Management', () => {
test.beforeEach("text-[#66d9ef]">async ({ page }) => {
"text-[#66d9ef]">class="text-[#75715e] italic">// Mock API responses
"text-[#66d9ef]">await page.route('/api/users', "text-[#66d9ef]">async route => {
"text-[#66d9ef]">const users = [
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phone: '123-456-7890',
address: '123 Main St',
},
{
id: 2,
name: 'Jane Smith',
email: 'jane@example.com',
phone: '098-765-4321',
address: '456 Oak Ave',
},
];
"text-[#66d9ef]">await route.fulfill({ json: users });
});
"text-[#66d9ef]">await page.goto('/users');
});
test('displays user list', "text-[#66d9ef]">async ({ page }) => {
"text-[#66d9ef]">await expect(page.getByTestId('user-list')).toBeVisible();
"text-[#66d9ef]">await expect(page.getByText('John Doe')).toBeVisible();
"text-[#66d9ef]">await expect(page.getByText('Jane Smith')).toBeVisible();
});
test('expands user details', "text-[#66d9ef]">async ({ page }) => {
"text-[#66d9ef]">class="text-[#75715e] italic">// Click expand button "text-[#66d9ef]">for first user
"text-[#66d9ef]">await page.getByTestId('expand-button').first().click();
"text-[#66d9ef]">class="text-[#75715e] italic">// Check that details are visible
"text-[#66d9ef]">await expect(page.getByText('Phone: 123-456-7890')).toBeVisible();
"text-[#66d9ef]">await expect(page.getByText('Address: 123 Main St')).toBeVisible();
"text-[#66d9ef]">class="text-[#75715e] italic">// Check that action buttons are visible
"text-[#66d9ef]">await expect(page.getByTestId('edit-button')).toBeVisible();
"text-[#66d9ef]">await expect(page.getByTestId('delete-button')).toBeVisible();
});
test('deletes user', "text-[#66d9ef]">async ({ page }) => {
"text-[#66d9ef]">class="text-[#75715e] italic">// Mock delete API
"text-[#66d9ef]">await page.route('/api/users/1', "text-[#66d9ef]">async route => {
"text-[#66d9ef]">if (route.request().method() === 'DELETE') {
"text-[#66d9ef]">await route.fulfill({ status: 200 });
}
});
"text-[#66d9ef]">class="text-[#75715e] italic">// Expand first user and click delete
"text-[#66d9ef]">await page.getByTestId('expand-button').first().click();
"text-[#66d9ef]">await page.getByTestId('delete-button').click();
"text-[#66d9ef]">class="text-[#75715e] italic">// Verify user is removed "text-[#66d9ef]">from list
"text-[#66d9ef]">await expect(page.getByText('John Doe')).not.toBeVisible();
"text-[#66d9ef]">await expect(page.getByText('Jane Smith')).toBeVisible();
});
test('handles delete error', "text-[#66d9ef]">async ({ page }) => {
"text-[#66d9ef]">class="text-[#75715e] italic">// Mock delete API to "text-[#66d9ef]">return error
"text-[#66d9ef]">await page.route('/api/users/1', "text-[#66d9ef]">async route => {
"text-[#66d9ef]">if (route.request().method() === 'DELETE') {
"text-[#66d9ef]">await route.fulfill({
status: 500,
json: { error: 'Internal server error' }
});
}
});
"text-[#66d9ef]">class="text-[#75715e] italic">// Expand first user and click delete
"text-[#66d9ef]">await page.getByTestId('expand-button').first().click();
"text-[#66d9ef]">await page.getByTestId('delete-button').click();
"text-[#66d9ef]">class="text-[#75715e] italic">// Verify error is displayed and user remains
"text-[#66d9ef]">await expect(page.getByTestId('error')).toBeVisible();
"text-[#66d9ef]">await expect(page.getByText('John Doe')).toBeVisible();
});
test('responsive design works on mobile', "text-[#66d9ef]">async ({ page }) => {
"text-[#66d9ef]">class="text-[#75715e] italic">// Set mobile viewport
"text-[#66d9ef]">await page.setViewportSize({ width: 375, height: 667 });
"text-[#66d9ef]">class="text-[#75715e] italic">// Check that layout adapts
"text-[#66d9ef]">await expect(page.getByTestId('user-list')).toBeVisible();
"text-[#66d9ef]">class="text-[#75715e] italic">// User cards should stack vertically on mobile
"text-[#66d9ef]">const userCards = page.getByTestId('user-card');
"text-[#66d9ef]">await expect(userCards).toHaveCount(2);
});
});
"text-[#66d9ef]">class="text-[#75715e] italic">// e2e/accessibility.spec.js
"text-[#66d9ef]">import { test, expect } "text-[#66d9ef]">from '@playwright/test';
"text-[#66d9ef]">import AxeBuilder "text-[#66d9ef]">from '@axe-core/playwright';
test.describe('Accessibility', () => {
test('should not have any automatically detectable accessibility issues', "text-[#66d9ef]">async ({ page }) => {
"text-[#66d9ef]">await page.goto('/users');
"text-[#66d9ef]">const accessibilityScanResults = "text-[#66d9ef]">await "text-[#66d9ef]">new AxeBuilder({ page }).analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
test('keyboard navigation works', "text-[#66d9ef]">async ({ page }) => {
"text-[#66d9ef]">await page.goto('/users');
"text-[#66d9ef]">class="text-[#75715e] italic">// Tab through interactive elements
"text-[#66d9ef]">await page.keyboard.press('Tab');
"text-[#66d9ef]">await expect(page.getByTestId('expand-button').first()).toBeFocused();
"text-[#66d9ef]">class="text-[#75715e] italic">// Press Enter to expand
"text-[#66d9ef]">await page.keyboard.press('Enter');
"text-[#66d9ef]">await expect(page.getByTestId('user-details').first()).toBeVisible();
"text-[#66d9ef]">class="text-[#75715e] italic">// Continue tabbing to action buttons
"text-[#66d9ef]">await page.keyboard.press('Tab');
"text-[#66d9ef]">await expect(page.getByTestId('edit-button').first()).toBeFocused();
"text-[#66d9ef]">await page.keyboard.press('Tab');
"text-[#66d9ef]">await expect(page.getByTestId('delete-button').first()).toBeFocused();
});
});
Testing Best Practices
"text-[#66d9ef]">class="text-[#75715e] italic">// Test organization and naming
describe('UserService', () => {
describe('when user exists', () => {
test('should "text-[#66d9ef]">return user data', () => {
"text-[#66d9ef]">class="text-[#75715e] italic">// Test implementation
});
test('should update user successfully', () => {
"text-[#66d9ef]">class="text-[#75715e] italic">// Test implementation
});
});
describe('when user does not exist', () => {
test('should "text-[#66d9ef]">throw UserNotFoundError', () => {
"text-[#66d9ef]">class="text-[#75715e] italic">// Test implementation
});
});
});
"text-[#66d9ef]">class="text-[#75715e] italic">// AAA Pattern: Arrange, Act, Assert
test('should calculate total price with tax', () => {
"text-[#66d9ef]">class="text-[#75715e] italic">// Arrange
"text-[#66d9ef]">const items = [
{ price: 10, quantity: 2 },
{ price: 5, quantity: 1 }
];
"text-[#66d9ef]">const taxRate = 0.1;
"text-[#66d9ef]">class="text-[#75715e] italic">// Act
"text-[#66d9ef]">const total = calculateTotalWithTax(items, taxRate);
"text-[#66d9ef]">class="text-[#75715e] italic">// Assert
expect(total).toBe(27.5); "text-[#66d9ef]">class="text-[#75715e] italic">// (10*2 + 5*1) * 1.1
});
"text-[#66d9ef]">class="text-[#75715e] italic">// Test data builders "text-[#66d9ef]">for complex objects
"text-[#66d9ef]">class UserBuilder {
constructor() {
this.user = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
role: 'user',
active: "text-[#ae81ff]">true,
};
}
withId(id) {
this.user.id = id;
"text-[#66d9ef]">return this;
}
withName(name) {
this.user.name = name;
"text-[#66d9ef]">return this;
}
withRole(role) {
this.user.role = role;
"text-[#66d9ef]">return this;
}
inactive() {
this.user.active = "text-[#ae81ff]">false;
"text-[#66d9ef]">return this;
}
build() {
"text-[#66d9ef]">return { ...this.user };
}
}
"text-[#66d9ef]">class="text-[#75715e] italic">// Usage
test('should allow admin to delete users', () => {
"text-[#66d9ef]">const admin = "text-[#66d9ef]">new UserBuilder()
.withRole('admin')
.build();
"text-[#66d9ef]">const targetUser = "text-[#66d9ef]">new UserBuilder()
.withId(2)
.build();
expect(canDeleteUser(admin, targetUser)).toBe("text-[#ae81ff]">true);
});
"text-[#66d9ef]">class="text-[#75715e] italic">// Custom matchers "text-[#66d9ef]">for better assertions
expect.extend({
toBeValidEmail(received) {
"text-[#66d9ef]">const emailRegex = /^[^s@]+@[^s@]+.[^s@]+$/;
"text-[#66d9ef]">const pass = emailRegex.test(received);
"text-[#66d9ef]">return {
message: () => `expected ${received} ${pass ? 'not ' : ''}to be a valid email`,
pass,
};
},
toHaveBeenCalledWithUser(received, expectedUser) {
"text-[#66d9ef]">const pass = received.mock.calls.some(call =>
call[0] && call[0].id === expectedUser.id
);
"text-[#66d9ef]">return {
message: () => `expected "text-[#66d9ef]">function ${pass ? 'not ' : ''}to have been called with user ${expectedUser.id}`,
pass,
};
},
});
"text-[#66d9ef]">class="text-[#75715e] italic">// Usage
test('validates email format', () => {
expect('test@example.com').toBeValidEmail();
expect('invalid-email').not.toBeValidEmail();
});
"text-[#66d9ef]">class="text-[#75715e] italic">// Page Object Model "text-[#66d9ef]">for E2E tests
"text-[#66d9ef]">class UserListPage {
constructor(page) {
this.page = page;
}
"text-[#66d9ef]">async goto() {
"text-[#66d9ef]">await this.page.goto('/users');
}
"text-[#66d9ef]">async expandUser(index = 0) {
"text-[#66d9ef]">await this.page.getByTestId('expand-button').nth(index).click();
}
"text-[#66d9ef]">async deleteUser(index = 0) {
"text-[#66d9ef]">await this.expandUser(index);
"text-[#66d9ef]">await this.page.getByTestId('delete-button').nth(index).click();
}
"text-[#66d9ef]">async getUserNames() {
"text-[#66d9ef]">const names = "text-[#66d9ef]">await this.page.getByTestId('user-card').allTextContents();
"text-[#66d9ef]">return names;
}
"text-[#66d9ef]">async waitForLoading() {
"text-[#66d9ef]">await this.page.waitForSelector('[data-testid="loading"]', { state: 'hidden' });
}
}
"text-[#66d9ef]">class="text-[#75715e] italic">// Usage in tests
test('user management workflow', "text-[#66d9ef]">async ({ page }) => {
"text-[#66d9ef]">const userListPage = "text-[#66d9ef]">new UserListPage(page);
"text-[#66d9ef]">await userListPage.goto();
"text-[#66d9ef]">await userListPage.waitForLoading();
"text-[#66d9ef]">const initialUsers = "text-[#66d9ef]">await userListPage.getUserNames();
expect(initialUsers).toHaveLength(2);
"text-[#66d9ef]">await userListPage.deleteUser(0);
"text-[#66d9ef]">const remainingUsers = "text-[#66d9ef]">await userListPage.getUserNames();
expect(remainingUsers).toHaveLength(1);
});
Continuous Integration and Testing
// .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 to Codecov
uses: codecov/codecov-action@v3
with: file: ./coverage/lcov.info
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: failure()
with: name: playwright-report
path: playwright-report/
# package.json scripts
{
"scripts": {
"test": "jest",
"test: unit": "jest --testPathPattern=src",
"test: integration": "jest --testPathPattern=integration",
"test: e2e": "playwright test",
"test: watch": "jest --watch",
"test: coverage": "jest --coverage",
"test: ci": "jest --coverage --watchAll=false --ci"
}
}
Conclusion
A comprehensive testing strategy includes:
- Unit Tests (70%) - Fast, isolated tests for individual functions and components
- Integration Tests (20%) - Tests for component interactions and API integration
- E2E Tests (10%) - Full user workflow tests across the entire application
Key principles for effective testing:
- Write tests first (TDD) or alongside development
- Test behavior, not implementation details
- Use descriptive test names that explain the scenario
- Keep tests simple and focused on one thing
- Mock external dependencies appropriately
- Maintain test code quality like production code
- Run tests in CI/CD to catch regressions early
Remember that testing is an investment in code quality, developer confidence, and user satisfaction. Start with the most critical paths and gradually increase coverage as your application grows.