Skip to main content

๐ŸŽฎ 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:

graph TD A[User Opens App] --> B[Sees Form] B --> C[Fills Fields] C --> D[Sees Validation Errors] D --> E[Corrects Errors] E --> F[Uploads File] F --> G[Submits Form] G --> H[Sees Loading State] H --> I[Sees Success Message] style A fill:#667eea,stroke:#333,stroke-width:2px,color:#fff style I fill:#51cf66,stroke:#2f9e44,stroke-width:2px,color:#fff

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

  1. Test complete flows, not isolated actions: Users don't just clickโ€”they complete tasks
  2. Include error paths: Test what happens when things go wrong
  3. Test accessibility: Ensure keyboard users can complete the same tasks
  4. Keep tests maintainable: Use helpers and abstractions for complex setups
  5. 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-label with 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 File objects with new File()
  • Mock FileReader for preview functionality
  • Test validation: file size, file type, file count
  • Use fireEvent for 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:

  1. Test navigation between steps (Next/Back buttons)
  2. Test form validation on each step
  3. Test progress indicator updates correctly
  4. Test data persists when navigating back
  5. Test submission with all data
  6. 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:

  1. Test that search doesn't trigger immediately on keystroke
  2. Test that search triggers after debounce delay
  3. Test that rapid typing cancels previous searches
  4. Test loading state during search
  5. Test displaying search results
  6. 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:

  1. Test that opening modal moves focus inside
  2. Test that Tab key cycles through focusable elements
  3. Test that focus is trapped within modal
  4. Test that Escape key closes modal
  5. Test that closing modal returns focus to trigger
  6. 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 renderHook to test hooks in isolation with proper act() wrapping
  • Keyboard Navigation: Test Tab, Arrow keys, Escape, and focus management for accessibility
  • File Uploads: Use user.upload() and create mock File objects 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

๐Ÿš€ 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!