Testing React Components with React Testing Library

Building Reliable Applications Through Effective Testing

Welcome to React Testing Library!

Imagine you're a quality inspector at a car factory. Instead of checking every nut and bolt (implementation details), you test if the car actually drives, the doors open properly, and the radio works when pressed (user interactions). React Testing Library takes the same approach - we test components from the user's perspective, not the internal implementation.

graph TD A[React Component] --> B{Testing Approach} B --> C[Implementation Testing] B --> D[User-Centric Testing] C --> E[Test State/Props] C --> F[Test Lifecycle Methods] C --> G[Test Internal Methods] D --> H[Test User Interactions] D --> I[Test Visual Output] D --> J[Test Accessibility] style D fill:#99ff99 style H fill:#99ff99 style I fill:#99ff99 style J fill:#99ff99 style C fill:#ff9999

React Testing Library Fundamentals

1. Basic Component Testing

// Component to test
function Greeting({ name = 'Stranger' }) {
  return (
    <div>
      <h1>Hello, {name}!</h1>
      <p>Welcome to our application.</p>
    </div>
  );
}

// Basic test
import { render, screen } from '@testing-library/react';

describe('Greeting', () => {
  test('renders greeting with default name', () => {
    render(<Greeting />);
    
    // Find elements by text
    const heading = screen.getByText('Hello, Stranger!');
    expect(heading).toBeInTheDocument();
    
    const welcome = screen.getByText('Welcome to our application.');
    expect(welcome).toBeInTheDocument();
  });
  
  test('renders greeting with custom name', () => {
    render(<Greeting name="John" />);
    
    const heading = screen.getByText('Hello, John!');
    expect(heading).toBeInTheDocument();
  });
});

// More complex component
function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  
  const handleSubmit = (e) => {
    e.preventDefault();
    if (!email || !password) {
      setError('All fields are required');
      return;
    }
    onSubmit({ email, password });
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <h2>Login</h2>
      {error && <div role="alert">{error}</div>}
      
      <div>
        <label htmlFor="email">Email:</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
      </div>
      
      <div>
        <label htmlFor="password">Password:</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </div>
      
      <button type="submit">Login</button>
    </form>
  );
}

// Testing the LoginForm
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

describe('LoginForm', () => {
  test('renders login form with all fields', () => {
    const handleSubmit = jest.fn();
    render(<LoginForm onSubmit={handleSubmit} />);
    
    expect(screen.getByLabelText('Email:')).toBeInTheDocument();
    expect(screen.getByLabelText('Password:')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument();
  });
  
  test('shows error when submitting empty form', () => {
    const handleSubmit = jest.fn();
    render(<LoginForm onSubmit={handleSubmit} />);
    
    fireEvent.click(screen.getByRole('button', { name: 'Login' }));
    
    expect(screen.getByRole('alert')).toHaveTextContent('All fields are required');
    expect(handleSubmit).not.toHaveBeenCalled();
  });
  
  test('submits form with email and password', async () => {
    const handleSubmit = jest.fn();
    const user = userEvent.setup();
    
    render(<LoginForm onSubmit={handleSubmit} />);
    
    await user.type(screen.getByLabelText('Email:'), 'test@example.com');
    await user.type(screen.getByLabelText('Password:'), 'password123');
    await user.click(screen.getByRole('button', { name: 'Login' }));
    
    expect(handleSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123'
    });
  });
});

2. Query Methods

// Different query methods
function QueryExamples() {
  return (
    <div>
      <h1>Query Examples</h1>
      <button>Click me</button>
      <input type="text" placeholder="Enter text" />
      <img src="test.jpg" alt="Test image" />
      <label htmlFor="username">Username</label>
      <input id="username" />
      <div data-testid="custom-element">Custom Element</div>
    </div>
  );
}

test('demonstrates query methods', () => {
  render(<QueryExamples />);
  
  // getBy - throws error if not found
  const heading = screen.getByRole('heading', { name: 'Query Examples' });
  expect(heading).toBeInTheDocument();
  
  // queryBy - returns null if not found
  const missingElement = screen.queryByRole('alert');
  expect(missingElement).not.toBeInTheDocument();
  
  // findBy - returns a promise, waits for element
  // Useful for async operations
  
  // Different query types:
  
  // By Role (preferred)
  const button = screen.getByRole('button', { name: 'Click me' });
  
  // By Label Text
  const input = screen.getByLabelText('Username');
  
  // By Placeholder Text
  const textInput = screen.getByPlaceholderText('Enter text');
  
  // By Alt Text
  const image = screen.getByAltText('Test image');
  
  // By Test ID (last resort)
  const customElement = screen.getByTestId('custom-element');
});

// Query priority (from most to least preferred):
// 1. getByRole
// 2. getByLabelText
// 3. getByPlaceholderText
// 4. getByText
// 5. getByDisplayValue
// 6. getByAltText
// 7. getByTitle
// 8. getByTestId

3. User Events

// Testing user interactions
import userEvent from '@testing-library/user-event';

function InteractiveForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    agreeToTerms: false,
    country: ''
  });
  
  const handleChange = (e) => {
    const { name, value, type, checked } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: type === 'checkbox' ? checked : value
    }));
  };
  
  return (
    <form>
      <input
        name="name"
        value={formData.name}
        onChange={handleChange}
        placeholder="Your name"
      />
      
      <input
        name="email"
        type="email"
        value={formData.email}
        onChange={handleChange}
        placeholder="Your email"
      />
      
      <select
        name="country"
        value={formData.country}
        onChange={handleChange}
      >
        <option value="">Select country</option>
        <option value="us">United States</option>
        <option value="uk">United Kingdom</option>
        <option value="ca">Canada</option>
      </select>
      
      <label>
        <input
          type="checkbox"
          name="agreeToTerms"
          checked={formData.agreeToTerms}
          onChange={handleChange}
        />
        I agree to terms
      </label>
      
      <div>
        {JSON.stringify(formData, null, 2)}
      </div>
    </form>
  );
}

test('handles form interactions', async () => {
  const user = userEvent.setup();
  render(<InteractiveForm />);
  
  // Type in input fields
  await user.type(screen.getByPlaceholderText('Your name'), 'John Doe');
  await user.type(screen.getByPlaceholderText('Your email'), 'john@example.com');
  
  // Select from dropdown
  await user.selectOptions(screen.getByRole('combobox'), 'us');
  
  // Click checkbox
  await user.click(screen.getByRole('checkbox'));
  
  // Verify form state is updated
  expect(screen.getByText(/"name": "John Doe"/)).toBeInTheDocument();
  expect(screen.getByText(/"email": "john@example.com"/)).toBeInTheDocument();
  expect(screen.getByText(/"country": "us"/)).toBeInTheDocument();
  expect(screen.getByText(/"agreeToTerms": true/)).toBeInTheDocument();
});

// Advanced user interactions
function AdvancedInteractions() {
  const [clickCount, setClickCount] = useState(0);
  const [hoverText, setHoverText] = useState('');
  const [keyPressed, setKeyPressed] = useState('');
  
  return (
    <div>
      <button
        onClick={() => setClickCount(c => c + 1)}
        onDoubleClick={() => setClickCount(c => c + 10)}
      >
        Clicks: {clickCount}
      </button>
      
      <div
        onMouseEnter={() => setHoverText('Mouse entered!')}
        onMouseLeave={() => setHoverText('Mouse left!')}
      >
        Hover me: {hoverText}
      </div>
      
      <input
        onKeyDown={(e) => setKeyPressed(e.key)}
        placeholder="Press a key"
      />
      
      <div>Last key: {keyPressed}</div>
    </div>
  );
}

test('handles advanced interactions', async () => {
  const user = userEvent.setup();
  render(<AdvancedInteractions />);
  
  // Single click
  await user.click(screen.getByRole('button'));
  expect(screen.getByText('Clicks: 1')).toBeInTheDocument();
  
  // Double click
  await user.dblClick(screen.getByRole('button'));
  expect(screen.getByText('Clicks: 11')).toBeInTheDocument();
  
  // Hover interactions
  const hoverDiv = screen.getByText(/Hover me:/);
  await user.hover(hoverDiv);
  expect(screen.getByText('Mouse entered!')).toBeInTheDocument();
  
  await user.unhover(hoverDiv);
  expect(screen.getByText('Mouse left!')).toBeInTheDocument();
  
  // Keyboard interactions
  const input = screen.getByPlaceholderText('Press a key');
  await user.type(input, 'a');
  expect(screen.getByText('Last key: a')).toBeInTheDocument();
});

Testing Asynchronous Code

1. Async Data Fetching

// Component that fetches data
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    async function fetchUser() {
      try {
        setLoading(true);
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) throw new Error('Failed to fetch user');
        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }
    
    fetchUser();
  }, [userId]);
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!user) return <div>No user found</div>;
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>Email: {user.email}</p>
      <p>Role: {user.role}</p>
    </div>
  );
}

// Testing async component
import { render, screen, waitFor } from '@testing-library/react';

describe('UserProfile', () => {
  beforeEach(() => {
    // Reset fetch mocks before each test
    global.fetch = jest.fn();
  });
  
  afterEach(() => {
    jest.restoreAllMocks();
  });
  
  test('shows loading state initially', () => {
    render(<UserProfile userId="1" />);
    expect(screen.getByText('Loading...')).toBeInTheDocument();
  });
  
  test('displays user data when fetch succeeds', async () => {
    const mockUser = {
      id: '1',
      name: 'John Doe',
      email: 'john@example.com',
      role: 'admin'
    };
    
    global.fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => mockUser
    });
    
    render(<UserProfile userId="1" />);
    
    // Wait for the data to load
    await waitFor(() => {
      expect(screen.getByText('John Doe')).toBeInTheDocument();
    });
    
    expect(screen.getByText('Email: john@example.com')).toBeInTheDocument();
    expect(screen.getByText('Role: admin')).toBeInTheDocument();
  });
  
  test('displays error when fetch fails', async () => {
    global.fetch.mockRejectedValueOnce(new Error('Network error'));
    
    render(<UserProfile userId="1" />);
    
    await waitFor(() => {
      expect(screen.getByText('Error: Network error')).toBeInTheDocument();
    });
  });
});

2. Testing with Mock Service Worker (MSW)

// Setting up MSW
import { rest } from 'msw';
import { setupServer } from 'msw/node';

// Define handlers
const handlers = [
  rest.get('/api/users/:userId', (req, res, ctx) => {
    const { userId } = req.params;
    
    if (userId === '1') {
      return res(
        ctx.json({
          id: '1',
          name: 'John Doe',
          email: 'john@example.com'
        })
      );
    }
    
    return res(
      ctx.status(404),
      ctx.json({ message: 'User not found' })
    );
  }),
  
  rest.post('/api/login', async (req, res, ctx) => {
    const { email, password } = await req.json();
    
    if (email === 'test@example.com' && password === 'password') {
      return res(
        ctx.json({
          token: 'fake-jwt-token',
          user: { id: '1', email }
        })
      );
    }
    
    return res(
      ctx.status(401),
      ctx.json({ message: 'Invalid credentials' })
    );
  })
];

// Setup server
const server = setupServer(...handlers);

// Start server before all tests
beforeAll(() => server.listen());

// Reset handlers after each test
afterEach(() => server.resetHandlers());

// Clean up after all tests
afterAll(() => server.close());

// Testing with MSW
function LoginForm() {
  const [status, setStatus] = useState('idle');
  const [error, setError] = useState(null);
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    setStatus('loading');
    
    const formData = new FormData(e.target);
    
    try {
      const response = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          email: formData.get('email'),
          password: formData.get('password')
        })
      });
      
      const data = await response.json();
      
      if (!response.ok) throw new Error(data.message);
      
      setStatus('success');
      localStorage.setItem('token', data.token);
    } catch (err) {
      setStatus('error');
      setError(err.message);
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button type="submit" disabled={status === 'loading'}>
        {status === 'loading' ? 'Logging in...' : 'Login'}
      </button>
      {status === 'error' && <div role="alert">{error}</div>}
      {status === 'success' && <div>Login successful!</div>}
    </form>
  );
}

test('handles successful login', async () => {
  const user = userEvent.setup();
  render(<LoginForm />);
  
  await user.type(screen.getByLabelText(/email/i), 'test@example.com');
  await user.type(screen.getByLabelText(/password/i), 'password');
  await user.click(screen.getByRole('button', { name: /login/i }));
  
  await waitFor(() => {
    expect(screen.getByText('Login successful!')).toBeInTheDocument();
  });
  
  expect(localStorage.getItem('token')).toBe('fake-jwt-token');
});

test('handles login failure', async () => {
  const user = userEvent.setup();
  render(<LoginForm />);
  
  await user.type(screen.getByLabelText(/email/i), 'wrong@example.com');
  await user.type(screen.getByLabelText(/password/i), 'wrongpassword');
  await user.click(screen.getByRole('button', { name: /login/i }));
  
  await waitFor(() => {
    expect(screen.getByRole('alert')).toHaveTextContent('Invalid credentials');
  });
});

3. Testing Timers and Delays

// Component with timers
function Notification({ message, duration = 3000 }) {
  const [visible, setVisible] = useState(true);
  
  useEffect(() => {
    const timer = setTimeout(() => {
      setVisible(false);
    }, duration);
    
    return () => clearTimeout(timer);
  }, [duration]);
  
  if (!visible) return null;
  
  return (
    <div role="alert" className="notification">
      {message}
    </div>
  );
}

// Testing with fake timers
beforeEach(() => {
  jest.useFakeTimers();
});

afterEach(() => {
  jest.runOnlyPendingTimers();
  jest.useRealTimers();
});

test('notification disappears after duration', () => {
  render(<Notification message="Test message" duration={3000} />);
  
  // Notification should be visible initially
  expect(screen.getByRole('alert')).toBeInTheDocument();
  
  // Fast-forward time
  act(() => {
    jest.advanceTimersByTime(3000);
  });
  
  // Notification should be gone
  expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});

// Component with intervals
function Counter() {
  const [count, setCount] = useState(0);
  const [isRunning, setIsRunning] = useState(false);
  
  useEffect(() => {
    let interval;
    
    if (isRunning) {
      interval = setInterval(() => {
        setCount(c => c + 1);
      }, 1000);
    }
    
    return () => clearInterval(interval);
  }, [isRunning]);
  
  return (
    <div>
      <div>Count: {count}</div>
      <button onClick={() => setIsRunning(!isRunning)}>
        {isRunning ? 'Stop' : 'Start'}
      </button>
    </div>
  );
}

test('counter increments when running', async () => {
  const user = userEvent.setup({ delay: null });
  render(<Counter />);
  
  // Initially count is 0
  expect(screen.getByText('Count: 0')).toBeInTheDocument();
  
  // Start the counter
  await user.click(screen.getByRole('button', { name: 'Start' }));
  
  // Advance timers by 3 seconds
  act(() => {
    jest.advanceTimersByTime(3000);
  });
  
  // Count should be 3
  expect(screen.getByText('Count: 3')).toBeInTheDocument();
  
  // Stop the counter
  await user.click(screen.getByRole('button', { name: 'Stop' }));
  
  // Advance time again
  act(() => {
    jest.advanceTimersByTime(2000);
  });
  
  // Count should still be 3
  expect(screen.getByText('Count: 3')).toBeInTheDocument();
});

Testing Custom Hooks

1. Basic Hook Testing

// Custom hook
function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  
  const increment = () => setCount(c => c + 1);
  const decrement = () => setCount(c => c - 1);
  const reset = () => setCount(initialValue);
  
  return { count, increment, decrement, reset };
}

// Testing custom hooks
import { renderHook, act } from '@testing-library/react';

describe('useCounter', () => {
  test('initializes with default value', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });
  
  test('initializes with custom value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });
  
  test('increments counter', () => {
    const { result } = renderHook(() => useCounter());
    
    act(() => {
      result.current.increment();
    });
    
    expect(result.current.count).toBe(1);
  });
  
  test('decrements counter', () => {
    const { result } = renderHook(() => useCounter(5));
    
    act(() => {
      result.current.decrement();
    });
    
    expect(result.current.count).toBe(4);
  });
  
  test('resets counter', () => {
    const { result } = renderHook(() => useCounter(5));
    
    act(() => {
      result.current.increment();
      result.current.increment();
      result.current.reset();
    });
    
    expect(result.current.count).toBe(5);
  });
});

2. Complex Hook Testing

// Complex hook with API calls
function useUser(userId) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  const fetchUser = useCallback(async () => {
    try {
      setLoading(true);
      setError(null);
      const response = await fetch(`/api/users/${userId}`);
      if (!response.ok) throw new Error('Failed to fetch user');
      const data = await response.json();
      setUser(data);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }, [userId]);
  
  useEffect(() => {
    fetchUser();
  }, [fetchUser]);
  
  return { user, loading, error, refetch: fetchUser };
}

// Testing complex hooks
describe('useUser', () => {
  beforeEach(() => {
    global.fetch = jest.fn();
  });
  
  afterEach(() => {
    jest.restoreAllMocks();
  });
  
  test('fetches user successfully', async () => {
    const mockUser = { id: 1, name: 'John Doe' };
    
    global.fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => mockUser
    });
    
    const { result } = renderHook(() => useUser(1));
    
    // Initially loading
    expect(result.current.loading).toBe(true);
    expect(result.current.user).toBe(null);
    
    // Wait for fetch to complete
    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });
    
    expect(result.current.user).toEqual(mockUser);
    expect(result.current.error).toBe(null);
  });
  
  test('handles fetch error', async () => {
    global.fetch.mockRejectedValueOnce(new Error('Network error'));
    
    const { result } = renderHook(() => useUser(1));
    
    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });
    
    expect(result.current.error).toBe('Network error');
    expect(result.current.user).toBe(null);
  });
  
  test('refetches data when userId changes', async () => {
    const mockUser1 = { id: 1, name: 'User 1' };
    const mockUser2 = { id: 2, name: 'User 2' };
    
    global.fetch
      .mockResolvedValueOnce({
        ok: true,
        json: async () => mockUser1
      })
      .mockResolvedValueOnce({
        ok: true,
        json: async () => mockUser2
      });
    
    const { result, rerender } = renderHook(
      ({ userId }) => useUser(userId),
      { initialProps: { userId: 1 } }
    );
    
    // Wait for first fetch
    await waitFor(() => {
      expect(result.current.user).toEqual(mockUser1);
    });
    
    // Change userId
    rerender({ userId: 2 });
    
    // Wait for second fetch
    await waitFor(() => {
      expect(result.current.user).toEqual(mockUser2);
    });
  });
});

Integration Testing

1. Testing Component Interaction

// Shopping cart components
function Product({ product, onAddToCart }) {
  return (
    <div>
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <button onClick={() => onAddToCart(product)}>
        Add to Cart
      </button>
    </div>
  );
}

function Cart({ items, onRemove }) {
  const total = items.reduce((sum, item) => sum + item.price, 0);
  
  return (
    <div>
      <h2>Shopping Cart</h2>
      {items.length === 0 ? (
        <p>Your cart is empty</p>
      ) : (
        <>
          <ul>
            {items.map(item => (
              <li key={item.id}>
                {item.name} - ${item.price}
                <button onClick={() => onRemove(item.id)}>
                  Remove
                </button>
              </li>
            ))}
          </ul>
          <p>Total: ${total}</p>
        </>
      )}
    </div>
  );
}

function ShoppingApp() {
  const [cart, setCart] = useState([]);
  const products = [
    { id: 1, name: 'Product 1', price: 10 },
    { id: 2, name: 'Product 2', price: 20 },
    { id: 3, name: 'Product 3', price: 30 }
  ];
  
  const addToCart = (product) => {
    setCart(prev => [...prev, product]);
  };
  
  const removeFromCart = (productId) => {
    setCart(prev => prev.filter(item => item.id !== productId));
  };
  
  return (
    <div>
      <h1>Shopping App</h1>
      <div>
        {products.map(product => (
          <Product
            key={product.id}
            product={product}
            onAddToCart={addToCart}
          />
        ))}
      </div>
      <Cart items={cart} onRemove={removeFromCart} />
    </div>
  );
}

// Integration test
test('shopping cart functionality', async () => {
  const user = userEvent.setup();
  render(<ShoppingApp />);
  
  // Initially cart is empty
  expect(screen.getByText('Your cart is empty')).toBeInTheDocument();
  
  // Add products to cart
  const addButtons = screen.getAllByRole('button', { name: 'Add to Cart' });
  await user.click(addButtons[0]); // Add Product 1
  await user.click(addButtons[1]); // Add Product 2
  
  // Check if products are in cart
  expect(screen.getByText('Product 1 - $10')).toBeInTheDocument();
  expect(screen.getByText('Product 2 - $20')).toBeInTheDocument();
  expect(screen.getByText('Total: $30')).toBeInTheDocument();
  
  // Remove a product
  const removeButtons = screen.getAllByRole('button', { name: 'Remove' });
  await user.click(removeButtons[0]); // Remove Product 1
  
  // Check if cart is updated
  expect(screen.queryByText('Product 1 - $10')).not.toBeInTheDocument();
  expect(screen.getByText('Product 2 - $20')).toBeInTheDocument();
  expect(screen.getByText('Total: $20')).toBeInTheDocument();
});

2. Testing with Context

// Theme context and components
const ThemeContext = createContext();

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  
  const toggleTheme = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light');
  };
  
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

function ThemedButton() {
  const { theme, toggleTheme } = useContext(ThemeContext);
  
  return (
    <button
      onClick={toggleTheme}
      style={{
        background: theme === 'light' ? '#fff' : '#333',
        color: theme === 'light' ? '#333' : '#fff'
      }}
    >
      Current theme: {theme}
    </button>
  );
}

// Testing with context
test('theme toggle functionality', async () => {
  const user = userEvent.setup();
  
  render(
    <ThemeProvider>
      <ThemedButton />
    </ThemeProvider>
  );
  
  const button = screen.getByRole('button');
  
  // Initially light theme
  expect(button).toHaveTextContent('Current theme: light');
  expect(button).toHaveStyle({ background: '#fff', color: '#333' });
  
  // Toggle to dark theme
  await user.click(button);
  expect(button).toHaveTextContent('Current theme: dark');
  expect(button).toHaveStyle({ background: '#333', color: '#fff' });
  
  // Toggle back to light theme
  await user.click(button);
  expect(button).toHaveTextContent('Current theme: light');
  expect(button).toHaveStyle({ background: '#fff', color: '#333' });
});

// Test utility for wrapping with providers
function renderWithProviders(ui, { providerProps, ...renderOptions } = {}) {
  return render(
    <ThemeProvider {...providerProps}>
      {ui}
    </ThemeProvider>,
    renderOptions
  );
}

// Using the utility
test('component with theme context', () => {
  renderWithProviders(<ThemedButton />);
  // ... test implementation
});

Testing Best Practices

1. Test User Behavior, Not Implementation

// ❌ Bad: Testing implementation details
test('calls setState when button clicked', () => {
  const setStateSpy = jest.spyOn(React, 'useState');
  // Don't do this!
});

// ✅ Good: Testing user behavior
test('increments counter when button clicked', async () => {
  const user = userEvent.setup();
  render(<Counter />);
  
  await user.click(screen.getByRole('button', { name: '+' }));
  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

2. Use Meaningful Queries

// ❌ Bad: Using test IDs everywhere
<button data-testid="submit-button">Submit</button>
screen.getByTestId('submit-button');

// ✅ Good: Using accessible queries
<button>Submit</button>
screen.getByRole('button', { name: 'Submit' });

// Accessibility-first queries (in order of preference):
// 1. getByRole
// 2. getByLabelText
// 3. getByPlaceholderText
// 4. getByText
// 5. getByDisplayValue
// 6. getByAltText
// 7. getByTitle
// 8. getByTestId (last resort)

3. Avoid Implementation Details

// ❌ Bad: Testing component state
test('sets isLoading to true', () => {
  // Don't test internal state!
});

// ✅ Good: Testing visible behavior
test('shows loading spinner while fetching', async () => {
  render(<UserProfile />);
  expect(screen.getByRole('progressbar')).toBeInTheDocument();
  
  await waitFor(() => {
    expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
  });
});

4. Write Maintainable Tests

// Create test utilities
const setup = (props = {}) => {
  const user = userEvent.setup();
  const utils = render(<LoginForm {...props} />);
  
  const emailInput = screen.getByLabelText(/email/i);
  const passwordInput = screen.getByLabelText(/password/i);
  const submitButton = screen.getByRole('button', { name: /login/i });
  
  return {
    user,
    emailInput,
    passwordInput,
    submitButton,
    ...utils
  };
};

// Use utilities in tests
test('successful login', async () => {
  const { user, emailInput, passwordInput, submitButton } = setup();
  
  await user.type(emailInput, 'test@example.com');
  await user.type(passwordInput, 'password');
  await user.click(submitButton);
  
  expect(screen.getByText('Welcome!')).toBeInTheDocument();
});

Practice Exercises

Exercise 1: Test a Todo Application

Create tests for a todo list app that:

  • Adds new todos
  • Marks todos as complete
  • Filters todos (all, active, completed)
  • Deletes todos
  • Persists todos to localStorage

Exercise 2: Test a Form with Validation

Write tests for a registration form with:

  • Email validation
  • Password strength requirements
  • Matching password confirmation
  • Async username availability check
  • Form submission

Exercise 3: Test a Data Table

Create tests for a data table component with:

  • Sorting by different columns
  • Pagination
  • Search/filter functionality
  • Row selection
  • Bulk actions

Key Takeaways