TDD in React: Test-Driven Development for Frontend Applications

TDD in React: Test-Driven Development for Frontend Applications

Test-driven development in React is more challenging than server-side TDD, but it's absolutely practical. The key is knowing which layer you're testing: component behavior, custom hooks, or integration between components.

TDD Setup for React

Modern React TDD uses React Testing Library (RTL), which tests components the way users interact with them — by finding elements and simulating user actions, not by inspecting implementation details.

npm install --save-dev @testing-library/react @testing-library/user-event @testing-library/jest-dom vitest jsdom
// vitest.config.js
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    setupFiles: './src/test-setup.js',
  },
});
// src/test-setup.js
import '@testing-library/jest-dom';

The Red-Green-Refactor Loop in React

Red: Write a failing test

// SearchBar.test.jsx — written before SearchBar.jsx exists
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SearchBar from './SearchBar';

test('calls onSearch with query when submitted', async () => {
  const onSearch = vi.fn();
  render(<SearchBar onSearch={onSearch} />);
  
  await userEvent.type(screen.getByRole('searchbox'), 'react testing');
  await userEvent.click(screen.getByRole('button', { name: /search/i }));
  
  expect(onSearch).toHaveBeenCalledWith('react testing');
});

Run it. Cannot find module './SearchBar'. That's red.

Green: Write minimum code

// SearchBar.jsx
export default function SearchBar({ onSearch }) {
  const [query, setQuery] = React.useState('');
  
  return (
    <form onSubmit={e => { e.preventDefault(); onSearch(query); }}>
      <input
        role="searchbox"
        value={query}
        onChange={e => setQuery(e.target.value)}
      />
      <button type="submit">Search</button>
    </form>
  );
}

Test passes. Don't add more features yet.

Refactor: Clean up

The implementation is fine. Add the next test.

test('trims whitespace from query', async () => {
  const onSearch = vi.fn();
  render(<SearchBar onSearch={onSearch} />);
  
  await userEvent.type(screen.getByRole('searchbox'), '  react  ');
  await userEvent.click(screen.getByRole('button', { name: /search/i }));
  
  expect(onSearch).toHaveBeenCalledWith('react');
});

Fails. Fix:

onSubmit={e => { e.preventDefault(); onSearch(query.trim()); }}

Green. Continue.

Testing Custom Hooks with TDD

Custom hooks are pure logic — they're the easiest part of React to TDD. Use renderHook from RTL:

// useCounter.test.js — written first
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

test('starts at initial value', () => {
  const { result } = renderHook(() => useCounter(5));
  expect(result.current.count).toBe(5);
});

test('increments count', () => {
  const { result } = renderHook(() => useCounter(0));
  act(() => result.current.increment());
  expect(result.current.count).toBe(1);
});

test('does not go below zero', () => {
  const { result } = renderHook(() => useCounter(0));
  act(() => result.current.decrement());
  expect(result.current.count).toBe(0);
});

test('resets to initial value', () => {
  const { result } = renderHook(() => useCounter(10));
  act(() => result.current.increment());
  act(() => result.current.reset());
  expect(result.current.count).toBe(10);
});

Now implement:

// useCounter.js
import { useState, useCallback } from 'react';

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  
  const increment = useCallback(() => setCount(c => c + 1), []);
  const decrement = useCallback(() => setCount(c => Math.max(0, c - 1)), []);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);
  
  return { count, increment, decrement, reset };
}

All tests pass. The hook is tested independently, without needing any component.

Testing Async Components

Components that fetch data need special handling. TDD your data-fetching components by mocking the data layer:

// UserProfile.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import { vi } from 'vitest';
import UserProfile from './UserProfile';
import * as api from './api';

vi.mock('./api');

test('displays user name after loading', async () => {
  api.getUser.mockResolvedValue({ name: 'Alice', email: 'alice@example.com' });
  
  render(<UserProfile userId="1" />);
  
  expect(screen.getByText(/loading/i)).toBeInTheDocument();
  
  await waitFor(() => {
    expect(screen.getByText('Alice')).toBeInTheDocument();
  });
  expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});

test('shows error message when fetch fails', async () => {
  api.getUser.mockRejectedValue(new Error('Network error'));
  
  render(<UserProfile userId="1" />);
  
  await waitFor(() => {
    expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
  });
});

test('shows empty state for unknown user', async () => {
  api.getUser.mockResolvedValue(null);
  
  render(<UserProfile userId="999" />);
  
  await waitFor(() => {
    expect(screen.getByText(/user not found/i)).toBeInTheDocument();
  });
});

Implement to make tests pass:

// UserProfile.jsx
import { useState, useEffect } from 'react';
import { getUser } from './api';

export default function UserProfile({ userId }) {
  const [user, setUser] = useState(undefined);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    getUser(userId)
      .then(data => { setUser(data); setLoading(false); })
      .catch(err => { setError(err); setLoading(false); });
  }, [userId]);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Failed to load user.</p>;
  if (!user) return <p>User not found.</p>;
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

Testing User Interactions

RTL's userEvent simulates realistic user behavior — typing, clicking, tabbing:

// LoginForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';

test('shows validation error when email is empty', async () => {
  const onSubmit = vi.fn();
  render(<LoginForm onSubmit={onSubmit} />);
  
  await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
  
  expect(screen.getByText(/email is required/i)).toBeInTheDocument();
  expect(onSubmit).not.toHaveBeenCalled();
});

test('submits with valid credentials', async () => {
  const onSubmit = vi.fn();
  render(<LoginForm onSubmit={onSubmit} />);
  
  await userEvent.type(screen.getByLabelText(/email/i), 'user@example.com');
  await userEvent.type(screen.getByLabelText(/password/i), 'secret123');
  await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
  
  expect(onSubmit).toHaveBeenCalledWith({
    email: 'user@example.com',
    password: 'secret123'
  });
});

TDD with Context and State Management

Test components that use Context by wrapping them in a provider:

// CartContext.test.jsx
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CartProvider, useCart } from './CartContext';

// Test component that uses the cart
function CartConsumer() {
  const { items, addItem, total } = useCart();
  return (
    <div>
      <span data-testid="count">{items.length}</span>
      <span data-testid="total">{total}</span>
      <button onClick={() => addItem({ id: '1', name: 'Widget', price: 9.99 })}>
        Add Item
      </button>
    </div>
  );
}

test('starts with empty cart', () => {
  render(<CartProvider><CartConsumer /></CartProvider>);
  expect(screen.getByTestId('count')).toHaveTextContent('0');
});

test('adds item to cart', async () => {
  render(<CartProvider><CartConsumer /></CartProvider>);
  await userEvent.click(screen.getByRole('button', { name: /add item/i }));
  expect(screen.getByTestId('count')).toHaveTextContent('1');
  expect(screen.getByTestId('total')).toHaveTextContent('9.99');
});

What to TDD vs What to Skip

TDD these:

  • Custom hooks (pure logic)
  • Form validation logic
  • Component interactions (user events → expected DOM changes)
  • Conditional rendering (loading, error, empty, data states)
  • Business logic in event handlers

Don't obsessively TDD these:

  • CSS/styling (visual tests or screenshots)
  • Third-party component configuration
  • Pure presentational components with no logic
  • Animations

Use snapshot tests sparingly:

test('renders correctly', () => {
  const { container } = render(<Button variant="primary">Click me</Button>);
  expect(container).toMatchSnapshot();
});

Snapshot tests are fragile. Any visual change, even intentional ones, breaks them. Use them for stable, well-defined components — not for everything.

Common Mistakes in React TDD

Testing implementation details: Don't test state variables, internal methods, or DOM structure. Test what users see and do.

Over-mocking: If you mock everything, your tests don't verify real behavior. Mock at boundaries (API calls, browser APIs) but let components compose naturally.

Not testing error states: Always write tests for loading, error, and empty states before implementing them. These are the states most often neglected in development.

Using getByTestId for everything: getByRole, getByLabelText, and getByText find elements the way users do. getByTestId should be a last resort.

React TDD takes practice, but components driven by tests tend to be simpler, more focused, and easier to maintain. Start with hooks — they're pure logic and easiest to TDD — then move to components.

Read more