- Published on
Comprehensive Frontend Unit Testing Guide: From Components to API Integration
Unit testing is the foundation of reliable frontend applications. Yet many developers struggle with what to test, how to test it, and how to make tests maintainable. Today, we'll dive deep into comprehensive frontend unit testing, covering everything from simple components to complex API integrations.
Why Frontend Unit Testing Matters
Frontend applications are increasingly complex, with intricate state management, user interactions, and API dependencies. Without proper testing:
- Bugs slip through to production
- Refactoring becomes risky and slow
- Team confidence decreases with each release
- Technical debt accumulates rapidly
The solution? A robust testing strategy that gives you confidence to ship fast and break nothing.
๐งช Testing Philosophy and Strategy
The Testing Pyramid for Frontend
โโโโโโโโโโโโโโโโโโโ
โ E2E Tests โ โ Few, Expensive, Slow
โ (Cypress) โ
โโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโ
โ Integration Tests โ โ Some, Moderate Cost
โ (Component + API) โ
โโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Unit Tests โ โ Many, Cheap, Fast
โ (Functions, Components) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
What to Test at Each Level
Test Type | Scope | Tools | Examples |
---|---|---|---|
Unit | Individual functions/components | Jest, React Testing Library | Pure functions, component rendering, user interactions |
Integration | Multiple units working together | Jest + MSW | Component + API, form submission flows |
E2E | Complete user workflows | Cypress, Playwright | Login flow, checkout process |
๐ ๏ธ Essential Testing Tools Setup
Core Testing Stack
# Core testing framework
npm install --save-dev jest @testing-library/react @testing-library/jest-dom @testing-library/user-event
# API mocking
npm install --save-dev msw
# Additional utilities
npm install --save-dev jest-environment-jsdom
Jest Configuration
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
moduleNameMapping: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/index.js',
'!src/reportWebVitals.js',
'!src/**/*.stories.{js,jsx,ts,tsx}',
'!src/**/*.test.{js,jsx,ts,tsx}',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
testMatch: [
'<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}',
'<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}',
],
}
Test Setup File
// src/setupTests.js
import '@testing-library/jest-dom'
import { server } from './mocks/server'
// Establish API mocking before all tests
beforeAll(() => server.listen())
// Reset any request handlers that we may add during the tests
afterEach(() => server.resetHandlers())
// Clean up after the tests are finished
afterAll(() => server.close())
// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
observe() {}
unobserve() {}
disconnect() {}
}
// Mock ResizeObserver
global.ResizeObserver = class ResizeObserver {
constructor() {}
observe() {}
unobserve() {}
disconnect() {}
}
// Mock window.matchMedia
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(),
})),
})
๐งฉ Component Testing Strategies
1. Basic Component Testing
// components/Button/Button.jsx
import React from 'react'
import classNames from 'classnames'
const Button = ({
children,
variant = 'primary',
size = 'medium',
disabled = false,
onClick,
...props
}) => {
const buttonClasses = classNames('btn', {
[`btn--${variant}`]: variant,
[`btn--${size}`]: size,
'btn--disabled': disabled,
})
return (
<button className={buttonClasses} disabled={disabled} onClick={onClick} {...props}>
{children}
</button>
)
}
export default Button
// components/Button/Button.test.jsx
import { render, screen, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Button from './Button'
describe('Button Component', () => {
it('renders with default props', () => {
render(<Button>Click me</Button>)
const button = screen.getByRole('button', { name: /click me/i })
expect(button).toBeInTheDocument()
expect(button).toHaveClass('btn', 'btn--primary', 'btn--medium')
})
it('applies variant classes correctly', () => {
render(<Button variant="secondary">Secondary</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('btn--secondary')
})
it('applies size classes correctly', () => {
render(<Button size="large">Large Button</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('btn--large')
})
it('handles disabled state', () => {
render(<Button disabled>Disabled</Button>)
const button = screen.getByRole('button')
expect(button).toBeDisabled()
expect(button).toHaveClass('btn--disabled')
})
it('calls onClick when clicked', async () => {
const user = userEvent.setup()
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click me</Button>)
const button = screen.getByRole('button')
await user.click(button)
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('does not call onClick when disabled', async () => {
const user = userEvent.setup()
const handleClick = jest.fn()
render(
<Button onClick={handleClick} disabled>
Disabled
</Button>
)
const button = screen.getByRole('button')
await user.click(button)
expect(handleClick).not.toHaveBeenCalled()
})
it('forwards additional props', () => {
render(
<Button data-testid="custom-button" aria-label="Custom">
Test
</Button>
)
const button = screen.getByTestId('custom-button')
expect(button).toHaveAttribute('aria-label', 'Custom')
})
})
2. Form Component Testing
// components/LoginForm/LoginForm.jsx
import React, { useState } from 'react'
import Button from '../Button/Button'
const LoginForm = ({ onSubmit, isLoading = false }) => {
const [formData, setFormData] = useState({
email: '',
password: '',
})
const [errors, setErrors] = useState({})
const validateForm = () => {
const newErrors = {}
if (!formData.email) {
newErrors.email = 'Email is required'
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = 'Email is invalid'
}
if (!formData.password) {
newErrors.password = 'Password is required'
} else if (formData.password.length < 6) {
newErrors.password = 'Password must be at least 6 characters'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = (e) => {
e.preventDefault()
if (validateForm()) {
onSubmit(formData)
}
}
const handleChange = (e) => {
const { name, value } = e.target
setFormData((prev) => ({
...prev,
[name]: value,
}))
// Clear error when user starts typing
if (errors[name]) {
setErrors((prev) => ({
...prev,
[name]: '',
}))
}
}
return (
<form onSubmit={handleSubmit} noValidate>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<div id="email-error" className="error" role="alert">
{errors.email}
</div>
)}
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
aria-invalid={!!errors.password}
aria-describedby={errors.password ? 'password-error' : undefined}
/>
{errors.password && (
<div id="password-error" className="error" role="alert">
{errors.password}
</div>
)}
</div>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
</form>
)
}
export default LoginForm
// components/LoginForm/LoginForm.test.jsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import LoginForm from './LoginForm'
describe('LoginForm', () => {
const mockOnSubmit = jest.fn()
beforeEach(() => {
mockOnSubmit.mockClear()
})
it('renders form fields correctly', () => {
render(<LoginForm onSubmit={mockOnSubmit} />)
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
})
it('updates input values when user types', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={mockOnSubmit} />)
const emailInput = screen.getByLabelText(/email/i)
const passwordInput = screen.getByLabelText(/password/i)
await user.type(emailInput, 'test@example.com')
await user.type(passwordInput, 'password123')
expect(emailInput).toHaveValue('test@example.com')
expect(passwordInput).toHaveValue('password123')
})
it('shows validation errors for empty fields', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={mockOnSubmit} />)
const submitButton = screen.getByRole('button', { name: /sign in/i })
await user.click(submitButton)
expect(screen.getByText('Email is required')).toBeInTheDocument()
expect(screen.getByText('Password is required')).toBeInTheDocument()
expect(mockOnSubmit).not.toHaveBeenCalled()
})
it('shows validation error for invalid email', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={mockOnSubmit} />)
const emailInput = screen.getByLabelText(/email/i)
const submitButton = screen.getByRole('button', { name: /sign in/i })
await user.type(emailInput, 'invalid-email')
await user.click(submitButton)
expect(screen.getByText('Email is invalid')).toBeInTheDocument()
})
it('shows validation error for short password', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={mockOnSubmit} />)
const passwordInput = screen.getByLabelText(/password/i)
const submitButton = screen.getByRole('button', { name: /sign in/i })
await user.type(passwordInput, '123')
await user.click(submitButton)
expect(screen.getByText('Password must be at least 6 characters')).toBeInTheDocument()
})
it('clears errors when user starts typing', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={mockOnSubmit} />)
const emailInput = screen.getByLabelText(/email/i)
const submitButton = screen.getByRole('button', { name: /sign in/i })
// Trigger validation error
await user.click(submitButton)
expect(screen.getByText('Email is required')).toBeInTheDocument()
// Start typing to clear error
await user.type(emailInput, 'test@example.com')
expect(screen.queryByText('Email is required')).not.toBeInTheDocument()
})
it('submits form with valid data', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={mockOnSubmit} />)
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /sign in/i }))
expect(mockOnSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
})
})
it('shows loading state correctly', () => {
render(<LoginForm onSubmit={mockOnSubmit} isLoading={true} />)
const submitButton = screen.getByRole('button')
expect(submitButton).toHaveTextContent('Signing in...')
expect(submitButton).toBeDisabled()
})
it('handles form submission with Enter key', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={mockOnSubmit} />)
const emailInput = screen.getByLabelText(/email/i)
await user.type(emailInput, 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.keyboard('{Enter}')
expect(mockOnSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
})
})
})
3. Component with Hooks Testing
// hooks/useApi.js
import { useState, useEffect } from 'react'
const useApi = (url, options = {}) => {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false
const fetchData = async () => {
try {
setLoading(true)
setError(null)
const response = await fetch(url, options)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result = await response.json()
if (!cancelled) {
setData(result)
}
} catch (err) {
if (!cancelled) {
setError(err.message)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
fetchData()
return () => {
cancelled = true
}
}, [url, options])
return { data, loading, error }
}
export default useApi
// components/UserProfile/UserProfile.jsx
import React from 'react'
import useApi from '../../hooks/useApi'
const UserProfile = ({ userId }) => {
const { data: user, loading, error } = useApi(`/api/users/${userId}`)
if (loading) {
return <div data-testid="loading">Loading user profile...</div>
}
if (error) {
return (
<div data-testid="error" role="alert">
Error loading user: {error}
</div>
)
}
if (!user) {
return <div data-testid="no-user">User not found</div>
}
return (
<div data-testid="user-profile">
<h1>{user.name}</h1>
<p>Email: {user.email}</p>
<p>Role: {user.role}</p>
{user.avatar && (
<img src={user.avatar} alt={`${user.name}'s avatar`} width="100" height="100" />
)}
</div>
)
}
export default UserProfile
// components/UserProfile/UserProfile.test.jsx
import { render, screen, waitFor } from '@testing-library/react'
import { rest } from 'msw'
import { server } from '../../mocks/server'
import UserProfile from './UserProfile'
const mockUser = {
id: '1',
name: 'John Doe',
email: 'john@example.com',
role: 'admin',
avatar: 'https://example.com/avatar.jpg',
}
describe('UserProfile', () => {
it('shows loading state initially', () => {
render(<UserProfile userId="1" />)
expect(screen.getByTestId('loading')).toBeInTheDocument()
})
it('displays user data when loaded successfully', async () => {
server.use(
rest.get('/api/users/1', (req, res, ctx) => {
return res(ctx.json(mockUser))
})
)
render(<UserProfile userId="1" />)
await waitFor(() => {
expect(screen.getByTestId('user-profile')).toBeInTheDocument()
})
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('Email: john@example.com')).toBeInTheDocument()
expect(screen.getByText('Role: admin')).toBeInTheDocument()
const avatar = screen.getByAltText("John Doe's avatar")
expect(avatar).toHaveAttribute('src', 'https://example.com/avatar.jpg')
})
it('displays error message when API call fails', async () => {
server.use(
rest.get('/api/users/1', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ message: 'Server error' }))
})
)
render(<UserProfile userId="1" />)
await waitFor(() => {
expect(screen.getByTestId('error')).toBeInTheDocument()
})
expect(screen.getByText(/error loading user/i)).toBeInTheDocument()
})
it('displays not found message when user does not exist', async () => {
server.use(
rest.get('/api/users/1', (req, res, ctx) => {
return res(ctx.status(404), ctx.json({ message: 'User not found' }))
})
)
render(<UserProfile userId="1" />)
await waitFor(() => {
expect(screen.getByTestId('error')).toBeInTheDocument()
})
})
it('handles network errors gracefully', async () => {
server.use(
rest.get('/api/users/1', (req, res, ctx) => {
return res.networkError('Failed to connect')
})
)
render(<UserProfile userId="1" />)
await waitFor(() => {
expect(screen.getByTestId('error')).toBeInTheDocument()
})
})
it('does not render avatar when not provided', async () => {
const userWithoutAvatar = { ...mockUser, avatar: null }
server.use(
rest.get('/api/users/1', (req, res, ctx) => {
return res(ctx.json(userWithoutAvatar))
})
)
render(<UserProfile userId="1" />)
await waitFor(() => {
expect(screen.getByTestId('user-profile')).toBeInTheDocument()
})
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
})
๐ API Testing Strategies
MSW (Mock Service Worker) Setup
// src/mocks/handlers.js
import { rest } from 'msw'
export const handlers = [
// User endpoints
rest.get('/api/users/:id', (req, res, ctx) => {
const { id } = req.params
const users = {
1: {
id: '1',
name: 'John Doe',
email: 'john@example.com',
role: 'admin',
},
2: {
id: '2',
name: 'Jane Smith',
email: 'jane@example.com',
role: 'user',
},
}
const user = users[id]
if (!user) {
return res(ctx.status(404), ctx.json({ message: 'User not found' }))
}
return res(ctx.json(user))
}),
// Authentication endpoints
rest.post('/api/auth/login', async (req, res, ctx) => {
const { email, password } = await req.json()
// Simulate validation
if (email === 'test@example.com' && password === 'password123') {
return res(
ctx.json({
user: {
id: '1',
name: 'Test User',
email: 'test@example.com',
},
token: 'mock-jwt-token',
})
)
}
return res(ctx.status(401), ctx.json({ message: 'Invalid credentials' }))
}),
// Products endpoint with pagination
rest.get('/api/products', (req, res, ctx) => {
const page = parseInt(req.url.searchParams.get('page') || '1')
const limit = parseInt(req.url.searchParams.get('limit') || '10')
const category = req.url.searchParams.get('category')
let products = [
{ id: '1', name: 'Product 1', category: 'electronics', price: 99.99 },
{ id: '2', name: 'Product 2', category: 'clothing', price: 49.99 },
{ id: '3', name: 'Product 3', category: 'electronics', price: 199.99 },
// ... more products
]
// Filter by category if provided
if (category) {
products = products.filter((p) => p.category === category)
}
// Simulate pagination
const startIndex = (page - 1) * limit
const endIndex = startIndex + limit
const paginatedProducts = products.slice(startIndex, endIndex)
return res(
ctx.json({
products: paginatedProducts,
pagination: {
page,
limit,
total: products.length,
totalPages: Math.ceil(products.length / limit),
},
})
)
}),
// Error simulation endpoint
rest.get('/api/error', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ message: 'Internal server error' }))
}),
]
// src/mocks/server.js
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
Service Layer Testing
// services/api.js
const API_BASE_URL = process.env.REACT_APP_API_URL || '/api'
class ApiError extends Error {
constructor(message, status, data) {
super(message)
this.name = 'ApiError'
this.status = status
this.data = data
}
}
const apiClient = {
async request(endpoint, options = {}) {
const url = `${API_BASE_URL}${endpoint}`
const config = {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
}
const response = await fetch(url, config)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new ApiError(errorData.message || 'Something went wrong', response.status, errorData)
}
return response.json()
},
get(endpoint, options = {}) {
return this.request(endpoint, { method: 'GET', ...options })
},
post(endpoint, data, options = {}) {
return this.request(endpoint, {
method: 'POST',
body: JSON.stringify(data),
...options,
})
},
put(endpoint, data, options = {}) {
return this.request(endpoint, {
method: 'PUT',
body: JSON.stringify(data),
...options,
})
},
delete(endpoint, options = {}) {
return this.request(endpoint, { method: 'DELETE', ...options })
},
}
export { apiClient, ApiError }
// services/userService.js
import { apiClient } from './api'
export const userService = {
async getUser(id) {
return apiClient.get(`/users/${id}`)
},
async getCurrentUser() {
return apiClient.get('/users/me')
},
async updateUser(id, userData) {
return apiClient.put(`/users/${id}`, userData)
},
async deleteUser(id) {
return apiClient.delete(`/users/${id}`)
},
}
// services/userService.test.js
import { rest } from 'msw'
import { server } from '../mocks/server'
import { userService } from './userService'
import { ApiError } from './api'
describe('userService', () => {
describe('getUser', () => {
it('returns user data for valid ID', async () => {
const mockUser = {
id: '1',
name: 'John Doe',
email: 'john@example.com',
}
server.use(
rest.get('/api/users/1', (req, res, ctx) => {
return res(ctx.json(mockUser))
})
)
const user = await userService.getUser('1')
expect(user).toEqual(mockUser)
})
it('throws ApiError for non-existent user', async () => {
server.use(
rest.get('/api/users/999', (req, res, ctx) => {
return res(ctx.status(404), ctx.json({ message: 'User not found' }))
})
)
await expect(userService.getUser('999')).rejects.toThrow(ApiError)
try {
await userService.getUser('999')
} catch (error) {
expect(error.status).toBe(404)
expect(error.message).toBe('User not found')
}
})
it('throws ApiError for server errors', async () => {
server.use(
rest.get('/api/users/1', (req, res, ctx) => {
return res(ctx.status(500))
})
)
await expect(userService.getUser('1')).rejects.toThrow(ApiError)
})
})
describe('updateUser', () => {
it('updates user successfully', async () => {
const updatedUser = {
id: '1',
name: 'John Updated',
email: 'john.updated@example.com',
}
server.use(
rest.put('/api/users/1', (req, res, ctx) => {
return res(ctx.json(updatedUser))
})
)
const result = await userService.updateUser('1', {
name: 'John Updated',
email: 'john.updated@example.com',
})
expect(result).toEqual(updatedUser)
})
it('handles validation errors', async () => {
server.use(
rest.put('/api/users/1', (req, res, ctx) => {
return res(
ctx.status(400),
ctx.json({
message: 'Validation failed',
errors: {
email: 'Invalid email format',
},
})
)
})
)
try {
await userService.updateUser('1', { email: 'invalid-email' })
} catch (error) {
expect(error).toBeInstanceOf(ApiError)
expect(error.status).toBe(400)
expect(error.data.errors.email).toBe('Invalid email format')
}
})
})
})
Integration Testing with Components and APIs
// components/UserList/UserList.jsx
import React, { useEffect, useState } from 'react'
import { userService } from '../../services/userService'
const UserList = () => {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
const fetchUsers = async () => {
try {
setLoading(true)
const userData = await userService.getUsers()
setUsers(userData)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
fetchUsers()
}, [])
const handleDeleteUser = async (userId) => {
try {
await userService.deleteUser(userId)
setUsers((prev) => prev.filter((user) => user.id !== userId))
} catch (err) {
setError(`Failed to delete user: ${err.message}`)
}
}
if (loading) return <div data-testid="loading">Loading users...</div>
if (error) return <div data-testid="error">Error: {error}</div>
return (
<div data-testid="user-list">
<h2>Users</h2>
{users.length === 0 ? (
<p data-testid="no-users">No users found</p>
) : (
<ul>
{users.map((user) => (
<li key={user.id} data-testid={`user-${user.id}`}>
<span>
{user.name} ({user.email})
</span>
<button onClick={() => handleDeleteUser(user.id)} aria-label={`Delete ${user.name}`}>
Delete
</button>
</li>
))}
</ul>
)}
</div>
)
}
export default UserList
// components/UserList/UserList.test.jsx
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { rest } from 'msw'
import { server } from '../../mocks/server'
import UserList from './UserList'
const mockUsers = [
{ id: '1', name: 'John Doe', email: 'john@example.com' },
{ id: '2', name: 'Jane Smith', email: 'jane@example.com' },
]
describe('UserList Integration', () => {
beforeEach(() => {
// Setup default successful response
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.json(mockUsers))
})
)
})
it('fetches and displays users on mount', async () => {
render(<UserList />)
// Shows loading initially
expect(screen.getByTestId('loading')).toBeInTheDocument()
// Wait for users to load
await waitFor(() => {
expect(screen.getByTestId('user-list')).toBeInTheDocument()
})
// Check users are displayed
expect(screen.getByTestId('user-1')).toBeInTheDocument()
expect(screen.getByTestId('user-2')).toBeInTheDocument()
expect(screen.getByText('John Doe (john@example.com)')).toBeInTheDocument()
expect(screen.getByText('Jane Smith (jane@example.com)')).toBeInTheDocument()
})
it('handles API errors gracefully', async () => {
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ message: 'Server error' }))
})
)
render(<UserList />)
await waitFor(() => {
expect(screen.getByTestId('error')).toBeInTheDocument()
})
expect(screen.getByText(/error: server error/i)).toBeInTheDocument()
})
it('deletes user successfully', async () => {
const user = userEvent.setup()
// Mock successful delete
server.use(
rest.delete('/api/users/1', (req, res, ctx) => {
return res(ctx.status(204))
})
)
render(<UserList />)
// Wait for initial load
await waitFor(() => {
expect(screen.getByTestId('user-1')).toBeInTheDocument()
})
// Click delete button
const deleteButton = screen.getByLabelText('Delete John Doe')
await user.click(deleteButton)
// User should be removed from the list
await waitFor(() => {
expect(screen.queryByTestId('user-1')).not.toBeInTheDocument()
})
// Other user should still be there
expect(screen.getByTestId('user-2')).toBeInTheDocument()
})
it('handles delete errors', async () => {
const user = userEvent.setup()
server.use(
rest.delete('/api/users/1', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ message: 'Delete failed' }))
})
)
render(<UserList />)
await waitFor(() => {
expect(screen.getByTestId('user-1')).toBeInTheDocument()
})
const deleteButton = screen.getByLabelText('Delete John Doe')
await user.click(deleteButton)
// Error should be displayed
await waitFor(() => {
expect(screen.getByText(/failed to delete user: delete failed/i)).toBeInTheDocument()
})
// User should still be in the list
expect(screen.getByTestId('user-1')).toBeInTheDocument()
})
it('shows no users message when list is empty', async () => {
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.json([]))
})
)
render(<UserList />)
await waitFor(() => {
expect(screen.getByTestId('no-users')).toBeInTheDocument()
})
expect(screen.getByText('No users found')).toBeInTheDocument()
})
})
๐ญ Advanced Testing Patterns
Custom Render Function
// test-utils.js
import React from 'react'
import { render } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from 'react-query'
import { AuthProvider } from '../contexts/AuthContext'
import { ThemeProvider } from '../contexts/ThemeContext'
const AllProviders = ({ children, initialEntries = ['/'] }) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return (
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ThemeProvider>{children}</ThemeProvider>
</AuthProvider>
</QueryClientProvider>
</BrowserRouter>
)
}
const customRender = (ui, options = {}) => render(ui, { wrapper: AllProviders, ...options })
// Re-export everything
export * from '@testing-library/react'
export { customRender as render }
Testing Context Providers
// contexts/AuthContext.jsx
import React, { createContext, useContext, useReducer } from 'react'
const AuthContext = createContext()
const authReducer = (state, action) => {
switch (action.type) {
case 'LOGIN_START':
return { ...state, loading: true, error: null }
case 'LOGIN_SUCCESS':
return { ...state, loading: false, user: action.payload, isAuthenticated: true }
case 'LOGIN_FAILURE':
return { ...state, loading: false, error: action.payload }
case 'LOGOUT':
return { loading: false, user: null, isAuthenticated: false, error: null }
default:
return state
}
}
export const AuthProvider = ({ children }) => {
const [state, dispatch] = useReducer(authReducer, {
user: null,
isAuthenticated: false,
loading: false,
error: null,
})
const login = async (credentials) => {
dispatch({ type: 'LOGIN_START' })
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
})
if (!response.ok) {
throw new Error('Login failed')
}
const data = await response.json()
dispatch({ type: 'LOGIN_SUCCESS', payload: data.user })
} catch (error) {
dispatch({ type: 'LOGIN_FAILURE', payload: error.message })
}
}
const logout = () => {
dispatch({ type: 'LOGOUT' })
}
return <AuthContext.Provider value={{ ...state, login, logout }}>{children}</AuthContext.Provider>
}
export const useAuth = () => {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}
// contexts/AuthContext.test.jsx
import { render, screen, waitFor, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { rest } from 'msw'
import { server } from '../mocks/server'
import { AuthProvider, useAuth } from './AuthContext'
// Test component that uses the auth context
const TestComponent = () => {
const { user, isAuthenticated, loading, error, login, logout } = useAuth()
return (
<div>
<div data-testid="auth-status">
{loading && 'Loading...'}
{isAuthenticated ? `Logged in as ${user?.name}` : 'Not logged in'}
{error && `Error: ${error}`}
</div>
<button onClick={() => login({ email: 'test@example.com', password: 'password' })}>
Login
</button>
<button onClick={logout}>Logout</button>
</div>
)
}
const renderWithAuthProvider = (component) => {
return render(<AuthProvider>{component}</AuthProvider>)
}
describe('AuthContext', () => {
it('starts with unauthenticated state', () => {
renderWithAuthProvider(<TestComponent />)
expect(screen.getByTestId('auth-status')).toHaveTextContent('Not logged in')
})
it('handles successful login', async () => {
const user = userEvent.setup()
server.use(
rest.post('/api/auth/login', (req, res, ctx) => {
return res(
ctx.json({
user: { id: '1', name: 'Test User', email: 'test@example.com' },
})
)
})
)
renderWithAuthProvider(<TestComponent />)
await user.click(screen.getByText('Login'))
// Should show loading state
expect(screen.getByTestId('auth-status')).toHaveTextContent('Loading...')
// Wait for login to complete
await waitFor(() => {
expect(screen.getByTestId('auth-status')).toHaveTextContent('Logged in as Test User')
})
})
it('handles login failure', async () => {
const user = userEvent.setup()
server.use(
rest.post('/api/auth/login', (req, res, ctx) => {
return res(ctx.status(401), ctx.json({ message: 'Invalid credentials' }))
})
)
renderWithAuthProvider(<TestComponent />)
await user.click(screen.getByText('Login'))
await waitFor(() => {
expect(screen.getByTestId('auth-status')).toHaveTextContent('Error: Login failed')
})
})
it('handles logout', async () => {
const user = userEvent.setup()
// Set up successful login first
server.use(
rest.post('/api/auth/login', (req, res, ctx) => {
return res(
ctx.json({
user: { id: '1', name: 'Test User', email: 'test@example.com' },
})
)
})
)
renderWithAuthProvider(<TestComponent />)
// Login first
await user.click(screen.getByText('Login'))
await waitFor(() => {
expect(screen.getByTestId('auth-status')).toHaveTextContent('Logged in as Test User')
})
// Then logout
await user.click(screen.getByText('Logout'))
expect(screen.getByTestId('auth-status')).toHaveTextContent('Not logged in')
})
it('throws error when useAuth is used outside provider', () => {
// Suppress console.error for this test
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
const TestComponentWithoutProvider = () => {
useAuth() // This should throw
return <div>Should not render</div>
}
expect(() => {
render(<TestComponentWithoutProvider />)
}).toThrow('useAuth must be used within AuthProvider')
consoleSpy.mockRestore()
})
})
๐ Testing Metrics and Coverage
Coverage Configuration
// jest.config.js - Enhanced coverage settings
module.exports = {
// ... other config
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/index.js',
'!src/reportWebVitals.js',
'!src/**/*.stories.{js,jsx,ts,tsx}',
'!src/**/*.test.{js,jsx,ts,tsx}',
'!src/mocks/**',
'!src/test-utils.js',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
// Stricter requirements for critical paths
'./src/services/': {
branches: 90,
functions: 90,
lines: 90,
statements: 90,
},
'./src/components/': {
branches: 85,
functions: 85,
lines: 85,
statements: 85,
},
},
coverageReporters: ['text', 'lcov', 'html', 'json-summary'],
}
Test Organization
src/
โโโ components/
โ โโโ Button/
โ โ โโโ Button.jsx
โ โ โโโ Button.test.jsx
โ โ โโโ Button.stories.jsx
โ โโโ LoginForm/
โ โโโ LoginForm.jsx
โ โโโ LoginForm.test.jsx
โ โโโ LoginForm.stories.jsx
โโโ hooks/
โ โโโ useApi.js
โ โโโ useApi.test.js
โโโ services/
โ โโโ api.js
โ โโโ userService.js
โ โโโ __tests__/
โ โโโ api.test.js
โ โโโ userService.test.js
โโโ mocks/
โ โโโ handlers.js
โ โโโ server.js
โโโ test-utils.js
โโโ setupTests.js
๐ Best Practices and Guidelines
Testing Philosophy
โ DO:
- Test behavior, not implementation
- Write tests before fixing bugs
- Keep tests simple and focused
- Use descriptive test names
- Mock external dependencies
- Test edge cases and error scenarios
โ DON'T:
- Test internal component state directly
- Mock everything (over-mocking)
- Write tests that depend on other tests
- Test third-party libraries
- Ignore failing tests
- Write overly complex test setups
Naming Conventions
// โ
Good: Descriptive test names
describe('LoginForm', () => {
it('shows validation error when email is empty', () => {})
it('calls onSubmit with form data when validation passes', () => {})
it('disables submit button when loading', () => {})
})
// โ Bad: Vague test names
describe('LoginForm', () => {
it('works correctly', () => {})
it('handles error', () => {})
it('tests form', () => {})
})
Effective Assertions
// โ
Good: Specific assertions
expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled()
expect(mockOnSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
})
// โ Bad: Generic assertions
expect(component).toBeTruthy()
expect(mockFunction).toHaveBeenCalled() // Missing specific call verification
Test Data Management
// test-data.js
export const testData = {
users: {
admin: {
id: '1',
name: 'Admin User',
email: 'admin@example.com',
role: 'admin',
},
regularUser: {
id: '2',
name: 'Regular User',
email: 'user@example.com',
role: 'user',
},
},
products: {
electronics: [
{ id: '1', name: 'Laptop', category: 'electronics', price: 999.99 },
{ id: '2', name: 'Phone', category: 'electronics', price: 599.99 },
],
},
}
// Factory functions for dynamic test data
export const createUser = (overrides = {}) => ({
id: Math.random().toString(),
name: 'Test User',
email: 'test@example.com',
role: 'user',
...overrides,
})
๐ Continuous Integration
GitHub Actions Workflow
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x, 18.x]
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run type check
run: npm run type-check
- name: Run tests
run: npm run test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage/lcov.info
- name: Comment coverage on PR
if: github.event_name == 'pull_request'
uses: romeovs/lcov-reporter-action@v0.3.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
lcov-file: ./coverage/lcov.info
Package.json Scripts
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:debug": "node --inspect-brk scripts/test.js --runInBand --no-cache",
"test:ci": "jest --coverage --ci --reporters=default --reporters=jest-junit",
"test:update-snapshots": "jest --updateSnapshot"
}
}
Conclusion
Frontend unit testing is essential for building reliable, maintainable applications. With the right tools and strategies, you can create a comprehensive testing suite that:
- Catches bugs early before they reach production
- Provides confidence for refactoring and new features
- Documents expected behavior for other developers
- Enables faster development through rapid feedback
Key Takeaways
- Start with the testing pyramid - Many unit tests, some integration tests, few E2E tests
- Test behavior, not implementation - Focus on what users experience
- Use MSW for API mocking - Realistic API testing without backend dependency
- Organize tests logically - Keep tests close to the code they test
- Maintain good coverage - Aim for 80%+ coverage on critical paths
- Automate everything - Run tests on every commit and PR
Remember: Good tests are an investment in your codebase's future. They pay dividends in reduced bugs, faster development, and team confidence.
Happy testing! ๐งชโจ