Prerequisites
- Understanding of React fundamentals and TypeScript π
- Knowledge of Jest testing framework and unit testing β‘
- Familiarity with React hooks and component patterns π»
What you'll learn
- Test React components with comprehensive coverage using React Testing Library π―
- Master testing user interactions, events, and component state ποΈ
- Handle async operations, hooks, and context in component tests π
- Create maintainable test suites for complex React applications β¨
π― Introduction
Welcome to the React component testing laboratory! βοΈ If testing regular functions were like checking individual gears in a machine, then testing React components would be like testing entire interactive user interfaces - complete with user inputs, state changes, visual feedback, and complex user workflows that need to work seamlessly together to create amazing user experiences!
React component testing with TypeScript combines the power of type safety with comprehensive user interaction testing. React Testing Library encourages testing components the way users actually interact with them, focusing on behavior rather than implementation details. This approach leads to more robust tests that give you confidence your components work correctly for real users.
By the end of this tutorial, youβll be a master of React component testing, capable of thoroughly testing everything from simple presentational components to complex interactive widgets with state management, async operations, and user interactions. Youβll learn to test like a user, ensuring your components provide excellent user experiences. Letβs build some bulletproof React component tests! π
π Understanding React Testing Library Fundamentals
π€ Why React Testing Library?
React Testing Library focuses on testing components from the userβs perspective, encouraging better testing practices and more maintainable tests.
// π Setting up comprehensive React Testing Library environment
import React, { useState, useEffect, useCallback, useMemo, useContext, createContext } from 'react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
// Basic component interfaces and types
interface User {
id: string;
name: string;
email: string;
avatar?: string;
isActive: boolean;
role: 'admin' | 'user' | 'moderator';
}
interface Product {
id: string;
name: string;
price: number;
description: string;
category: string;
inStock: boolean;
imageUrl?: string;
}
interface CartItem {
product: Product;
quantity: number;
}
// Context for state management
interface AppContextType {
user: User | null;
cart: CartItem[];
addToCart: (product: Product, quantity: number) => void;
removeFromCart: (productId: string) => void;
updateCartQuantity: (productId: string, quantity: number) => void;
login: (user: User) => void;
logout: () => void;
}
const AppContext = createContext<AppContextType | undefined>(undefined);
const useAppContext = (): AppContextType => {
const context = useContext(AppContext);
if (!context) {
throw new Error('useAppContext must be used within AppProvider');
}
return context;
};
// Basic presentational component
interface ButtonProps {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
variant?: 'primary' | 'secondary' | 'danger';
size?: 'small' | 'medium' | 'large';
type?: 'button' | 'submit' | 'reset';
'data-testid'?: string;
}
const Button: React.FC<ButtonProps> = ({
children,
onClick,
disabled = false,
variant = 'primary',
size = 'medium',
type = 'button',
'data-testid': testId
}) => {
const baseClasses = 'btn';
const variantClasses = {
primary: 'btn-primary',
secondary: 'btn-secondary',
danger: 'btn-danger'
};
const sizeClasses = {
small: 'btn-sm',
medium: 'btn-md',
large: 'btn-lg'
};
const className = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]}`;
return (
<button
type={type}
className={className}
onClick={onClick}
disabled={disabled}
data-testid={testId}
>
{children}
</button>
);
};
// Input component with validation
interface InputProps {
label: string;
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
error?: string;
type?: 'text' | 'email' | 'password' | 'number';
placeholder?: string;
required?: boolean;
disabled?: boolean;
'data-testid'?: string;
}
const Input: React.FC<InputProps> = ({
label,
value,
onChange,
onBlur,
error,
type = 'text',
placeholder,
required = false,
disabled = false,
'data-testid': testId
}) => {
const inputId = `input-${label.toLowerCase().replace(/\s+/g, '-')}`;
return (
<div className="input-group">
<label htmlFor={inputId} className="input-label">
{label}
{required && <span className="required">*</span>}
</label>
<input
id={inputId}
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
placeholder={placeholder}
required={required}
disabled={disabled}
className={`input ${error ? 'input-error' : ''}`}
data-testid={testId}
aria-describedby={error ? `${inputId}-error` : undefined}
aria-invalid={!!error}
/>
{error && (
<span id={`${inputId}-error`} className="error-message" role="alert">
{error}
</span>
)}
</div>
);
};
// User card component
interface UserCardProps {
user: User;
onEdit?: (user: User) => void;
onDelete?: (userId: string) => void;
onToggleActive?: (userId: string) => void;
showActions?: boolean;
}
const UserCard: React.FC<UserCardProps> = ({
user,
onEdit,
onDelete,
onToggleActive,
showActions = true
}) => {
const handleEdit = useCallback(() => {
onEdit?.(user);
}, [onEdit, user]);
const handleDelete = useCallback(() => {
if (window.confirm(`Are you sure you want to delete ${user.name}?`)) {
onDelete?.(user.id);
}
}, [onDelete, user.id, user.name]);
const handleToggleActive = useCallback(() => {
onToggleActive?.(user.id);
}, [onToggleActive, user.id]);
return (
<div className={`user-card ${user.isActive ? 'active' : 'inactive'}`} data-testid="user-card">
<div className="user-avatar">
{user.avatar ? (
<img src={user.avatar} alt={`${user.name}'s avatar`} />
) : (
<div className="avatar-placeholder">{user.name.charAt(0).toUpperCase()}</div>
)}
</div>
<div className="user-info">
<h3 className="user-name">{user.name}</h3>
<p className="user-email">{user.email}</p>
<span className={`user-role role-${user.role}`}>{user.role}</span>
<span className={`user-status ${user.isActive ? 'active' : 'inactive'}`}>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</div>
{showActions && (
<div className="user-actions">
{onEdit && (
<Button variant="secondary" size="small" onClick={handleEdit} data-testid="edit-button">
Edit
</Button>
)}
{onToggleActive && (
<Button
variant="secondary"
size="small"
onClick={handleToggleActive}
data-testid="toggle-active-button"
>
{user.isActive ? 'Deactivate' : 'Activate'}
</Button>
)}
{onDelete && (
<Button variant="danger" size="small" onClick={handleDelete} data-testid="delete-button">
Delete
</Button>
)}
</div>
)}
</div>
);
};
// Product card with add to cart functionality
interface ProductCardProps {
product: Product;
onAddToCart?: (product: Product, quantity: number) => void;
showAddToCart?: boolean;
}
const ProductCard: React.FC<ProductCardProps> = ({
product,
onAddToCart,
showAddToCart = true
}) => {
const [quantity, setQuantity] = useState(1);
const [isAdding, setIsAdding] = useState(false);
const handleAddToCart = useCallback(async () => {
if (!onAddToCart || !product.inStock) return;
setIsAdding(true);
try {
// Simulate async operation
await new Promise(resolve => setTimeout(resolve, 500));
onAddToCart(product, quantity);
setQuantity(1); // Reset quantity after adding
} finally {
setIsAdding(false);
}
}, [onAddToCart, product, quantity]);
const incrementQuantity = useCallback(() => {
setQuantity(prev => prev + 1);
}, []);
const decrementQuantity = useCallback(() => {
setQuantity(prev => Math.max(1, prev - 1));
}, []);
return (
<div className={`product-card ${!product.inStock ? 'out-of-stock' : ''}`} data-testid="product-card">
{product.imageUrl && (
<img src={product.imageUrl} alt={product.name} className="product-image" />
)}
<div className="product-info">
<h3 className="product-name">{product.name}</h3>
<p className="product-description">{product.description}</p>
<span className="product-category">{product.category}</span>
<span className="product-price">${product.price.toFixed(2)}</span>
{!product.inStock && (
<span className="out-of-stock-badge">Out of Stock</span>
)}
</div>
{showAddToCart && product.inStock && (
<div className="add-to-cart-section">
<div className="quantity-controls">
<Button
variant="secondary"
size="small"
onClick={decrementQuantity}
disabled={quantity <= 1}
data-testid="decrement-quantity"
>
-
</Button>
<span className="quantity-display" data-testid="quantity-display">
{quantity}
</span>
<Button
variant="secondary"
size="small"
onClick={incrementQuantity}
data-testid="increment-quantity"
>
+
</Button>
</div>
<Button
variant="primary"
onClick={handleAddToCart}
disabled={isAdding}
data-testid="add-to-cart-button"
>
{isAdding ? 'Adding...' : 'Add to Cart'}
</Button>
</div>
)}
</div>
);
};
// Form component with validation
interface ContactFormData {
name: string;
email: string;
message: string;
}
interface ContactFormProps {
onSubmit: (data: ContactFormData) => Promise<void>;
initialData?: Partial<ContactFormData>;
}
const ContactForm: React.FC<ContactFormProps> = ({ onSubmit, initialData = {} }) => {
const [formData, setFormData] = useState<ContactFormData>({
name: initialData.name || '',
email: initialData.email || '',
message: initialData.message || ''
});
const [errors, setErrors] = useState<Partial<ContactFormData>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitSuccess, setSubmitSuccess] = useState(false);
const validateForm = useCallback((): boolean => {
const newErrors: Partial<ContactFormData> = {};
if (!formData.name.trim()) {
newErrors.name = 'Name is required';
}
if (!formData.email.trim()) {
newErrors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = 'Email is invalid';
}
if (!formData.message.trim()) {
newErrors.message = 'Message is required';
} else if (formData.message.length < 10) {
newErrors.message = 'Message must be at least 10 characters';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}, [formData]);
const handleInputChange = useCallback((field: keyof ContactFormData, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: undefined }));
}
}, [errors]);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setIsSubmitting(true);
try {
await onSubmit(formData);
setSubmitSuccess(true);
setFormData({ name: '', email: '', message: '' }); // Reset form
} catch (error) {
console.error('Form submission error:', error);
} finally {
setIsSubmitting(false);
}
}, [formData, onSubmit, validateForm]);
if (submitSuccess) {
return (
<div className="success-message" data-testid="success-message">
<h3>Thank you!</h3>
<p>Your message has been sent successfully.</p>
<Button onClick={() => setSubmitSuccess(false)}>Send Another Message</Button>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="contact-form" data-testid="contact-form">
<Input
label="Name"
value={formData.name}
onChange={(value) => handleInputChange('name', value)}
error={errors.name}
required
data-testid="name-input"
/>
<Input
label="Email"
type="email"
value={formData.email}
onChange={(value) => handleInputChange('email', value)}
error={errors.email}
required
data-testid="email-input"
/>
<div className="input-group">
<label htmlFor="message" className="input-label">
Message<span className="required">*</span>
</label>
<textarea
id="message"
value={formData.message}
onChange={(e) => handleInputChange('message', e.target.value)}
className={`textarea ${errors.message ? 'input-error' : ''}`}
rows={4}
data-testid="message-textarea"
aria-describedby={errors.message ? 'message-error' : undefined}
aria-invalid={!!errors.message}
/>
{errors.message && (
<span id="message-error" className="error-message" role="alert">
{errors.message}
</span>
)}
</div>
<Button
type="submit"
variant="primary"
disabled={isSubmitting}
data-testid="submit-button"
>
{isSubmitting ? 'Sending...' : 'Send Message'}
</Button>
</form>
);
};
// Search component with debounced input
interface SearchProps {
onSearch: (query: string) => void;
placeholder?: string;
debounceMs?: number;
}
const Search: React.FC<SearchProps> = ({
onSearch,
placeholder = 'Search...',
debounceMs = 300
}) => {
const [query, setQuery] = useState('');
const debouncedOnSearch = useMemo(
() => {
let timeoutId: NodeJS.Timeout;
return (searchQuery: string) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
onSearch(searchQuery);
}, debounceMs);
};
},
[onSearch, debounceMs]
);
useEffect(() => {
debouncedOnSearch(query);
}, [query, debouncedOnSearch]);
return (
<div className="search-container">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className="search-input"
data-testid="search-input"
/>
{query && (
<button
onClick={() => setQuery('')}
className="clear-search"
data-testid="clear-search"
aria-label="Clear search"
>
Γ
</button>
)}
</div>
);
};
β Testing Basic Component Functionality
π§ͺ Rendering and Props Testing
The foundation of component testing is verifying that components render correctly with different props.
// π Comprehensive basic component testing
describe('Button Component', () => {
// β
Testing basic rendering
it('should render 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-md');
expect(button).toHaveAttribute('type', 'button');
expect(button).not.toBeDisabled();
});
it('should render with custom props', () => {
render(
<Button
variant="danger"
size="large"
type="submit"
disabled
data-testid="custom-button"
>
Submit Form
</Button>
);
const button = screen.getByTestId('custom-button');
expect(button).toBeInTheDocument();
expect(button).toHaveClass('btn', 'btn-danger', 'btn-lg');
expect(button).toHaveAttribute('type', 'submit');
expect(button).toBeDisabled();
expect(button).toHaveTextContent('Submit Form');
});
it('should render all variant styles correctly', () => {
const variants: Array<ButtonProps['variant']> = ['primary', 'secondary', 'danger'];
variants.forEach(variant => {
const { unmount } = render(<Button variant={variant}>Test</Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass(`btn-${variant}`);
unmount();
});
});
it('should render all sizes correctly', () => {
const sizes: Array<ButtonProps['size']> = ['small', 'medium', 'large'];
sizes.forEach(size => {
const { unmount } = render(<Button size={size}>Test</Button>);
const button = screen.getByRole('button');
const sizeClass = size === 'small' ? 'btn-sm' : size === 'large' ? 'btn-lg' : 'btn-md';
expect(button).toHaveClass(sizeClass);
unmount();
});
});
// β
Testing click events
it('should call onClick when clicked', async () => {
const user = userEvent.setup();
const mockOnClick = jest.fn();
render(<Button onClick={mockOnClick}>Click me</Button>);
const button = screen.getByRole('button');
await user.click(button);
expect(mockOnClick).toHaveBeenCalledTimes(1);
});
it('should not call onClick when disabled', async () => {
const user = userEvent.setup();
const mockOnClick = jest.fn();
render(<Button onClick={mockOnClick} disabled>Click me</Button>);
const button = screen.getByRole('button');
await user.click(button);
expect(mockOnClick).not.toHaveBeenCalled();
});
it('should handle multiple clicks', async () => {
const user = userEvent.setup();
const mockOnClick = jest.fn();
render(<Button onClick={mockOnClick}>Click me</Button>);
const button = screen.getByRole('button');
await user.click(button);
await user.click(button);
await user.click(button);
expect(mockOnClick).toHaveBeenCalledTimes(3);
});
// β
Testing accessibility
it('should be accessible', () => {
render(<Button>Accessible Button</Button>);
const button = screen.getByRole('button', { name: /accessible button/i });
expect(button).toBeInTheDocument();
// Button should be keyboard accessible
expect(button).toHaveAttribute('type', 'button');
});
it('should work with keyboard navigation', async () => {
const user = userEvent.setup();
const mockOnClick = jest.fn();
render(<Button onClick={mockOnClick}>Keyboard Test</Button>);
const button = screen.getByRole('button');
button.focus();
expect(button).toHaveFocus();
// Press Enter
await user.keyboard('{Enter}');
expect(mockOnClick).toHaveBeenCalledTimes(1);
// Press Space
await user.keyboard(' ');
expect(mockOnClick).toHaveBeenCalledTimes(2);
});
});
describe('Input Component', () => {
// β
Testing basic input functionality
it('should render with label and handle changes', async () => {
const user = userEvent.setup();
const mockOnChange = jest.fn();
render(
<Input
label="Test Input"
value=""
onChange={mockOnChange}
data-testid="test-input"
/>
);
const input = screen.getByTestId('test-input');
const label = screen.getByText('Test Input');
expect(input).toBeInTheDocument();
expect(label).toBeInTheDocument();
expect(input).toHaveAttribute('id', 'input-test-input');
expect(label).toHaveAttribute('for', 'input-test-input');
await user.type(input, 'Hello World');
expect(mockOnChange).toHaveBeenCalledTimes(11); // One call per character
expect(mockOnChange).toHaveBeenLastCalledWith('Hello World');
});
it('should display error message', () => {
render(
<Input
label="Email"
value="invalid-email"
onChange={() => {}}
error="Please enter a valid email"
/>
);
const input = screen.getByRole('textbox', { name: /email/i });
const errorMessage = screen.getByRole('alert');
expect(input).toHaveClass('input-error');
expect(input).toHaveAttribute('aria-invalid', 'true');
expect(input).toHaveAttribute('aria-describedby', 'input-email-error');
expect(errorMessage).toHaveTextContent('Please enter a valid email');
});
it('should handle different input types', () => {
const types: Array<InputProps['type']> = ['text', 'email', 'password', 'number'];
types.forEach(type => {
const { unmount } = render(
<Input label={`${type} input`} value="" onChange={() => {}} type={type} />
);
const input = screen.getByRole(type === 'password' ? 'textbox' : 'textbox');
expect(input).toHaveAttribute('type', type);
unmount();
});
});
it('should show required indicator', () => {
render(
<Input
label="Required Field"
value=""
onChange={() => {}}
required
/>
);
const requiredIndicator = screen.getByText('*');
expect(requiredIndicator).toBeInTheDocument();
expect(requiredIndicator).toHaveClass('required');
});
it('should handle blur events', async () => {
const user = userEvent.setup();
const mockOnBlur = jest.fn();
render(
<Input
label="Blur Test"
value=""
onChange={() => {}}
onBlur={mockOnBlur}
/>
);
const input = screen.getByRole('textbox');
await user.click(input);
await user.tab(); // This will trigger blur
expect(mockOnBlur).toHaveBeenCalledTimes(1);
});
it('should be disabled when disabled prop is true', () => {
render(
<Input
label="Disabled Input"
value="Cannot edit"
onChange={() => {}}
disabled
/>
);
const input = screen.getByRole('textbox');
expect(input).toBeDisabled();
});
});
describe('UserCard Component', () => {
const mockUser: User = {
id: 'user-123',
name: 'John Doe',
email: '[email protected]',
isActive: true,
role: 'admin'
};
// β
Testing component with complex props
it('should render user information correctly', () => {
render(<UserCard user={mockUser} />);
const userCard = screen.getByTestId('user-card');
expect(userCard).toHaveClass('user-card', 'active');
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('[email protected]')).toBeInTheDocument();
expect(screen.getByText('admin')).toBeInTheDocument();
expect(screen.getByText('Active')).toBeInTheDocument();
// Check avatar placeholder
const avatarPlaceholder = screen.getByText('J');
expect(avatarPlaceholder).toBeInTheDocument();
});
it('should render user with avatar image', () => {
const userWithAvatar = { ...mockUser, avatar: 'https://example.com/avatar.jpg' };
render(<UserCard user={userWithAvatar} />);
const avatarImage = screen.getByAltText("John Doe's avatar");
expect(avatarImage).toBeInTheDocument();
expect(avatarImage).toHaveAttribute('src', 'https://example.com/avatar.jpg');
});
it('should render inactive user correctly', () => {
const inactiveUser = { ...mockUser, isActive: false };
render(<UserCard user={inactiveUser} />);
const userCard = screen.getByTestId('user-card');
expect(userCard).toHaveClass('user-card', 'inactive');
expect(screen.getByText('Inactive')).toBeInTheDocument();
});
it('should hide actions when showActions is false', () => {
render(<UserCard user={mockUser} showActions={false} />);
expect(screen.queryByTestId('edit-button')).not.toBeInTheDocument();
expect(screen.queryByTestId('delete-button')).not.toBeInTheDocument();
expect(screen.queryByTestId('toggle-active-button')).not.toBeInTheDocument();
});
it('should call onEdit when edit button is clicked', async () => {
const user = userEvent.setup();
const mockOnEdit = jest.fn();
render(<UserCard user={mockUser} onEdit={mockOnEdit} />);
const editButton = screen.getByTestId('edit-button');
await user.click(editButton);
expect(mockOnEdit).toHaveBeenCalledTimes(1);
expect(mockOnEdit).toHaveBeenCalledWith(mockUser);
});
it('should call onToggleActive when toggle button is clicked', async () => {
const user = userEvent.setup();
const mockOnToggleActive = jest.fn();
render(<UserCard user={mockUser} onToggleActive={mockOnToggleActive} />);
const toggleButton = screen.getByTestId('toggle-active-button');
expect(toggleButton).toHaveTextContent('Deactivate');
await user.click(toggleButton);
expect(mockOnToggleActive).toHaveBeenCalledTimes(1);
expect(mockOnToggleActive).toHaveBeenCalledWith('user-123');
});
it('should show confirm dialog before delete', async () => {
const user = userEvent.setup();
const mockOnDelete = jest.fn();
// Mock window.confirm
const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true);
render(<UserCard user={mockUser} onDelete={mockOnDelete} />);
const deleteButton = screen.getByTestId('delete-button');
await user.click(deleteButton);
expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to delete John Doe?');
expect(mockOnDelete).toHaveBeenCalledTimes(1);
expect(mockOnDelete).toHaveBeenCalledWith('user-123');
confirmSpy.mockRestore();
});
it('should not delete when user cancels confirm dialog', async () => {
const user = userEvent.setup();
const mockOnDelete = jest.fn();
// Mock window.confirm to return false
const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(false);
render(<UserCard user={mockUser} onDelete={mockOnDelete} />);
const deleteButton = screen.getByTestId('delete-button');
await user.click(deleteButton);
expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to delete John Doe?');
expect(mockOnDelete).not.toHaveBeenCalled();
confirmSpy.mockRestore();
});
});
π Testing User Interactions and Events
π±οΈ Advanced User Event Testing
Testing user interactions requires simulating real user behavior with mouse, keyboard, and form interactions.
// π Comprehensive user interaction testing
describe('ProductCard Component - User Interactions', () => {
const mockProduct: Product = {
id: 'prod-123',
name: 'Awesome Widget',
price: 29.99,
description: 'A really awesome widget for your needs',
category: 'Widgets',
inStock: true,
imageUrl: 'https://example.com/widget.jpg'
};
// β
Testing quantity controls
it('should handle quantity increment and decrement', async () => {
const user = userEvent.setup();
const mockOnAddToCart = jest.fn();
render(<ProductCard product={mockProduct} onAddToCart={mockOnAddToCart} />);
const quantityDisplay = screen.getByTestId('quantity-display');
const incrementButton = screen.getByTestId('increment-quantity');
const decrementButton = screen.getByTestId('decrement-quantity');
// Initial state
expect(quantityDisplay).toHaveTextContent('1');
expect(decrementButton).toBeDisabled();
// Increment quantity
await user.click(incrementButton);
expect(quantityDisplay).toHaveTextContent('2');
expect(decrementButton).not.toBeDisabled();
// Increment again
await user.click(incrementButton);
expect(quantityDisplay).toHaveTextContent('3');
// Decrement quantity
await user.click(decrementButton);
expect(quantityDisplay).toHaveTextContent('2');
// Decrement to minimum
await user.click(decrementButton);
expect(quantityDisplay).toHaveTextContent('1');
expect(decrementButton).toBeDisabled();
});
it('should add product to cart with correct quantity', async () => {
const user = userEvent.setup();
const mockOnAddToCart = jest.fn().mockResolvedValue(undefined);
render(<ProductCard product={mockProduct} onAddToCart={mockOnAddToCart} />);
// Increase quantity to 3
const incrementButton = screen.getByTestId('increment-quantity');
await user.click(incrementButton);
await user.click(incrementButton);
const addToCartButton = screen.getByTestId('add-to-cart-button');
await user.click(addToCartButton);
expect(mockOnAddToCart).toHaveBeenCalledTimes(1);
expect(mockOnAddToCart).toHaveBeenCalledWith(mockProduct, 3);
});
it('should show loading state during add to cart', async () => {
const user = userEvent.setup();
const mockOnAddToCart = jest.fn(() => new Promise(resolve => setTimeout(resolve, 1000)));
render(<ProductCard product={mockProduct} onAddToCart={mockOnAddToCart} />);
const addToCartButton = screen.getByTestId('add-to-cart-button');
// Initial state
expect(addToCartButton).toHaveTextContent('Add to Cart');
expect(addToCartButton).not.toBeDisabled();
// Click button
await user.click(addToCartButton);
// Should show loading state
expect(addToCartButton).toHaveTextContent('Adding...');
expect(addToCartButton).toBeDisabled();
// Wait for async operation to complete
await waitFor(() => {
expect(addToCartButton).toHaveTextContent('Add to Cart');
expect(addToCartButton).not.toBeDisabled();
});
});
it('should reset quantity after successful add to cart', async () => {
const user = userEvent.setup();
const mockOnAddToCart = jest.fn().mockResolvedValue(undefined);
render(<ProductCard product={mockProduct} onAddToCart={mockOnAddToCart} />);
// Set quantity to 5
const incrementButton = screen.getByTestId('increment-quantity');
for (let i = 0; i < 4; i++) {
await user.click(incrementButton);
}
const quantityDisplay = screen.getByTestId('quantity-display');
expect(quantityDisplay).toHaveTextContent('5');
// Add to cart
const addToCartButton = screen.getByTestId('add-to-cart-button');
await user.click(addToCartButton);
// Wait for reset
await waitFor(() => {
expect(quantityDisplay).toHaveTextContent('1');
});
});
it('should hide add to cart section for out of stock products', () => {
const outOfStockProduct = { ...mockProduct, inStock: false };
render(<ProductCard product={outOfStockProduct} />);
expect(screen.getByText('Out of Stock')).toBeInTheDocument();
expect(screen.queryByTestId('add-to-cart-button')).not.toBeInTheDocument();
expect(screen.queryByTestId('quantity-display')).not.toBeInTheDocument();
});
it('should not show add to cart when showAddToCart is false', () => {
render(<ProductCard product={mockProduct} showAddToCart={false} />);
expect(screen.queryByTestId('add-to-cart-button')).not.toBeInTheDocument();
expect(screen.queryByTestId('quantity-display')).not.toBeInTheDocument();
});
});
describe('ContactForm Component - Form Interactions', () => {
const mockOnSubmit = jest.fn();
beforeEach(() => {
mockOnSubmit.mockClear();
});
// β
Testing form validation
it('should validate required fields', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={mockOnSubmit} />);
const submitButton = screen.getByTestId('submit-button');
await user.click(submitButton);
// Should show validation errors
expect(screen.getByText('Name is required')).toBeInTheDocument();
expect(screen.getByText('Email is required')).toBeInTheDocument();
expect(screen.getByText('Message is required')).toBeInTheDocument();
// Form should not be submitted
expect(mockOnSubmit).not.toHaveBeenCalled();
});
it('should validate email format', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={mockOnSubmit} />);
const emailInput = screen.getByTestId('email-input');
// Enter invalid email
await user.type(emailInput, 'invalid-email');
const submitButton = screen.getByTestId('submit-button');
await user.click(submitButton);
expect(screen.getByText('Email is invalid')).toBeInTheDocument();
expect(mockOnSubmit).not.toHaveBeenCalled();
});
it('should validate message length', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={mockOnSubmit} />);
const messageTextarea = screen.getByTestId('message-textarea');
// Enter short message
await user.type(messageTextarea, 'Short');
const submitButton = screen.getByTestId('submit-button');
await user.click(submitButton);
expect(screen.getByText('Message must be at least 10 characters')).toBeInTheDocument();
expect(mockOnSubmit).not.toHaveBeenCalled();
});
it('should clear errors when user starts typing', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={mockOnSubmit} />);
// Submit empty form to trigger errors
const submitButton = screen.getByTestId('submit-button');
await user.click(submitButton);
expect(screen.getByText('Name is required')).toBeInTheDocument();
// Start typing in name field
const nameInput = screen.getByTestId('name-input');
await user.type(nameInput, 'J');
// Error should be cleared
expect(screen.queryByText('Name is required')).not.toBeInTheDocument();
});
it('should submit form with valid data', async () => {
const user = userEvent.setup();
mockOnSubmit.mockResolvedValue(undefined);
render(<ContactForm onSubmit={mockOnSubmit} />);
// Fill out form
const nameInput = screen.getByTestId('name-input');
const emailInput = screen.getByTestId('email-input');
const messageTextarea = screen.getByTestId('message-textarea');
await user.type(nameInput, 'John Doe');
await user.type(emailInput, '[email protected]');
await user.type(messageTextarea, 'This is a test message that is long enough.');
const submitButton = screen.getByTestId('submit-button');
await user.click(submitButton);
expect(mockOnSubmit).toHaveBeenCalledTimes(1);
expect(mockOnSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: '[email protected]',
message: 'This is a test message that is long enough.'
});
});
it('should show loading state during submission', async () => {
const user = userEvent.setup();
mockOnSubmit.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1000)));
render(<ContactForm onSubmit={mockOnSubmit} />);
// Fill out form
await user.type(screen.getByTestId('name-input'), 'John Doe');
await user.type(screen.getByTestId('email-input'), '[email protected]');
await user.type(screen.getByTestId('message-textarea'), 'This is a test message.');
const submitButton = screen.getByTestId('submit-button');
// Initial state
expect(submitButton).toHaveTextContent('Send Message');
expect(submitButton).not.toBeDisabled();
// Submit form
await user.click(submitButton);
// Should show loading state
expect(submitButton).toHaveTextContent('Sending...');
expect(submitButton).toBeDisabled();
// Wait for submission to complete
await waitFor(() => {
expect(screen.getByTestId('success-message')).toBeInTheDocument();
});
});
it('should show success message and reset form after submission', async () => {
const user = userEvent.setup();
mockOnSubmit.mockResolvedValue(undefined);
render(<ContactForm onSubmit={mockOnSubmit} />);
// Fill and submit form
await user.type(screen.getByTestId('name-input'), 'John Doe');
await user.type(screen.getByTestId('email-input'), '[email protected]');
await user.type(screen.getByTestId('message-textarea'), 'Test message.');
await user.click(screen.getByTestId('submit-button'));
// Should show success message
await waitFor(() => {
expect(screen.getByTestId('success-message')).toBeInTheDocument();
expect(screen.getByText('Thank you!')).toBeInTheDocument();
expect(screen.getByText('Your message has been sent successfully.')).toBeInTheDocument();
});
// Original form should not be visible
expect(screen.queryByTestId('contact-form')).not.toBeInTheDocument();
// Should be able to send another message
const sendAnotherButton = screen.getByText('Send Another Message');
await user.click(sendAnotherButton);
// Form should be visible again with empty fields
expect(screen.getByTestId('contact-form')).toBeInTheDocument();
expect(screen.getByTestId('name-input')).toHaveValue('');
expect(screen.getByTestId('email-input')).toHaveValue('');
expect(screen.getByTestId('message-textarea')).toHaveValue('');
});
it('should populate form with initial data', () => {
const initialData = {
name: 'Jane Doe',
email: '[email protected]',
message: 'Pre-filled message'
};
render(<ContactForm onSubmit={mockOnSubmit} initialData={initialData} />);
expect(screen.getByTestId('name-input')).toHaveValue('Jane Doe');
expect(screen.getByTestId('email-input')).toHaveValue('[email protected]');
expect(screen.getByTestId('message-textarea')).toHaveValue('Pre-filled message');
});
});
describe('Search Component - Debounced Input', () => {
// β
Testing debounced search
it('should debounce search input', async () => {
const user = userEvent.setup();
const mockOnSearch = jest.fn();
render(<Search onSearch={mockOnSearch} debounceMs={300} />);
const searchInput = screen.getByTestId('search-input');
// Type quickly
await user.type(searchInput, 'test query');
// Should not have called onSearch yet
expect(mockOnSearch).not.toHaveBeenCalled();
// Wait for debounce
await waitFor(() => {
expect(mockOnSearch).toHaveBeenCalledTimes(1);
expect(mockOnSearch).toHaveBeenCalledWith('test query');
}, { timeout: 500 });
});
it('should clear search when clear button is clicked', async () => {
const user = userEvent.setup();
const mockOnSearch = jest.fn();
render(<Search onSearch={mockOnSearch} />);
const searchInput = screen.getByTestId('search-input');
// Type something
await user.type(searchInput, 'search term');
// Clear button should appear
const clearButton = screen.getByTestId('clear-search');
expect(clearButton).toBeInTheDocument();
// Click clear button
await user.click(clearButton);
// Input should be cleared
expect(searchInput).toHaveValue('');
// Clear button should disappear
expect(screen.queryByTestId('clear-search')).not.toBeInTheDocument();
// Should trigger search with empty string
await waitFor(() => {
expect(mockOnSearch).toHaveBeenLastCalledWith('');
});
});
it('should use custom placeholder', () => {
render(<Search onSearch={() => {}} placeholder="Search products..." />);
const searchInput = screen.getByTestId('search-input');
expect(searchInput).toHaveAttribute('placeholder', 'Search products...');
});
it('should handle rapid typing correctly', async () => {
const user = userEvent.setup();
const mockOnSearch = jest.fn();
render(<Search onSearch={mockOnSearch} debounceMs={200} />);
const searchInput = screen.getByTestId('search-input');
// Type, then quickly change
await user.type(searchInput, 'first');
await user.clear(searchInput);
await user.type(searchInput, 'second query');
// Wait for debounce
await waitFor(() => {
expect(mockOnSearch).toHaveBeenCalledWith('second query');
}, { timeout: 400 });
// Should only have been called with the final value
expect(mockOnSearch).toHaveBeenCalledTimes(1);
});
});
π Testing State Management and Hooks
π£ Advanced Hook and State Testing
Testing components with complex state management requires careful handling of state updates and side effects.
// π Comprehensive state and hook testing
describe('Components with State Management', () => {
// β
Testing useState behavior
describe('Counter Component with useState', () => {
const Counter: React.FC = () => {
const [count, setCount] = useState(0);
const [step, setStep] = useState(1);
return (
<div data-testid="counter">
<div data-testid="count-display">Count: {count}</div>
<div data-testid="step-display">Step: {step}</div>
<button onClick={() => setCount(c => c + step)} data-testid="increment">
+{step}
</button>
<button onClick={() => setCount(c => c - step)} data-testid="decrement">
-{step}
</button>
<button onClick={() => setCount(0)} data-testid="reset">
Reset
</button>
<input
type="number"
value={step}
onChange={(e) => setStep(Number(e.target.value))}
data-testid="step-input"
/>
</div>
);
};
it('should initialize with default state', () => {
render(<Counter />);
expect(screen.getByTestId('count-display')).toHaveTextContent('Count: 0');
expect(screen.getByTestId('step-display')).toHaveTextContent('Step: 1');
});
it('should increment and decrement by step', async () => {
const user = userEvent.setup();
render(<Counter />);
const incrementButton = screen.getByTestId('increment');
const decrementButton = screen.getByTestId('decrement');
const countDisplay = screen.getByTestId('count-display');
// Increment
await user.click(incrementButton);
expect(countDisplay).toHaveTextContent('Count: 1');
await user.click(incrementButton);
expect(countDisplay).toHaveTextContent('Count: 2');
// Decrement
await user.click(decrementButton);
expect(countDisplay).toHaveTextContent('Count: 1');
});
it('should change step size', async () => {
const user = userEvent.setup();
render(<Counter />);
const stepInput = screen.getByTestId('step-input');
const incrementButton = screen.getByTestId('increment');
const countDisplay = screen.getByTestId('count-display');
// Change step to 5
await user.clear(stepInput);
await user.type(stepInput, '5');
expect(screen.getByTestId('step-display')).toHaveTextContent('Step: 5');
// Increment should now add 5
await user.click(incrementButton);
expect(countDisplay).toHaveTextContent('Count: 5');
});
it('should reset count to zero', async () => {
const user = userEvent.setup();
render(<Counter />);
const incrementButton = screen.getByTestId('increment');
const resetButton = screen.getByTestId('reset');
const countDisplay = screen.getByTestId('count-display');
// Increment a few times
await user.click(incrementButton);
await user.click(incrementButton);
await user.click(incrementButton);
expect(countDisplay).toHaveTextContent('Count: 3');
// Reset
await user.click(resetButton);
expect(countDisplay).toHaveTextContent('Count: 0');
});
});
// β
Testing useEffect behavior
describe('Timer Component with useEffect', () => {
const Timer: React.FC<{ interval?: number; autoStart?: boolean }> = ({
interval = 1000,
autoStart = false
}) => {
const [seconds, setSeconds] = useState(0);
const [isRunning, setIsRunning] = useState(autoStart);
useEffect(() => {
let intervalId: NodeJS.Timeout;
if (isRunning) {
intervalId = setInterval(() => {
setSeconds(s => s + 1);
}, interval);
}
return () => {
if (intervalId) {
clearInterval(intervalId);
}
};
}, [isRunning, interval]);
return (
<div data-testid="timer">
<div data-testid="seconds-display">{seconds}s</div>
<button
onClick={() => setIsRunning(!isRunning)}
data-testid="toggle-timer"
>
{isRunning ? 'Pause' : 'Start'}
</button>
<button
onClick={() => setSeconds(0)}
data-testid="reset-timer"
>
Reset
</button>
</div>
);
};
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should start and stop timer', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(<Timer />);
const toggleButton = screen.getByTestId('toggle-timer');
const secondsDisplay = screen.getByTestId('seconds-display');
// Initially stopped
expect(secondsDisplay).toHaveTextContent('0s');
expect(toggleButton).toHaveTextContent('Start');
// Start timer
await user.click(toggleButton);
expect(toggleButton).toHaveTextContent('Pause');
// Advance time
act(() => {
jest.advanceTimersByTime(3000);
});
expect(secondsDisplay).toHaveTextContent('3s');
// Pause timer
await user.click(toggleButton);
expect(toggleButton).toHaveTextContent('Start');
// Advance time - should not change
act(() => {
jest.advanceTimersByTime(2000);
});
expect(secondsDisplay).toHaveTextContent('3s');
});
it('should auto-start when autoStart is true', () => {
render(<Timer autoStart />);
const toggleButton = screen.getByTestId('toggle-timer');
expect(toggleButton).toHaveTextContent('Pause');
// Advance time
act(() => {
jest.advanceTimersByTime(2000);
});
expect(screen.getByTestId('seconds-display')).toHaveTextContent('2s');
});
it('should reset timer', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(<Timer autoStart />);
// Let timer run
act(() => {
jest.advanceTimersByTime(5000);
});
expect(screen.getByTestId('seconds-display')).toHaveTextContent('5s');
// Reset
const resetButton = screen.getByTestId('reset-timer');
await user.click(resetButton);
expect(screen.getByTestId('seconds-display')).toHaveTextContent('0s');
});
it('should use custom interval', () => {
render(<Timer interval={500} autoStart />);
// Advance by 1 second (2 intervals of 500ms)
act(() => {
jest.advanceTimersByTime(1000);
});
expect(screen.getByTestId('seconds-display')).toHaveTextContent('2s');
});
});
// β
Testing useCallback and useMemo
describe('Optimized Component with useCallback and useMemo', () => {
interface TodoItem {
id: string;
text: string;
completed: boolean;
}
const TodoList: React.FC = () => {
const [todos, setTodos] = useState<TodoItem[]>([]);
const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all');
const [renderCount, setRenderCount] = useState(0);
// Increment render count on every render
useEffect(() => {
setRenderCount(c => c + 1);
});
const addTodo = useCallback((text: string) => {
const newTodo: TodoItem = {
id: Date.now().toString(),
text,
completed: false
};
setTodos(todos => [...todos, newTodo]);
}, []);
const toggleTodo = useCallback((id: string) => {
setTodos(todos =>
todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}, []);
const filteredTodos = useMemo(() => {
return todos.filter(todo => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});
}, [todos, filter]);
const todoStats = useMemo(() => {
const total = todos.length;
const completed = todos.filter(t => t.completed).length;
const active = total - completed;
return { total, completed, active };
}, [todos]);
return (
<div data-testid="todo-list">
<div data-testid="render-count">Renders: {renderCount}</div>
<div data-testid="stats">
Total: {todoStats.total}, Active: {todoStats.active}, Completed: {todoStats.completed}
</div>
<div>
<button onClick={() => addTodo('New Todo')} data-testid="add-todo">
Add Todo
</button>
</div>
<div>
<button
onClick={() => setFilter('all')}
data-active={filter === 'all'}
data-testid="filter-all"
>
All
</button>
<button
onClick={() => setFilter('active')}
data-active={filter === 'active'}
data-testid="filter-active"
>
Active
</button>
<button
onClick={() => setFilter('completed')}
data-active={filter === 'completed'}
data-testid="filter-completed"
>
Completed
</button>
</div>
<div data-testid="filtered-todos">
{filteredTodos.map(todo => (
<div key={todo.id} data-testid={`todo-${todo.id}`}>
<span>{todo.text}</span>
<button
onClick={() => toggleTodo(todo.id)}
data-testid={`toggle-${todo.id}`}
>
{todo.completed ? 'Undo' : 'Complete'}
</button>
</div>
))}
</div>
</div>
);
};
it('should add and display todos', async () => {
const user = userEvent.setup();
render(<TodoList />);
const addButton = screen.getByTestId('add-todo');
const stats = screen.getByTestId('stats');
// Initial state
expect(stats).toHaveTextContent('Total: 0, Active: 0, Completed: 0');
// Add todos
await user.click(addButton);
await user.click(addButton);
await user.click(addButton);
expect(stats).toHaveTextContent('Total: 3, Active: 3, Completed: 0');
// Check todos are displayed
expect(screen.getAllByText('New Todo')).toHaveLength(3);
});
it('should toggle todo completion', async () => {
const user = userEvent.setup();
render(<TodoList />);
// Add a todo
await user.click(screen.getByTestId('add-todo'));
const stats = screen.getByTestId('stats');
expect(stats).toHaveTextContent('Total: 1, Active: 1, Completed: 0');
// Find and toggle the todo
const toggleButton = screen.getByText('Complete');
await user.click(toggleButton);
expect(stats).toHaveTextContent('Total: 1, Active: 0, Completed: 1');
expect(screen.getByText('Undo')).toBeInTheDocument();
// Toggle back
await user.click(screen.getByText('Undo'));
expect(stats).toHaveTextContent('Total: 1, Active: 1, Completed: 0');
});
it('should filter todos correctly', async () => {
const user = userEvent.setup();
render(<TodoList />);
// Add 3 todos
await user.click(screen.getByTestId('add-todo'));
await user.click(screen.getByTestId('add-todo'));
await user.click(screen.getByTestId('add-todo'));
// Complete first todo
const toggleButtons = screen.getAllByText('Complete');
await user.click(toggleButtons[0]);
// Test filtering
const filteredTodos = screen.getByTestId('filtered-todos');
// All filter (default)
expect(filteredTodos.children).toHaveLength(3);
// Active filter
await user.click(screen.getByTestId('filter-active'));
expect(filteredTodos.children).toHaveLength(2);
// Completed filter
await user.click(screen.getByTestId('filter-completed'));
expect(filteredTodos.children).toHaveLength(1);
// Back to all
await user.click(screen.getByTestId('filter-all'));
expect(filteredTodos.children).toHaveLength(3);
});
});
});
π Testing Context and Async Operations
π Advanced Context and Async Testing
Testing components that use Context and async operations requires proper setup and waiting for async updates.
// π Comprehensive context and async testing
describe('Context and Async Testing', () => {
// β
Testing React Context
describe('App Context Provider and Consumers', () => {
const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [cart, setCart] = useState<CartItem[]>([]);
const addToCart = useCallback((product: Product, quantity: number) => {
setCart(prevCart => {
const existingItem = prevCart.find(item => item.product.id === product.id);
if (existingItem) {
return prevCart.map(item =>
item.product.id === product.id
? { ...item, quantity: item.quantity + quantity }
: item
);
}
return [...prevCart, { product, quantity }];
});
}, []);
const removeFromCart = useCallback((productId: string) => {
setCart(prevCart => prevCart.filter(item => item.product.id !== productId));
}, []);
const updateCartQuantity = useCallback((productId: string, quantity: number) => {
if (quantity <= 0) {
removeFromCart(productId);
return;
}
setCart(prevCart =>
prevCart.map(item =>
item.product.id === productId
? { ...item, quantity }
: item
)
);
}, [removeFromCart]);
const login = useCallback((userData: User) => {
setUser(userData);
}, []);
const logout = useCallback(() => {
setUser(null);
setCart([]);
}, []);
const contextValue: AppContextType = {
user,
cart,
addToCart,
removeFromCart,
updateCartQuantity,
login,
logout
};
return (
<AppContext.Provider value={contextValue}>
{children}
</AppContext.Provider>
);
};
const UserProfile: React.FC = () => {
const { user, login, logout } = useAppContext();
const handleLogin = () => {
const mockUser: User = {
id: 'user-123',
name: 'John Doe',
email: '[email protected]',
isActive: true,
role: 'user'
};
login(mockUser);
};
if (!user) {
return (
<div>
<div data-testid="login-prompt">Please log in</div>
<button onClick={handleLogin} data-testid="login-button">
Login
</button>
</div>
);
}
return (
<div data-testid="user-profile">
<div data-testid="user-name">Welcome, {user.name}!</div>
<div data-testid="user-email">{user.email}</div>
<button onClick={logout} data-testid="logout-button">
Logout
</button>
</div>
);
};
const CartSummary: React.FC = () => {
const { cart, updateCartQuantity, removeFromCart } = useAppContext();
const totalItems = cart.reduce((sum, item) => sum + item.quantity, 0);
const totalPrice = cart.reduce((sum, item) => sum + (item.product.price * item.quantity), 0);
return (
<div data-testid="cart-summary">
<div data-testid="total-items">Items: {totalItems}</div>
<div data-testid="total-price">Total: ${totalPrice.toFixed(2)}</div>
<div data-testid="cart-items">
{cart.map(item => (
<div key={item.product.id} data-testid={`cart-item-${item.product.id}`}>
<span>{item.product.name}</span>
<span>Qty: {item.quantity}</span>
<span>${(item.product.price * item.quantity).toFixed(2)}</span>
<button
onClick={() => updateCartQuantity(item.product.id, item.quantity + 1)}
data-testid={`increase-${item.product.id}`}
>
+
</button>
<button
onClick={() => updateCartQuantity(item.product.id, item.quantity - 1)}
data-testid={`decrease-${item.product.id}`}
>
-
</button>
<button
onClick={() => removeFromCart(item.product.id)}
data-testid={`remove-${item.product.id}`}
>
Remove
</button>
</div>
))}
</div>
</div>
);
};
const TestApp: React.FC = () => {
const { addToCart } = useAppContext();
const mockProduct: Product = {
id: 'prod-123',
name: 'Test Product',
price: 19.99,
description: 'A test product',
category: 'Test',
inStock: true
};
return (
<div>
<UserProfile />
<CartSummary />
<button
onClick={() => addToCart(mockProduct, 2)}
data-testid="add-product-button"
>
Add Test Product
</button>
</div>
);
};
it('should provide context to child components', async () => {
const user = userEvent.setup();
render(
<AppProvider>
<TestApp />
</AppProvider>
);
// Initially should show login prompt
expect(screen.getByTestId('login-prompt')).toBeInTheDocument();
expect(screen.getByTestId('total-items')).toHaveTextContent('Items: 0');
// Login
await user.click(screen.getByTestId('login-button'));
// Should show user profile
expect(screen.getByTestId('user-profile')).toBeInTheDocument();
expect(screen.getByTestId('user-name')).toHaveTextContent('Welcome, John Doe!');
});
it('should manage cart state across components', async () => {
const user = userEvent.setup();
render(
<AppProvider>
<TestApp />
</AppProvider>
);
// Add product to cart
await user.click(screen.getByTestId('add-product-button'));
// Check cart summary
expect(screen.getByTestId('total-items')).toHaveTextContent('Items: 2');
expect(screen.getByTestId('total-price')).toHaveTextContent('Total: $39.98');
// Check cart item
const cartItem = screen.getByTestId('cart-item-prod-123');
expect(cartItem).toHaveTextContent('Test Product');
expect(cartItem).toHaveTextContent('Qty: 2');
});
it('should update cart quantities', async () => {
const user = userEvent.setup();
render(
<AppProvider>
<TestApp />
</AppProvider>
);
// Add product to cart
await user.click(screen.getByTestId('add-product-button'));
// Increase quantity
await user.click(screen.getByTestId('increase-prod-123'));
expect(screen.getByTestId('total-items')).toHaveTextContent('Items: 3');
// Decrease quantity
await user.click(screen.getByTestId('decrease-prod-123'));
expect(screen.getByTestId('total-items')).toHaveTextContent('Items: 2');
// Decrease to zero (should remove item)
await user.click(screen.getByTestId('decrease-prod-123'));
await user.click(screen.getByTestId('decrease-prod-123'));
expect(screen.getByTestId('total-items')).toHaveTextContent('Items: 0');
});
it('should clear cart on logout', async () => {
const user = userEvent.setup();
render(
<AppProvider>
<TestApp />
</AppProvider>
);
// Login and add items
await user.click(screen.getByTestId('login-button'));
await user.click(screen.getByTestId('add-product-button'));
expect(screen.getByTestId('total-items')).toHaveTextContent('Items: 2');
// Logout
await user.click(screen.getByTestId('logout-button'));
// Cart should be cleared
expect(screen.getByTestId('total-items')).toHaveTextContent('Items: 0');
expect(screen.getByTestId('login-prompt')).toBeInTheDocument();
});
it('should throw error when context is used outside provider', () => {
// Mock console.error to avoid cluttering test output
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => {
render(<UserProfile />);
}).toThrow('useAppContext must be used within AppProvider');
consoleSpy.mockRestore();
});
});
// β
Testing async operations
describe('Async Data Fetching Component', () => {
interface ApiUser {
id: string;
name: string;
email: string;
}
const UserList: React.FC = () => {
const [users, setUsers] = useState<ApiUser[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchUsers = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error('Failed to fetch users');
}
const userData = await response.json();
setUsers(userData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
if (loading) {
return <div data-testid="loading">Loading users...</div>;
}
if (error) {
return (
<div>
<div data-testid="error">Error: {error}</div>
<button onClick={fetchUsers} data-testid="retry-button">
Retry
</button>
</div>
);
}
return (
<div data-testid="user-list">
<button onClick={fetchUsers} data-testid="refresh-button">
Refresh
</button>
{users.length === 0 ? (
<div data-testid="no-users">No users found</div>
) : (
<div data-testid="users">
{users.map(user => (
<div key={user.id} data-testid={`user-${user.id}`}>
<span>{user.name}</span>
<span>{user.email}</span>
</div>
))}
</div>
)}
</div>
);
};
beforeEach(() => {
global.fetch = jest.fn();
});
afterEach(() => {
jest.resetAllMocks();
});
it('should show loading state initially', () => {
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves
render(<UserList />);
expect(screen.getByTestId('loading')).toBeInTheDocument();
});
it('should display users after successful fetch', async () => {
const mockUsers: ApiUser[] = [
{ id: '1', name: 'John Doe', email: '[email protected]' },
{ id: '2', name: 'Jane Smith', email: '[email protected]' }
];
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockUsers)
} as Response);
render(<UserList />);
// Should show loading initially
expect(screen.getByTestId('loading')).toBeInTheDocument();
// Wait for users to load
await waitFor(() => {
expect(screen.getByTestId('user-list')).toBeInTheDocument();
});
// Should display users
expect(screen.getByTestId('user-1')).toHaveTextContent('John Doe');
expect(screen.getByTestId('user-2')).toHaveTextContent('Jane Smith');
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
});
it('should display error message on fetch failure', async () => {
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockResolvedValue({
ok: false,
status: 500
} as Response);
render(<UserList />);
await waitFor(() => {
expect(screen.getByTestId('error')).toBeInTheDocument();
});
expect(screen.getByTestId('error')).toHaveTextContent('Error: Failed to fetch users');
expect(screen.getByTestId('retry-button')).toBeInTheDocument();
});
it('should handle network errors', async () => {
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockRejectedValue(new Error('Network error'));
render(<UserList />);
await waitFor(() => {
expect(screen.getByTestId('error')).toBeInTheDocument();
});
expect(screen.getByTestId('error')).toHaveTextContent('Error: Network error');
});
it('should retry on retry button click', async () => {
const user = userEvent.setup();
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
// First call fails
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500
} as Response);
// Second call succeeds
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([{ id: '1', name: 'John', email: '[email protected]' }])
} as Response);
render(<UserList />);
// Wait for error
await waitFor(() => {
expect(screen.getByTestId('error')).toBeInTheDocument();
});
// Click retry
await user.click(screen.getByTestId('retry-button'));
// Should show loading again
expect(screen.getByTestId('loading')).toBeInTheDocument();
// Wait for success
await waitFor(() => {
expect(screen.getByTestId('user-list')).toBeInTheDocument();
});
expect(screen.getByTestId('user-1')).toBeInTheDocument();
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('should refresh data on refresh button click', async () => {
const user = userEvent.setup();
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
const initialUsers = [{ id: '1', name: 'John', email: '[email protected]' }];
const updatedUsers = [
{ id: '1', name: 'John Updated', email: '[email protected]' },
{ id: '2', name: 'Jane', email: '[email protected]' }
];
// Initial load
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(initialUsers)
} as Response);
render(<UserList />);
await waitFor(() => {
expect(screen.getByTestId('user-1')).toHaveTextContent('John');
});
// Mock updated data
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(updatedUsers)
} as Response);
// Click refresh
await user.click(screen.getByTestId('refresh-button'));
// Should show loading during refresh
expect(screen.getByTestId('loading')).toBeInTheDocument();
// Wait for updated data
await waitFor(() => {
expect(screen.getByTestId('user-1')).toHaveTextContent('John Updated');
});
expect(screen.getByTestId('user-2')).toBeInTheDocument();
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('should display no users message when list is empty', async () => {
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve([])
} as Response);
render(<UserList />);
await waitFor(() => {
expect(screen.getByTestId('no-users')).toBeInTheDocument();
});
expect(screen.getByTestId('no-users')).toHaveTextContent('No users found');
});
});
});
π― Advanced Testing Patterns and Best Practices
π Production-Ready Component Testing
Here are advanced patterns for comprehensive component testing in production applications.
// π Advanced testing patterns and best practices
describe('Advanced Testing Patterns', () => {
// β
Testing component composition
describe('Component Composition Testing', () => {
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = 'unset';
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="modal-overlay" data-testid="modal-overlay" onClick={onClose}>
<div
className="modal-content"
data-testid="modal-content"
onClick={(e) => e.stopPropagation()}
>
<header className="modal-header">
<h2>{title}</h2>
<button onClick={onClose} data-testid="modal-close">
Γ
</button>
</header>
<div className="modal-body">{children}</div>
</div>
</div>
);
};
const ConfirmDialog: React.FC<{
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
}> = ({ isOpen, onClose, onConfirm, title, message }) => {
const handleConfirm = () => {
onConfirm();
onClose();
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={title}>
<p data-testid="confirm-message">{message}</p>
<div className="confirm-actions">
<button onClick={onClose} data-testid="cancel-button">
Cancel
</button>
<button onClick={handleConfirm} data-testid="confirm-button">
Confirm
</button>
</div>
</Modal>
);
};
const AppWithModal: React.FC = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [actionPerformed, setActionPerformed] = useState(false);
return (
<div>
<button onClick={() => setIsModalOpen(true)} data-testid="open-modal">
Open Modal
</button>
<button onClick={() => setIsConfirmOpen(true)} data-testid="open-confirm">
Open Confirm
</button>
{actionPerformed && <div data-testid="action-result">Action was performed!</div>}
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="Test Modal"
>
<p>This is modal content</p>
<button
onClick={() => setIsModalOpen(false)}
data-testid="modal-action"
>
Close from inside
</button>
</Modal>
<ConfirmDialog
isOpen={isConfirmOpen}
onClose={() => setIsConfirmOpen(false)}
onConfirm={() => setActionPerformed(true)}
title="Confirm Action"
message="Are you sure you want to perform this action?"
/>
</div>
);
};
beforeEach(() => {
// Reset body overflow style before each test
document.body.style.overflow = 'unset';
});
it('should render modal when open', async () => {
const user = userEvent.setup();
render(<AppWithModal />);
// Modal should not be visible initially
expect(screen.queryByTestId('modal-overlay')).not.toBeInTheDocument();
// Open modal
await user.click(screen.getByTestId('open-modal'));
// Modal should be visible
expect(screen.getByTestId('modal-overlay')).toBeInTheDocument();
expect(screen.getByText('Test Modal')).toBeInTheDocument();
expect(screen.getByText('This is modal content')).toBeInTheDocument();
});
it('should close modal when clicking overlay', async () => {
const user = userEvent.setup();
render(<AppWithModal />);
// Open modal
await user.click(screen.getByTestId('open-modal'));
expect(screen.getByTestId('modal-overlay')).toBeInTheDocument();
// Click overlay
await user.click(screen.getByTestId('modal-overlay'));
// Modal should be closed
expect(screen.queryByTestId('modal-overlay')).not.toBeInTheDocument();
});
it('should not close modal when clicking content', async () => {
const user = userEvent.setup();
render(<AppWithModal />);
// Open modal
await user.click(screen.getByTestId('open-modal'));
expect(screen.getByTestId('modal-overlay')).toBeInTheDocument();
// Click modal content
await user.click(screen.getByTestId('modal-content'));
// Modal should still be open
expect(screen.getByTestId('modal-overlay')).toBeInTheDocument();
});
it('should close modal with escape key', async () => {
const user = userEvent.setup();
render(<AppWithModal />);
// Open modal
await user.click(screen.getByTestId('open-modal'));
expect(screen.getByTestId('modal-overlay')).toBeInTheDocument();
// Press escape
await user.keyboard('{Escape}');
// Modal should be closed
expect(screen.queryByTestId('modal-overlay')).not.toBeInTheDocument();
});
it('should prevent body scroll when modal is open', async () => {
const user = userEvent.setup();
render(<AppWithModal />);
// Initially body should be scrollable
expect(document.body.style.overflow).toBe('unset');
// Open modal
await user.click(screen.getByTestId('open-modal'));
// Body scroll should be disabled
expect(document.body.style.overflow).toBe('hidden');
// Close modal
await user.click(screen.getByTestId('modal-close'));
// Body scroll should be restored
expect(document.body.style.overflow).toBe('unset');
});
it('should handle confirm dialog workflow', async () => {
const user = userEvent.setup();
render(<AppWithModal />);
// Open confirm dialog
await user.click(screen.getByTestId('open-confirm'));
// Should show confirm dialog
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
expect(screen.getByTestId('confirm-message')).toHaveTextContent(
'Are you sure you want to perform this action?'
);
// Click confirm
await user.click(screen.getByTestId('confirm-button'));
// Dialog should close and action should be performed
expect(screen.queryByTestId('modal-overlay')).not.toBeInTheDocument();
expect(screen.getByTestId('action-result')).toBeInTheDocument();
});
it('should handle confirm dialog cancellation', async () => {
const user = userEvent.setup();
render(<AppWithModal />);
// Open confirm dialog
await user.click(screen.getByTestId('open-confirm'));
// Click cancel
await user.click(screen.getByTestId('cancel-button'));
// Dialog should close and action should not be performed
expect(screen.queryByTestId('modal-overlay')).not.toBeInTheDocument();
expect(screen.queryByTestId('action-result')).not.toBeInTheDocument();
});
});
// β
Testing custom hooks
describe('Custom Hook Testing', () => {
const useLocalStorage = <T,>(key: string, initialValue: T) => {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
const setValue = useCallback((value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error('Error saving to localStorage:', error);
}
}, [key, storedValue]);
const removeValue = useCallback(() => {
try {
window.localStorage.removeItem(key);
setStoredValue(initialValue);
} catch (error) {
console.error('Error removing from localStorage:', error);
}
}, [key, initialValue]);
return [storedValue, setValue, removeValue] as const;
};
const TestComponent: React.FC<{ storageKey: string; initialValue: string }> = ({
storageKey,
initialValue
}) => {
const [value, setValue, removeValue] = useLocalStorage(storageKey, initialValue);
return (
<div>
<div data-testid="current-value">{value}</div>
<button onClick={() => setValue('new value')} data-testid="set-value">
Set Value
</button>
<button onClick={() => setValue(prev => prev + '!')} data-testid="append-value">
Append !
</button>
<button onClick={removeValue} data-testid="remove-value">
Remove Value
</button>
</div>
);
};
beforeEach(() => {
// Clear localStorage before each test
localStorage.clear();
});
it('should initialize with default value', () => {
render(<TestComponent storageKey="test-key" initialValue="default" />);
expect(screen.getByTestId('current-value')).toHaveTextContent('default');
});
it('should read existing value from localStorage', () => {
// Pre-populate localStorage
localStorage.setItem('test-key', JSON.stringify('existing value'));
render(<TestComponent storageKey="test-key" initialValue="default" />);
expect(screen.getByTestId('current-value')).toHaveTextContent('existing value');
});
it('should update localStorage when value changes', async () => {
const user = userEvent.setup();
render(<TestComponent storageKey="test-key" initialValue="default" />);
await user.click(screen.getByTestId('set-value'));
expect(screen.getByTestId('current-value')).toHaveTextContent('new value');
expect(localStorage.getItem('test-key')).toBe('"new value"');
});
it('should handle functional updates', async () => {
const user = userEvent.setup();
render(<TestComponent storageKey="test-key" initialValue="hello" />);
await user.click(screen.getByTestId('append-value'));
expect(screen.getByTestId('current-value')).toHaveTextContent('hello!');
expect(localStorage.getItem('test-key')).toBe('"hello!"');
});
it('should remove value from localStorage', async () => {
const user = userEvent.setup();
localStorage.setItem('test-key', JSON.stringify('existing'));
render(<TestComponent storageKey="test-key" initialValue="default" />);
expect(screen.getByTestId('current-value')).toHaveTextContent('existing');
await user.click(screen.getByTestId('remove-value'));
expect(screen.getByTestId('current-value')).toHaveTextContent('default');
expect(localStorage.getItem('test-key')).toBeNull();
});
it('should handle localStorage errors gracefully', () => {
// Mock localStorage to throw error
const originalSetItem = localStorage.setItem;
localStorage.setItem = jest.fn(() => {
throw new Error('Storage quota exceeded');
});
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
render(<TestComponent storageKey="test-key" initialValue="default" />);
// Component should still render
expect(screen.getByTestId('current-value')).toHaveTextContent('default');
// Restore original methods
localStorage.setItem = originalSetItem;
consoleSpy.mockRestore();
});
});
// β
Testing error boundaries
describe('Error Boundary Testing', () => {
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
class ErrorBoundary extends React.Component<
{ children: React.ReactNode; fallback?: React.ComponentType<{ error: Error }> },
ErrorBoundaryState
> {
constructor(props: any) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
}
render() {
if (this.state.hasError) {
const FallbackComponent = this.props.fallback;
if (FallbackComponent && this.state.error) {
return <FallbackComponent error={this.state.error} />;
}
return (
<div data-testid="error-fallback">
<h2>Something went wrong.</h2>
<p data-testid="error-message">
{this.state.error?.message || 'Unknown error'}
</p>
</div>
);
}
return this.props.children;
}
}
const ThrowError: React.FC<{ shouldThrow: boolean; errorMessage?: string }> = ({
shouldThrow,
errorMessage = 'Test error'
}) => {
if (shouldThrow) {
throw new Error(errorMessage);
}
return <div data-testid="no-error">Component rendered successfully</div>;
};
const CustomErrorFallback: React.FC<{ error: Error }> = ({ error }) => (
<div data-testid="custom-error-fallback">
<h3>Custom Error Handler</h3>
<p data-testid="custom-error-message">{error.message}</p>
<button onClick={() => window.location.reload()} data-testid="reload-button">
Reload
</button>
</div>
);
beforeEach(() => {
// Suppress console.error for these tests
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should render children when no error occurs', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={false} />
</ErrorBoundary>
);
expect(screen.getByTestId('no-error')).toBeInTheDocument();
expect(screen.queryByTestId('error-fallback')).not.toBeInTheDocument();
});
it('should catch and display error', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} errorMessage="Custom test error" />
</ErrorBoundary>
);
expect(screen.getByTestId('error-fallback')).toBeInTheDocument();
expect(screen.getByTestId('error-message')).toHaveTextContent('Custom test error');
expect(screen.queryByTestId('no-error')).not.toBeInTheDocument();
});
it('should use custom fallback component', () => {
render(
<ErrorBoundary fallback={CustomErrorFallback}>
<ThrowError shouldThrow={true} errorMessage="Custom fallback test" />
</ErrorBoundary>
);
expect(screen.getByTestId('custom-error-fallback')).toBeInTheDocument();
expect(screen.getByTestId('custom-error-message')).toHaveTextContent('Custom fallback test');
expect(screen.getByTestId('reload-button')).toBeInTheDocument();
});
it('should handle errors in event handlers', async () => {
const user = userEvent.setup();
const ComponentWithHandler: React.FC = () => {
const [shouldThrow, setShouldThrow] = useState(false);
const handleClick = () => {
setShouldThrow(true);
};
if (shouldThrow) {
throw new Error('Error in event handler');
}
return (
<button onClick={handleClick} data-testid="trigger-error">
Trigger Error
</button>
);
};
render(
<ErrorBoundary>
<ComponentWithHandler />
</ErrorBoundary>
);
// Component should render initially
expect(screen.getByTestId('trigger-error')).toBeInTheDocument();
// Click to trigger error
await user.click(screen.getByTestId('trigger-error'));
// Error boundary should catch the error
expect(screen.getByTestId('error-fallback')).toBeInTheDocument();
expect(screen.getByTestId('error-message')).toHaveTextContent('Error in event handler');
});
});
});
π Conclusion
Congratulations! Youβve mastered the art of testing React components with TypeScript and React Testing Library! π―
π Key Takeaways
- User-Centric Testing: Test components as users would interact with them
- Proper Queries: Use semantic queries (getByRole, getByLabelText) over test IDs when possible
- Event Simulation: Use userEvent for realistic user interactions
- Async Testing: Handle async operations with waitFor and proper assertions
- Context Testing: Test components within their context providers
- State Management: Verify state changes and their effects on the UI
- Error Handling: Test error states and edge cases thoroughly
- Accessibility: Ensure components are accessible and keyboard navigable
π Next Steps
- Testing Hooks: Learn advanced custom hook testing patterns
- Integration Testing: Build comprehensive integration test suites
- Visual Testing: Add visual regression testing with tools like Storybook
- Performance Testing: Test component performance and optimization
- E2E Testing: Complete user workflows with Cypress or Playwright
You now have the skills to test any React component with confidence, ensuring your applications provide excellent user experiences that are reliable, accessible, and bug-free! π