๐ฎ Lesson 9.3: Testing User Interactions
Take your testing skills to the next level by mastering complex user interaction testing. Learn to test forms with validation, custom hooks, keyboard navigation, drag-and-drop, and complete user workflows in React applications.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Test complex forms with multi-field validation and dynamic fields
- Test custom hooks in isolation and within components
- Handle keyboard interactions and accessibility testing
- Test file uploads and drag-and-drop functionality
- Create custom render functions with providers and context
- Test complete user workflows and multi-step processes
- Mock external dependencies and API calls effectively
Estimated Time: 75-90 minutes
Project: Test a complete multi-step form with file upload
๐ In This Lesson
๐ Introduction to Advanced Testing
In the previous lessons, you learned the fundamentals of testing React components. Now it's time to level up and tackle more complex, real-world testing scenarios that you'll encounter in production applications.
Real applications involve:
- Complex forms: Multi-step wizards, dynamic fields, file uploads, and intricate validation
- Custom hooks: Reusable logic that needs isolated testing
- User workflows: Complete journeys through your application
- External dependencies: APIs, authentication, third-party services
- Accessibility: Keyboard navigation, screen readers, focus management
๐ What Makes Interactions "Advanced"?
Advanced interactions involve multiple steps, state changes, side effects, and dependencies. They require sophisticated testing techniques including mocking, custom render functions, and careful attention to async behavior and user flows.
The Testing Mindset for Complex Interactions
When testing complex interactions, think like a real user:
Your tests should mirror this flowโeach step building on the previous one, just like a user would experience it.
๐ก Key Principles for Advanced Testing
- Test complete flows, not isolated actions: Users don't just clickโthey complete tasks
- Include error paths: Test what happens when things go wrong
- Test accessibility: Ensure keyboard users can complete the same tasks
- Keep tests maintainable: Use helpers and abstractions for complex setups
- Mock strategically: Mock external dependencies, not your own code
What We'll Build
Throughout this lesson, we'll test a multi-step registration form with:
- Personal information (name, email, password)
- Real-time validation with error messages
- Address autocomplete
- Profile picture upload with preview
- Review step before submission
- Success/error handling
This mirrors the complexity you'll find in real-world applications!
๐ฌ Testing Philosophy: "If a user can do it, you should test it. If a user shouldn't be able to do it, you should test that they can't."
๐ Testing Complex Forms
Forms are the backbone of interactive web applications. Let's master testing them thoroughly, from simple inputs to complex multi-step wizards.
Form with Real-Time Validation
Here's a registration form with validation that updates as users type:
interface RegistrationFormProps {
onSubmit: (data: FormData) => Promise<void>;
}
interface FormData {
email: string;
password: string;
confirmPassword: string;
}
export function RegistrationForm({ onSubmit }: RegistrationFormProps) {
const [formData, setFormData] = React.useState<FormData>({
email: '',
password: '',
confirmPassword: ''
});
const [errors, setErrors] = React.useState<Partial<FormData>>({});
const [touched, setTouched] = React.useState<Partial<Record<keyof FormData, boolean>>>({});
const [isSubmitting, setIsSubmitting] = React.useState(false);
const validateField = (name: keyof FormData, value: string) => {
switch (name) {
case 'email':
if (!value) return 'Email is required';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Invalid email format';
}
return '';
case 'password':
if (!value) return 'Password is required';
if (value.length < 8) return 'Password must be at least 8 characters';
if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value)) {
return 'Password must contain uppercase, lowercase, and number';
}
return '';
case 'confirmPassword':
if (!value) return 'Please confirm your password';
if (value !== formData.password) return 'Passwords do not match';
return '';
default:
return '';
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Validate on change if field has been touched
if (touched[name as keyof FormData]) {
const error = validateField(name as keyof FormData, value);
setErrors(prev => ({ ...prev, [name]: error }));
}
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setTouched(prev => ({ ...prev, [name]: true }));
const error = validateField(name as keyof FormData, value);
setErrors(prev => ({ ...prev, [name]: error }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate all fields
const newErrors: Partial<FormData> = {};
Object.keys(formData).forEach(key => {
const error = validateField(key as keyof FormData, formData[key as keyof FormData]);
if (error) newErrors[key as keyof FormData] = error;
});
setErrors(newErrors);
if (Object.keys(newErrors).length === 0) {
setIsSubmitting(true);
try {
await onSubmit(formData);
} finally {
setIsSubmitting(false);
}
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<span id="email-error" role="alert">{errors.email}</span>
)}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
onBlur={handleBlur}
aria-invalid={!!errors.password}
aria-describedby={errors.password ? 'password-error' : undefined}
/>
{errors.password && (
<span id="password-error" role="alert">{errors.password}</span>
)}
</div>
<div>
<label htmlFor="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={handleChange}
onBlur={handleBlur}
aria-invalid={!!errors.confirmPassword}
aria-describedby={errors.confirmPassword ? 'confirm-error' : undefined}
/>
{errors.confirmPassword && (
<span id="confirm-error" role="alert">{errors.confirmPassword}</span>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating account...' : 'Create Account'}
</button>
</form>
);
}
Comprehensive Form Tests
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { RegistrationForm } from './RegistrationForm';
describe('RegistrationForm', () => {
it('shows validation errors on blur', async () => {
const user = userEvent.setup();
const mockOnSubmit = vi.fn();
render(<RegistrationForm onSubmit={mockOnSubmit} />);
const emailInput = screen.getByLabelText('Email');
// Focus and blur without entering anything
await user.click(emailInput);
await user.tab(); // Tab away to trigger blur
// Should show required error
expect(screen.getByText('Email is required')).toBeInTheDocument();
});
it('shows format error for invalid email', async () => {
const user = userEvent.setup();
const mockOnSubmit = vi.fn();
render(<RegistrationForm onSubmit={mockOnSubmit} />);
const emailInput = screen.getByLabelText('Email');
// Type invalid email
await user.type(emailInput, 'notanemail');
await user.tab();
expect(screen.getByText('Invalid email format')).toBeInTheDocument();
});
it('validates password strength', async () => {
const user = userEvent.setup();
const mockOnSubmit = vi.fn();
render(<RegistrationForm onSubmit={mockOnSubmit} />);
const passwordInput = screen.getByLabelText('Password');
// Too short
await user.type(passwordInput, 'short');
await user.tab();
expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument();
// Clear and try without uppercase
await user.clear(passwordInput);
await user.type(passwordInput, 'lowercase123');
await user.tab();
expect(screen.getByText(/must contain uppercase/i)).toBeInTheDocument();
});
it('validates password confirmation matches', async () => {
const user = userEvent.setup();
const mockOnSubmit = vi.fn();
render(<RegistrationForm onSubmit={mockOnSubmit} />);
// Enter password
await user.type(screen.getByLabelText('Password'), 'Password123');
// Enter different confirmation
await user.type(screen.getByLabelText('Confirm Password'), 'Different123');
await user.tab();
expect(screen.getByText('Passwords do not match')).toBeInTheDocument();
});
it('submits form with valid data', async () => {
const user = userEvent.setup();
const mockOnSubmit = vi.fn().mockResolvedValue(undefined);
render(<RegistrationForm onSubmit={mockOnSubmit} />);
// Fill form with valid data
await user.type(screen.getByLabelText('Email'), 'user@example.com');
await user.type(screen.getByLabelText('Password'), 'Password123');
await user.type(screen.getByLabelText('Confirm Password'), 'Password123');
// Submit
await user.click(screen.getByRole('button', { name: 'Create Account' }));
// Should call onSubmit with correct data
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'Password123',
confirmPassword: 'Password123'
});
});
});
it('shows loading state during submission', async () => {
const user = userEvent.setup();
// Mock that takes time to resolve
const mockOnSubmit = vi.fn(() =>
new Promise(resolve => setTimeout(resolve, 100))
);
render(<RegistrationForm onSubmit={mockOnSubmit} />);
// Fill and submit
await user.type(screen.getByLabelText('Email'), 'user@example.com');
await user.type(screen.getByLabelText('Password'), 'Password123');
await user.type(screen.getByLabelText('Confirm Password'), 'Password123');
await user.click(screen.getByRole('button', { name: 'Create Account' }));
// Should show loading state
expect(screen.getByRole('button', { name: 'Creating account...' })).toBeDisabled();
// Wait for completion
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Create Account' })).not.toBeDisabled();
});
});
it('prevents submission with validation errors', async () => {
const user = userEvent.setup();
const mockOnSubmit = vi.fn();
render(<RegistrationForm onSubmit={mockOnSubmit} />);
// Leave fields empty and submit
await user.click(screen.getByRole('button', { name: 'Create Account' }));
// Should show all validation errors
expect(screen.getByText('Email is required')).toBeInTheDocument();
expect(screen.getByText('Password is required')).toBeInTheDocument();
// Should NOT call onSubmit
expect(mockOnSubmit).not.toHaveBeenCalled();
});
it('clears errors when user fixes input', async () => {
const user = userEvent.setup();
const mockOnSubmit = vi.fn();
render(<RegistrationForm onSubmit={mockOnSubmit} />);
const emailInput = screen.getByLabelText('Email');
// Trigger error
await user.click(emailInput);
await user.tab();
expect(screen.getByText('Email is required')).toBeInTheDocument();
// Fix error
await user.type(emailInput, 'user@example.com');
// Error should clear as user types (after first blur)
await waitFor(() => {
expect(screen.queryByText('Email is required')).not.toBeInTheDocument();
});
});
});
โ What We're Testing
- Validation triggers: Errors appear at the right time (blur, submit)
- Error messages: Correct messages for each validation rule
- Field dependencies: Password confirmation depends on password
- Form submission: Only submits with valid data
- Loading states: UI updates during async operations
- Error recovery: Errors clear when user fixes issues
Testing Dynamic Form Fields
Forms that add/remove fields dynamically need special testing:
function DynamicSkillsForm() {
const [skills, setSkills] = React.useState<string[]>(['']);
const addSkill = () => {
setSkills([...skills, '']);
};
const removeSkill = (index: number) => {
setSkills(skills.filter((_, i) => i !== index));
};
const updateSkill = (index: number, value: string) => {
const newSkills = [...skills];
newSkills[index] = value;
setSkills(newSkills);
};
return (
<div>
<h2>Skills</h2>
{skills.map((skill, index) => (
<div key={index}>
<input
type="text"
value={skill}
onChange={(e) => updateSkill(index, e.target.value)}
aria-label={`Skill ${index + 1}`}
/>
{skills.length > 1 && (
<button
type="button"
onClick={() => removeSkill(index)}
aria-label={`Remove skill ${index + 1}`}
>
Remove
</button>
)}
</div>
))}
<button type="button" onClick={addSkill}>Add Skill</button>
</div>
);
}
test('adds and removes dynamic fields', async () => {
const user = userEvent.setup();
render(<DynamicSkillsForm />);
// Initially one field
expect(screen.getByLabelText('Skill 1')).toBeInTheDocument();
// Add a skill
await user.click(screen.getByRole('button', { name: 'Add Skill' }));
// Should now have two fields
expect(screen.getByLabelText('Skill 1')).toBeInTheDocument();
expect(screen.getByLabelText('Skill 2')).toBeInTheDocument();
// Fill them
await user.type(screen.getByLabelText('Skill 1'), 'React');
await user.type(screen.getByLabelText('Skill 2'), 'TypeScript');
expect(screen.getByLabelText('Skill 1')).toHaveValue('React');
expect(screen.getByLabelText('Skill 2')).toHaveValue('TypeScript');
// Remove first skill
await user.click(screen.getByRole('button', { name: 'Remove skill 1' }));
// TypeScript should now be the only skill
expect(screen.getByLabelText('Skill 1')).toHaveValue('TypeScript');
expect(screen.queryByLabelText('Skill 2')).not.toBeInTheDocument();
});
โ ๏ธ Dynamic Form Testing Tips
- Use
aria-labelwith indices to make fields uniquely queryable - Test adding multiple fields, not just one
- Test removing from different positions (first, middle, last)
- Verify that removing a field doesn't affect others
- Test minimum and maximum field constraints
๐ช Testing Custom Hooks
Custom hooks encapsulate reusable logic, but they can't be tested outside of components. React Testing Library provides renderHook to test hooks in isolation.
The renderHook Utility
Here's a custom hook for managing form state:
// useFormState.ts
import { useState } from 'react';
export function useFormState<T extends Record<string, any>>(initialValues: T) {
const [values, setValues] = useState<T>(initialValues);
const [touched, setTouched] = useState<Record<string, boolean>>({});
const handleChange = (name: keyof T, value: any) => {
setValues(prev => ({ ...prev, [name]: value }));
};
const handleBlur = (name: keyof T) => {
setTouched(prev => ({ ...prev, [name]: true }));
};
const reset = () => {
setValues(initialValues);
setTouched({});
};
const isTouched = (name: keyof T) => touched[name] || false;
return {
values,
touched,
handleChange,
handleBlur,
reset,
isTouched
};
}
Testing the Hook
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useFormState } from './useFormState';
describe('useFormState', () => {
it('initializes with default values', () => {
const initialValues = { name: '', email: '' };
const { result } = renderHook(() => useFormState(initialValues));
expect(result.current.values).toEqual(initialValues);
expect(result.current.touched).toEqual({});
});
it('updates values when handleChange is called', () => {
const { result } = renderHook(() =>
useFormState({ name: '', email: '' })
);
act(() => {
result.current.handleChange('name', 'John');
});
expect(result.current.values.name).toBe('John');
expect(result.current.values.email).toBe('');
});
it('tracks touched fields', () => {
const { result } = renderHook(() =>
useFormState({ name: '', email: '' })
);
expect(result.current.isTouched('name')).toBe(false);
act(() => {
result.current.handleBlur('name');
});
expect(result.current.isTouched('name')).toBe(true);
expect(result.current.isTouched('email')).toBe(false);
});
it('resets to initial values', () => {
const initialValues = { name: '', email: '' };
const { result } = renderHook(() => useFormState(initialValues));
// Make changes
act(() => {
result.current.handleChange('name', 'John');
result.current.handleBlur('name');
});
expect(result.current.values.name).toBe('John');
expect(result.current.isTouched('name')).toBe(true);
// Reset
act(() => {
result.current.reset();
});
expect(result.current.values).toEqual(initialValues);
expect(result.current.touched).toEqual({});
});
});
๐ Understanding renderHook
renderHook() creates a test component that calls your hook and provides access to its return value through result.current. Use act() to wrap state updates just like in component tests.
Testing Hooks with Dependencies
Test hooks that depend on props or context:
// useDebounce.ts
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
// useDebounce.test.ts
describe('useDebounce', () => {
it('returns initial value immediately', () => {
const { result } = renderHook(() => useDebounce('initial', 500));
expect(result.current).toBe('initial');
});
it('debounces value changes', async () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'first', delay: 500 } }
);
expect(result.current).toBe('first');
// Change value
rerender({ value: 'second', delay: 500 });
// Should still be first (not debounced yet)
expect(result.current).toBe('first');
// Wait for debounce
await waitFor(() => {
expect(result.current).toBe('second');
}, { timeout: 600 });
});
it('cancels previous timeout on rapid changes', async () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: 'first' } }
);
// Rapid changes
rerender({ value: 'second' });
rerender({ value: 'third' });
rerender({ value: 'fourth' });
// Wait for debounce - should skip intermediate values
await waitFor(() => {
expect(result.current).toBe('fourth');
}, { timeout: 600 });
});
});
Testing Hooks with Context
// useAuth.ts
import { useContext } from 'react';
import { AuthContext } from './AuthContext';
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
// useAuth.test.ts
import { renderHook } from '@testing-library/react';
import { AuthProvider } from './AuthContext';
import { useAuth } from './useAuth';
describe('useAuth', () => {
it('throws error when used outside provider', () => {
// Suppress console.error for this test
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => {
renderHook(() => useAuth());
}).toThrow('useAuth must be used within AuthProvider');
spy.mockRestore();
});
it('returns auth context when inside provider', () => {
const mockUser = { id: '1', name: 'John' };
const mockLogin = vi.fn();
const mockLogout = vi.fn();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthProvider value={{ user: mockUser, login: mockLogin, logout: mockLogout }}>
{children}
</AuthProvider>
);
const { result } = renderHook(() => useAuth(), { wrapper });
expect(result.current.user).toEqual(mockUser);
expect(result.current.login).toBe(mockLogin);
expect(result.current.logout).toBe(mockLogout);
});
});
Testing Async Hooks
// useFetch.ts
import { useState, useEffect } from 'react';
interface FetchState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
export function useFetch<T>(url: string): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: true,
error: null
});
useEffect(() => {
let cancelled = false;
setState({ data: null, loading: true, error: null });
fetch(url)
.then(res => res.json())
.then(data => {
if (!cancelled) {
setState({ data, loading: false, error: null });
}
})
.catch(error => {
if (!cancelled) {
setState({ data: null, loading: false, error });
}
});
return () => {
cancelled = true;
};
}, [url]);
return state;
}
// useFetch.test.ts
describe('useFetch', () => {
it('fetches data successfully', async () => {
const mockData = { id: 1, name: 'Test' };
global.fetch = vi.fn().mockResolvedValue({
json: async () => mockData
});
const { result } = renderHook(() => useFetch('/api/test'));
// Initially loading
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe(null);
// Wait for data
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBe(null);
});
it('handles fetch errors', async () => {
const mockError = new Error('Network error');
global.fetch = vi.fn().mockRejectedValue(mockError);
const { result } = renderHook(() => useFetch('/api/test'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBe(null);
expect(result.current.error).toEqual(mockError);
});
it('refetches when URL changes', async () => {
global.fetch = vi.fn()
.mockResolvedValueOnce({ json: async () => ({ id: 1 }) })
.mockResolvedValueOnce({ json: async () => ({ id: 2 }) });
const { result, rerender } = renderHook(
({ url }) => useFetch(url),
{ initialProps: { url: '/api/1' } }
);
// Wait for first fetch
await waitFor(() => {
expect(result.current.data).toEqual({ id: 1 });
});
// Change URL
rerender({ url: '/api/2' });
// Should refetch
await waitFor(() => {
expect(result.current.data).toEqual({ id: 2 });
});
expect(global.fetch).toHaveBeenCalledTimes(2);
});
});
โ Hook Testing Best Practices
- Use renderHook: Test hooks in isolation when possible
- Test in components too: Verify hooks work in real usage
- Use act(): Wrap all state updates
- Test cleanup: Verify useEffect cleanup functions work
- Mock dependencies: Mock external APIs, timers, etc.
- Test edge cases: Rapid updates, cancellation, errors
โจ๏ธ Keyboard and Accessibility Testing
Keyboard navigation is crucial for accessibility. Let's ensure your components work for keyboard users.
Tab Navigation
function NavigationMenu() {
const [activeIndex, setActiveIndex] = React.useState(0);
const items = ['Home', 'About', 'Services', 'Contact'];
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
setActiveIndex((index + 1) % items.length);
break;
case 'ArrowLeft':
e.preventDefault();
setActiveIndex((index - 1 + items.length) % items.length);
break;
case 'Home':
e.preventDefault();
setActiveIndex(0);
break;
case 'End':
e.preventDefault();
setActiveIndex(items.length - 1);
break;
}
};
return (
<nav role="navigation" aria-label="Main navigation">
<ul role="menubar">
{items.map((item, index) => (
<li key={item} role="none">
<button
role="menuitem"
tabIndex={activeIndex === index ? 0 : -1}
onKeyDown={(e) => handleKeyDown(e, index)}
onFocus={() => setActiveIndex(index)}
aria-current={activeIndex === index ? 'page' : undefined}
>
{item}
</button>
</li>
))}
</ul>
</nav>
);
}
test('navigates with arrow keys', async () => {
const user = userEvent.setup();
render(<NavigationMenu />);
const home = screen.getByRole('menuitem', { name: 'Home' });
const about = screen.getByRole('menuitem', { name: 'About' });
// Focus first item
home.focus();
expect(home).toHaveFocus();
// Press right arrow
await user.keyboard('{ArrowRight}');
expect(about).toHaveFocus();
// Press left arrow
await user.keyboard('{ArrowLeft}');
expect(home).toHaveFocus();
// Press End key
await user.keyboard('{End}');
expect(screen.getByRole('menuitem', { name: 'Contact' })).toHaveFocus();
// Press Home key
await user.keyboard('{Home}');
expect(home).toHaveFocus();
});
Testing Tab Trapping in Modals
function Modal({ isOpen, onClose, children }: ModalProps) {
const modalRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!isOpen) return;
const focusableElements = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusableElements || focusableElements.length === 0) return;
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
const handleTab = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};
document.addEventListener('keydown', handleTab);
firstElement.focus();
return () => {
document.removeEventListener('keydown', handleTab);
};
}, [isOpen]);
if (!isOpen) return null;
return (
<div role="dialog" aria-modal="true" ref={modalRef}>
<button onClick={onClose} aria-label="Close">ร</button>
{children}
<button onClick={onClose}>Close</button>
</div>
);
}
test('traps focus within modal', async () => {
const user = userEvent.setup();
const handleClose = vi.fn();
render(
<Modal isOpen={true} onClose={handleClose}>
<h2>Modal Title</h2>
<input placeholder="Name" />
<input placeholder="Email" />
</Modal>
);
const closeButton = screen.getByRole('button', { name: 'Close' });
const nameInput = screen.getByPlaceholderText('Name');
const emailInput = screen.getByPlaceholderText('Email');
const bottomCloseButton = screen.getByRole('button', { name: /^Close$/ });
// First element should have focus
expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus();
// Tab through elements
await user.tab();
expect(nameInput).toHaveFocus();
await user.tab();
expect(emailInput).toHaveFocus();
await user.tab();
expect(bottomCloseButton).toHaveFocus();
// Tab from last element should loop to first
await user.tab();
expect(closeButton).toHaveFocus();
// Shift+Tab should go backwards
await user.tab({ shift: true });
expect(bottomCloseButton).toHaveFocus();
});
Testing Escape Key to Close
test('closes modal on Escape key', async () => {
const user = userEvent.setup();
const handleClose = vi.fn();
render(
<Modal isOpen={true} onClose={handleClose}>
<p>Modal content</p>
</Modal>
);
await user.keyboard('{Escape}');
expect(handleClose).toHaveBeenCalledTimes(1);
});
Testing ARIA Attributes
function Accordion() {
const [expanded, setExpanded] = React.useState<string | null>(null);
const sections = [
{ id: 'section1', title: 'Section 1', content: 'Content 1' },
{ id: 'section2', title: 'Section 2', content: 'Content 2' }
];
return (
<div>
{sections.map(section => (
<div key={section.id}>
<button
aria-expanded={expanded === section.id}
aria-controls={`panel-${section.id}`}
onClick={() => setExpanded(
expanded === section.id ? null : section.id
)}
>
{section.title}
</button>
<div
id={`panel-${section.id}`}
role="region"
aria-labelledby={section.id}
hidden={expanded !== section.id}
>
{section.content}
</div>
</div>
))}
</div>
);
}
test('has correct ARIA attributes', async () => {
const user = userEvent.setup();
render(<Accordion />);
const button1 = screen.getByRole('button', { name: 'Section 1' });
// Initially collapsed
expect(button1).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByRole('region', { hidden: true })).toBeInTheDocument();
// Expand
await user.click(button1);
expect(button1).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('Content 1')).toBeVisible();
});
๐ก Accessibility Testing Checklist
- โ All interactive elements are keyboard accessible
- โ Tab order is logical and matches visual order
- โ Focus is visible and properly managed
- โ Modal focus is trapped and returns on close
- โ ARIA attributes are correct and updated
- โ Keyboard shortcuts work (Arrow keys, Escape, Enter)
- โ Screen reader announcements happen (role="alert", etc.)
๐ Testing File Uploads
File uploads involve browser APIs and require special handling in tests.
Basic File Upload Component
interface FileUploadProps {
onFileSelect: (file: File) => void;
accept?: string;
maxSize?: number; // in bytes
}
export function FileUpload({ onFileSelect, accept, maxSize }: FileUploadProps) {
const [error, setError] = React.useState<string | null>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setError(null);
// Validate file size
if (maxSize && file.size > maxSize) {
setError(`File size must be less than ${maxSize / 1024 / 1024}MB`);
return;
}
// Validate file type
if (accept) {
const acceptedTypes = accept.split(',').map(t => t.trim());
const fileExtension = '.' + file.name.split('.').pop();
if (!acceptedTypes.includes(fileExtension) && !acceptedTypes.includes(file.type)) {
setError(`File type must be: ${accept}`);
return;
}
}
onFileSelect(file);
};
return (
<div>
<label htmlFor="file-upload">Choose File</label>
<input
id="file-upload"
type="file"
onChange={handleChange}
accept={accept}
/>
{error && <div role="alert">{error}</div>}
</div>
);
}
Testing File Upload
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { FileUpload } from './FileUpload';
describe('FileUpload', () => {
it('uploads a valid file', async () => {
const user = userEvent.setup();
const handleFileSelect = vi.fn();
render(<FileUpload onFileSelect={handleFileSelect} />);
// Create a mock file
const file = new File(['hello'], 'hello.png', { type: 'image/png' });
const input = screen.getByLabelText('Choose File');
// Upload the file
await user.upload(input, file);
expect(handleFileSelect).toHaveBeenCalledWith(file);
});
it('validates file size', async () => {
const user = userEvent.setup();
const handleFileSelect = vi.fn();
const maxSize = 1024; // 1KB
render(<FileUpload onFileSelect={handleFileSelect} maxSize={maxSize} />);
// Create a file larger than max size
const largeFile = new File(['x'.repeat(2000)], 'large.png', {
type: 'image/png'
});
await user.upload(screen.getByLabelText('Choose File'), largeFile);
expect(screen.getByRole('alert')).toHaveTextContent(/File size must be less than/);
expect(handleFileSelect).not.toHaveBeenCalled();
});
it('validates file type', async () => {
const user = userEvent.setup();
const handleFileSelect = vi.fn();
render(
<FileUpload
onFileSelect={handleFileSelect}
accept=".jpg,.png"
/>
);
// Create a file with wrong type
const file = new File(['content'], 'document.pdf', { type: 'application/pdf' });
await user.upload(screen.getByLabelText('Choose File'), file);
expect(screen.getByRole('alert')).toHaveTextContent(/File type must be/);
expect(handleFileSelect).not.toHaveBeenCalled();
});
it('handles multiple file types', async () => {
const user = userEvent.setup();
const handleFileSelect = vi.fn();
render(
<FileUpload
onFileSelect={handleFileSelect}
accept=".jpg,.png,.gif"
/>
);
const jpgFile = new File(['jpg'], 'image.jpg', { type: 'image/jpeg' });
await user.upload(screen.getByLabelText('Choose File'), jpgFile);
expect(handleFileSelect).toHaveBeenCalledWith(jpgFile);
const pngFile = new File(['png'], 'image.png', { type: 'image/png' });
await user.upload(screen.getByLabelText('Choose File'), pngFile);
expect(handleFileSelect).toHaveBeenCalledWith(pngFile);
});
});
Testing File Upload with Preview
function ImageUploadWithPreview({ onUpload }: { onUpload: (file: File) => void }) {
const [preview, setPreview] = React.useState<string | null>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Create preview
const reader = new FileReader();
reader.onloadend = () => {
setPreview(reader.result as string);
};
reader.readAsDataURL(file);
onUpload(file);
};
return (
<div>
<input
type="file"
onChange={handleFileChange}
accept="image/*"
aria-label="Upload image"
/>
{preview && (
<img
src={preview}
alt="Upload preview"
style={{ maxWidth: '200px' }}
/>
)}
</div>
);
}
test('shows image preview after upload', async () => {
const user = userEvent.setup();
const handleUpload = vi.fn();
render(<ImageUploadWithPreview onUpload={handleUpload} />);
const file = new File(['image'], 'test.png', { type: 'image/png' });
// Mock FileReader
const mockFileReader = {
readAsDataURL: vi.fn(),
onloadend: null as any,
result: 'data:image/png;base64,mockImageData'
};
global.FileReader = vi.fn(() => mockFileReader) as any;
await user.upload(screen.getByLabelText('Upload image'), file);
// Trigger the onloadend callback
mockFileReader.onloadend?.();
// Preview should appear
await waitFor(() => {
expect(screen.getByAltText('Upload preview')).toBeInTheDocument();
});
});
Testing Drag and Drop
function DragDropUpload({ onDrop }: { onDrop: (files: File[]) => void }) {
const [isDragging, setIsDragging] = React.useState(false);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
onDrop(files);
};
return (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{
border: isDragging ? '2px dashed blue' : '2px dashed gray',
padding: '2rem'
}}
data-testid="drop-zone"
>
{isDragging ? 'Drop files here' : 'Drag files here'}
</div>
);
}
test('handles file drop', async () => {
const handleDrop = vi.fn();
render(<DragDropUpload onDrop={handleDrop} />);
const dropZone = screen.getByTestId('drop-zone');
// Create mock files
const file1 = new File(['content1'], 'file1.txt', { type: 'text/plain' });
const file2 = new File(['content2'], 'file2.txt', { type: 'text/plain' });
// Create a drop event
const dropEvent = new Event('drop', { bubbles: true }) as any;
dropEvent.dataTransfer = {
files: [file1, file2]
};
// Trigger drop
fireEvent.drop(dropZone, dropEvent);
expect(handleDrop).toHaveBeenCalledWith([file1, file2]);
});
test('shows dragging state', () => {
const handleDrop = vi.fn();
render(<DragDropUpload onDrop={handleDrop} />);
const dropZone = screen.getByTestId('drop-zone');
// Initial state
expect(dropZone).toHaveTextContent('Drag files here');
// Drag over
fireEvent.dragOver(dropZone);
expect(dropZone).toHaveTextContent('Drop files here');
// Drag leave
fireEvent.dragLeave(dropZone);
expect(dropZone).toHaveTextContent('Drag files here');
});
โ File Upload Testing Tips
- Use
user.upload()for simple file uploads - Create mock
Fileobjects withnew File() - Mock
FileReaderfor preview functionality - Test validation: file size, file type, file count
- Use
fireEventfor drag-and-drop (userEvent doesn't support it yet) - Test error states and edge cases
๐จ Custom Render Functions
Real applications wrap components with providers (Theme, Router, Auth, etc.). Creating custom render functions simplifies testing these components.
The Problem with Providers
Without custom render, you need to wrap every component test:
// โ Repetitive - wrapping providers in every test
test('renders themed button', () => {
render(
<ThemeProvider>
<AuthProvider>
<RouterProvider>
<MyButton />
</RouterProvider>
</AuthProvider>
</ThemeProvider>
);
});
Creating a Custom Render
Create a test utilities file to handle all providers:
// test-utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { ReactElement, ReactNode } from 'react';
import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
// Add custom options
theme?: 'light' | 'dark';
initialAuth?: {
isAuthenticated: boolean;
user?: { id: string; name: string };
};
route?: string;
}
export function renderWithProviders(
ui: ReactElement,
{
theme = 'light',
initialAuth = { isAuthenticated: false },
route = '/',
...renderOptions
}: CustomRenderOptions = {}
) {
// Create a fresh QueryClient for each test
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false }
}
});
// Set up router history
window.history.pushState({}, 'Test page', route);
function Wrapper({ children }: { children: ReactNode }) {
return (
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<ThemeProvider initialTheme={theme}>
<AuthProvider initialAuth={initialAuth}>
{children}
</AuthProvider>
</ThemeProvider>
</QueryClientProvider>
</BrowserRouter>
);
}
return render(ui, { wrapper: Wrapper, ...renderOptions });
}
// Re-export everything from React Testing Library
export * from '@testing-library/react';
// Override render with our custom version
export { renderWithProviders as render };
Using Custom Render
// MyComponent.test.tsx
import { render, screen } from './test-utils'; // Import from test-utils
import userEvent from '@testing-library/user-event';
import { MyComponent } from './MyComponent';
test('renders with dark theme', () => {
// โ
Clean and simple!
render(<MyComponent />, { theme: 'dark' });
expect(screen.getByTestId('theme-indicator')).toHaveTextContent('dark');
});
test('shows user name when authenticated', () => {
render(<MyComponent />, {
initialAuth: {
isAuthenticated: true,
user: { id: '1', name: 'Alice' }
}
});
expect(screen.getByText('Welcome, Alice')).toBeInTheDocument();
});
test('renders at specific route', () => {
render(<MyComponent />, { route: '/dashboard' });
expect(screen.getByText('Dashboard')).toBeInTheDocument();
});
Custom Render for Specific Contexts
Create specialized render functions for different scenarios:
// test-utils.tsx (continued)
// For components that only need router
export function renderWithRouter(
ui: ReactElement,
{ route = '/' }: { route?: string } = {}
) {
window.history.pushState({}, 'Test page', route);
return render(ui, {
wrapper: ({ children }) => <BrowserRouter>{children}</BrowserRouter>
});
}
// For components that only need theme
export function renderWithTheme(
ui: ReactElement,
{ theme = 'light' }: { theme?: 'light' | 'dark' } = {}
) {
return render(ui, {
wrapper: ({ children }) => (
<ThemeProvider initialTheme={theme}>{children}</ThemeProvider>
)
});
}
// For testing forms with React Hook Form
import { FormProvider, useForm } from 'react-hook-form';
export function renderWithForm(
ui: ReactElement,
{ defaultValues = {} }: { defaultValues?: any } = {}
) {
function Wrapper({ children }: { children: ReactNode }) {
const methods = useForm({ defaultValues });
return <FormProvider {...methods}>{children}</FormProvider>;
}
return render(ui, { wrapper: Wrapper });
}
โ Custom Render Benefits
- DRY: Write provider setup once, use everywhere
- Consistency: All tests use the same provider configuration
- Flexibility: Easily customize providers per test
- Maintainability: Update providers in one place
- Readability: Tests focus on behavior, not setup
๐ญ Mocking Dependencies
Mocking external dependencies makes tests faster, more reliable, and easier to control. Let's explore different mocking strategies.
Mocking API Calls with MSW
Mock Service Worker (MSW) intercepts network requests at the network level:
// mocks/handlers.ts
import { rest } from 'msw';
export const handlers = [
// GET request
rest.get('/api/users/:id', (req, res, ctx) => {
const { id } = req.params;
return res(
ctx.status(200),
ctx.json({
id,
name: 'John Doe',
email: 'john@example.com'
})
);
}),
// POST request
rest.post('/api/users', async (req, res, ctx) => {
const body = await req.json();
return res(
ctx.status(201),
ctx.json({
id: '123',
...body
})
);
}),
// Error response
rest.get('/api/error', (req, res, ctx) => {
return res(
ctx.status(500),
ctx.json({ message: 'Internal server error' })
);
})
];
// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// test/setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from '../mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Using MSW in Tests
import { render, screen, waitFor } from '@testing-library/react';
import { server } from '../mocks/server';
import { rest } from 'msw';
import { UserProfile } from './UserProfile';
test('displays user data from API', async () => {
render(<UserProfile userId="1" />);
// MSW automatically handles the request
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
});
test('handles API errors', async () => {
// Override handler for this test
server.use(
rest.get('/api/users/:id', (req, res, ctx) => {
return res(
ctx.status(404),
ctx.json({ message: 'User not found' })
);
})
);
render(<UserProfile userId="999" />);
await waitFor(() => {
expect(screen.getByText('User not found')).toBeInTheDocument();
});
});
test('handles network errors', async () => {
server.use(
rest.get('/api/users/:id', (req, res, ctx) => {
return res.networkError('Failed to connect');
})
);
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText(/network error/i)).toBeInTheDocument();
});
});
Mocking Modules with Vitest
// Mocking an entire module
vi.mock('./api', () => ({
fetchUser: vi.fn(),
createUser: vi.fn()
}));
import { fetchUser, createUser } from './api';
test('fetches user data', async () => {
const mockUser = { id: '1', name: 'Alice' };
// Mock implementation
(fetchUser as any).mockResolvedValue(mockUser);
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
expect(fetchUser).toHaveBeenCalledWith('1');
});
Mocking localStorage
describe('LocalStorageComponent', () => {
beforeEach(() => {
// Clear localStorage before each test
localStorage.clear();
});
it('saves data to localStorage', async () => {
const user = userEvent.setup();
render(<SettingsForm />);
await user.type(screen.getByLabelText('Username'), 'testuser');
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(localStorage.getItem('username')).toBe('testuser');
});
it('loads data from localStorage', () => {
localStorage.setItem('username', 'existing-user');
render(<SettingsForm />);
expect(screen.getByLabelText('Username')).toHaveValue('existing-user');
});
});
// Or mock localStorage entirely
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn()
};
global.localStorage = localStorageMock as any;
Mocking Timers
import { vi } from 'vitest';
test('auto-hides notification after delay', async () => {
vi.useFakeTimers();
render(<Notification message="Success!" autoHideDuration={3000} />);
// Notification visible initially
expect(screen.getByText('Success!')).toBeInTheDocument();
// Fast-forward time
vi.advanceTimersByTime(3000);
// Notification should be hidden
await waitFor(() => {
expect(screen.queryByText('Success!')).not.toBeInTheDocument();
});
vi.useRealTimers();
});
test('clears timer on unmount', () => {
vi.useFakeTimers();
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
const { unmount } = render(<TimerComponent />);
unmount();
expect(clearTimeoutSpy).toHaveBeenCalled();
vi.useRealTimers();
});
Mocking Date and Time
test('displays current date', () => {
// Mock Date
const mockDate = new Date('2024-01-15T12:00:00Z');
vi.setSystemTime(mockDate);
render(<DateDisplay />);
expect(screen.getByText('January 15, 2024')).toBeInTheDocument();
vi.useRealTimers();
});
Mocking window.location
test('redirects to login page', () => {
const mockReload = vi.fn();
delete (window as any).location;
window.location = { ...window.location, reload: mockReload };
render(<LogoutButton />);
fireEvent.click(screen.getByRole('button', { name: 'Logout' }));
expect(mockReload).toHaveBeenCalled();
});
๐ก Mocking Best Practices
- Mock at the boundary: Mock external APIs, not your own code
- Use MSW for APIs: More realistic than mocking fetch
- Reset mocks: Clean up between tests
- Test both success and failure: Mock errors and edge cases
- Keep mocks simple: Don't recreate the entire API
- Document complex mocks: Explain why certain data is used
๐ Testing Complete Workflows
Real users complete multi-step workflows. Let's test entire user journeys from start to finish.
Multi-Step Registration Workflow
test('complete registration workflow', async () => {
const user = userEvent.setup();
// Mock API calls
server.use(
rest.post('/api/register', async (req, res, ctx) => {
const body = await req.json();
return res(ctx.status(201), ctx.json({
id: '123',
...body
}));
})
);
render(<RegistrationWizard />);
// Step 1: Personal Information
expect(screen.getByRole('heading', { name: 'Personal Information' })).toBeInTheDocument();
await user.type(screen.getByLabelText('First Name'), 'John');
await user.type(screen.getByLabelText('Last Name'), 'Doe');
await user.type(screen.getByLabelText('Email'), 'john@example.com');
await user.click(screen.getByRole('button', { name: 'Next' }));
// Step 2: Account Details
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Account Details' })).toBeInTheDocument();
});
await user.type(screen.getByLabelText('Username'), 'johndoe');
await user.type(screen.getByLabelText('Password'), 'SecurePass123');
await user.type(screen.getByLabelText('Confirm Password'), 'SecurePass123');
await user.click(screen.getByRole('button', { name: 'Next' }));
// Step 3: Profile Picture
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Profile Picture' })).toBeInTheDocument();
});
const file = new File(['profile'], 'profile.jpg', { type: 'image/jpeg' });
await user.upload(screen.getByLabelText('Upload Photo'), file);
await user.click(screen.getByRole('button', { name: 'Next' }));
// Step 4: Review
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Review' })).toBeInTheDocument();
});
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('john@example.com')).toBeInTheDocument();
expect(screen.getByText('johndoe')).toBeInTheDocument();
// Submit
await user.click(screen.getByRole('button', { name: 'Submit' }));
// Success message
await waitFor(() => {
expect(screen.getByText(/registration successful/i)).toBeInTheDocument();
});
});
E-commerce Checkout Flow
test('complete checkout process', async () => {
const user = userEvent.setup();
render(<App />, {
route: '/products',
initialAuth: {
isAuthenticated: true,
user: { id: '1', name: 'Customer' }
}
});
// 1. Browse products
expect(screen.getByRole('heading', { name: 'Products' })).toBeInTheDocument();
// 2. Add to cart
const addToCartButtons = screen.getAllByRole('button', { name: /add to cart/i });
await user.click(addToCartButtons[0]);
expect(await screen.findByText('1 item in cart')).toBeInTheDocument();
// 3. View cart
await user.click(screen.getByRole('link', { name: /cart/i }));
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Shopping Cart' })).toBeInTheDocument();
});
// 4. Update quantity
const quantityInput = screen.getByLabelText('Quantity');
await user.clear(quantityInput);
await user.type(quantityInput, '2');
expect(await screen.findByText(/subtotal.*\$39.98/i)).toBeInTheDocument();
// 5. Proceed to checkout
await user.click(screen.getByRole('button', { name: 'Checkout' }));
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Checkout' })).toBeInTheDocument();
});
// 6. Fill shipping info
await user.type(screen.getByLabelText('Street Address'), '123 Main St');
await user.type(screen.getByLabelText('City'), 'Springfield');
await user.selectOptions(screen.getByLabelText('State'), 'IL');
await user.type(screen.getByLabelText('Zip Code'), '62701');
// 7. Fill payment info
await user.type(screen.getByLabelText('Card Number'), '4111111111111111');
await user.type(screen.getByLabelText('Expiry'), '12/25');
await user.type(screen.getByLabelText('CVV'), '123');
// 8. Place order
await user.click(screen.getByRole('button', { name: 'Place Order' }));
// 9. Confirmation
await waitFor(() => {
expect(screen.getByText(/order confirmed/i)).toBeInTheDocument();
}, { timeout: 3000 });
expect(screen.getByText(/order #/i)).toBeInTheDocument();
});
Testing Navigation Between Pages
test('navigates through application', async () => {
const user = userEvent.setup();
render(<App />, { route: '/' });
// Start at home
expect(screen.getByRole('heading', { name: 'Home' })).toBeInTheDocument();
// Navigate to about
await user.click(screen.getByRole('link', { name: 'About' }));
expect(screen.getByRole('heading', { name: 'About Us' })).toBeInTheDocument();
// Navigate to contact
await user.click(screen.getByRole('link', { name: 'Contact' }));
expect(screen.getByRole('heading', { name: 'Contact' })).toBeInTheDocument();
// Fill contact form
await user.type(screen.getByLabelText('Name'), 'John Doe');
await user.type(screen.getByLabelText('Email'), 'john@example.com');
await user.type(screen.getByLabelText('Message'), 'Hello!');
await user.click(screen.getByRole('button', { name: 'Send' }));
// Success and redirect
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Home' })).toBeInTheDocument();
});
expect(screen.getByText(/message sent successfully/i)).toBeInTheDocument();
});
Testing Error Recovery Flows
test('handles and recovers from errors', async () => {
const user = userEvent.setup();
// Start with failing API
server.use(
rest.post('/api/submit', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ message: 'Server error' }));
})
);
render(<SubmitForm />);
// Fill form
await user.type(screen.getByLabelText('Name'), 'John');
await user.click(screen.getByRole('button', { name: 'Submit' }));
// Error appears
expect(await screen.findByText(/server error/i)).toBeInTheDocument();
// Fix API
server.use(
rest.post('/api/submit', (req, res, ctx) => {
return res(ctx.status(200), ctx.json({ success: true }));
})
);
// Retry
await user.click(screen.getByRole('button', { name: 'Try Again' }));
// Success
expect(await screen.findByText(/submitted successfully/i)).toBeInTheDocument();
});
โ Workflow Testing Tips
- Test the happy path first: Ensure the ideal flow works
- Test error paths: What happens when things go wrong?
- Test back navigation: Can users go back and change things?
- Test state persistence: Does data survive navigation?
- Keep tests focused: One workflow per test
- Use descriptive test names: Name should describe the journey
๐๏ธ Hands-on Exercises
๐๏ธ Exercise 1: Multi-Step Survey Form
Objective: Test a multi-step survey with progress tracking and data persistence.
Requirements:
- Test navigation between steps (Next/Back buttons)
- Test form validation on each step
- Test progress indicator updates correctly
- Test data persists when navigating back
- Test submission with all data
- Test that incomplete forms can't be submitted
๐ก Hint
Use screen.getByRole('button', { name: /next/i }) for navigation. Check that input values persist with toHaveValue(). Use waitFor for step transitions.
๐๏ธ Exercise 2: Search with Debounce
Objective: Test a search component that debounces API calls.
Requirements:
- Test that search doesn't trigger immediately on keystroke
- Test that search triggers after debounce delay
- Test that rapid typing cancels previous searches
- Test loading state during search
- Test displaying search results
- Test error handling
๐ก Hint
Use vi.useFakeTimers() and vi.advanceTimersByTime() to control time. Mock the API with MSW or vi.fn(). Use findBy queries to wait for results.
๐๏ธ Exercise 3: Modal with Focus Management
Objective: Test a modal dialog with proper keyboard and focus handling.
Requirements:
- Test that opening modal moves focus inside
- Test that Tab key cycles through focusable elements
- Test that focus is trapped within modal
- Test that Escape key closes modal
- Test that closing modal returns focus to trigger
- Test correct ARIA attributes
๐ก Hint
Use document.activeElement or toHaveFocus() to check focus. Use user.tab() and user.keyboard('{Escape}') for keyboard interaction. Check aria-modal and role="dialog".
โจ Best Practices
โ Do's
1. Test Complete User Flows
// โ
Good - tests the complete user experience
test('user can complete checkout', async () => {
// Add to cart โ View cart โ Enter shipping โ Payment โ Confirm
});
// โ Incomplete - only tests one step
test('user can add to cart', async () => {
// Just tests adding, not the full journey
});
2. Keep Tests Independent
// โ
Good - each test sets up its own data
test('displays cart items', () => {
const items = [{ id: '1', name: 'Product' }];
render(<Cart items={items} />);
});
// โ Bad - depends on global state
let globalItems = [];
test('adds to cart', () => {
globalItems.push({ id: '1' });
});
test('displays cart', () => {
render(<Cart items={globalItems} />); // Depends on previous test
});
3. Use Custom Render Functions
// โ
Good - use custom render
import { render } from './test-utils';
render(<MyComponent />);
// โ Tedious - manual provider wrapping
render(
<Provider1><Provider2><MyComponent /></Provider2></Provider1>
);
4. Test Error States
// โ
Good - tests both success and failure
test('handles successful submission', async () => { /* ... */ });
test('handles submission errors', async () => { /* ... */ });
test('handles network errors', async () => { /* ... */ });
// โ Incomplete - only tests happy path
test('submits form', async () => { /* ... */ });
โ Don'ts
1. Don't Test Implementation Details
// โ Bad - testing state internals
expect(component.state.count).toBe(1);
// โ
Good - testing visible output
expect(screen.getByText('Count: 1')).toBeInTheDocument();
2. Don't Use Brittle Selectors
// โ Bad - breaks if styling changes
container.querySelector('.submit-button');
// โ
Good - uses semantic queries
screen.getByRole('button', { name: 'Submit' });
3. Don't Make Tests Too Long
// โ Bad - testing too many things
test('app works', async () => {
// 500 lines testing every feature
});
// โ
Good - focused tests
test('user can register', async () => { /* ... */ });
test('user can login', async () => { /* ... */ });
test('user can update profile', async () => { /* ... */ });
๐ก Pro Tips
1. Create Test Data Factories
// test-factories.ts
export const createMockUser = (overrides = {}) => ({
id: '1',
name: 'Test User',
email: 'test@example.com',
...overrides
});
export const createMockProduct = (overrides = {}) => ({
id: '1',
name: 'Test Product',
price: 9.99,
...overrides
});
// Usage
const user = createMockUser({ name: 'John' });
const product = createMockProduct({ price: 19.99 });
2. Use Testing Library Debug Tools
test('debugging', () => {
render(<MyComponent />);
screen.debug(); // See entire DOM
screen.logTestingPlaygroundURL(); // Get query suggestions
});
3. Group Related Tests
describe('RegistrationForm', () => {
describe('validation', () => {
test('validates email format', async () => { /* ... */ });
test('validates password strength', async () => { /* ... */ });
});
describe('submission', () => {
test('submits valid data', async () => { /* ... */ });
test('handles submission errors', async () => { /* ... */ });
});
});
โ Testing Checklist
- โ Tests cover complete user workflows
- โ Both success and error paths are tested
- โ Tests are independent and can run in any order
- โ Custom render functions reduce boilerplate
- โ Mocks are used for external dependencies
- โ Keyboard navigation is tested
- โ ARIA attributes are verified
- โ Async operations are handled properly
- โ Tests are readable and well-named
๐ Summary
๐ Key Takeaways
- Complex Forms: Test validation timing, error messages, field dependencies, and dynamic fields
- Custom Hooks: Use
renderHookto test hooks in isolation with properact()wrapping - Keyboard Navigation: Test Tab, Arrow keys, Escape, and focus management for accessibility
- File Uploads: Use
user.upload()and create mockFileobjects for testing - Custom Render: Create reusable render functions that wrap providers to simplify tests
- Mocking: Use MSW for APIs, mock timers with
vi.useFakeTimers(), and mock modules strategically - Complete Workflows: Test entire user journeys from start to finish, including error recovery
- Test Independence: Each test should set up its own data and not depend on other tests
๐ Additional Resources
- renderHook Documentation - Testing custom hooks
- Mock Service Worker - API mocking at the network level
- ARIA Authoring Practices - Accessibility patterns to test
- Resilient UI Tests - Kent C. Dodds on maintainable tests
- user-event API - Complete interaction reference
๐ What's Next?
In the next lesson, we'll explore Testing Async Code in depth:
- Advanced async testing patterns
- Testing data fetching with React Query
- Testing WebSockets and real-time features
- Testing optimistic updates
- Handling race conditions in tests
- Testing infinite scroll and pagination
๐ก Remember: The best tests tell a story about how users interact with your application. If you can read a test and understand what a user is doing, you've written a great test!
๐ Congratulations!
You've mastered advanced user interaction testing! You can now test complex forms, custom hooks, keyboard navigation, file uploads, and complete user workflows. These skills will help you build rock-solid React applications that users can rely on.
Keep practicing, and remember: every interaction you test makes your application better!