TechLead

Testing React Applications

Unit testing, component testing, and mocking with Jest and React Testing Library

Why Test React Apps?

Testing ensures your components work correctly, prevents regressions, and gives you confidence to refactor. The React ecosystem has excellent testing tools: Jest as the test runner and React Testing Library (RTL) for component testing.

React Testing Library encourages testing components the way users interact with them β€” by finding elements by role, label, or text rather than implementation details like class names or state.

πŸ§ͺ The Testing Philosophy

"The more your tests resemble the way your software is used, the more confidence they can give you."

β€” Kent C. Dodds, creator of React Testing Library

Setup

Install the testing libraries (these come pre-configured with Create React App and most frameworks):

npm install --save-dev @testing-library/react @testing-library/jest-dom
npm install --save-dev @testing-library/user-event
npm install --save-dev jest jest-environment-jsdom

Your First Component Test

Here's a simple component and its test:

// Greeting.jsx
export function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>;
}

// Greeting.test.jsx
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { Greeting } from './Greeting';

test('renders greeting with name', () => {
  render(<Greeting name="Alice" />);

  // Find the heading element
  const heading = screen.getByRole('heading');

  // Assert it has the right text
  expect(heading).toHaveTextContent('Hello, Alice!');
});

test('renders with different name', () => {
  render(<Greeting name="Bob" />);
  expect(screen.getByText('Hello, Bob!')).toBeInTheDocument();
});

Testing User Interactions

Use userEvent to simulate real user behavior (clicks, typing, etc.):

// Counter.jsx
import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
      <button onClick={() => setCount(c => c - 1)}>Decrement</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}

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

test('increments and decrements counter', async () => {
  const user = userEvent.setup();
  render(<Counter />);

  // Initial state
  expect(screen.getByText('Count: 0')).toBeInTheDocument();

  // Click increment twice
  await user.click(screen.getByText('Increment'));
  await user.click(screen.getByText('Increment'));
  expect(screen.getByText('Count: 2')).toBeInTheDocument();

  // Click decrement
  await user.click(screen.getByText('Decrement'));
  expect(screen.getByText('Count: 1')).toBeInTheDocument();

  // Reset
  await user.click(screen.getByText('Reset'));
  expect(screen.getByText('Count: 0')).toBeInTheDocument();
});

Testing Forms

// LoginForm.jsx
export function LoginForm({ onSubmit }) {
  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      const formData = new FormData(e.target);
      onSubmit({
        email: formData.get('email'),
        password: formData.get('password'),
      });
    }}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" required />

      <label htmlFor="password">Password</label>
      <input id="password" name="password" type="password" required />

      <button type="submit">Log In</button>
    </form>
  );
}

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

test('submits form with email and password', async () => {
  const user = userEvent.setup();
  const handleSubmit = jest.fn();

  render(<LoginForm onSubmit={handleSubmit} />);

  // Fill in the form β€” find inputs by their label
  await user.type(screen.getByLabelText('Email'), 'alice@example.com');
  await user.type(screen.getByLabelText('Password'), 'secret123');

  // Submit
  await user.click(screen.getByRole('button', { name: 'Log In' }));

  // Assert onSubmit was called with correct data
  expect(handleSubmit).toHaveBeenCalledWith({
    email: 'alice@example.com',
    password: 'secret123',
  });
});

Mocking API Calls

Mock fetch or API functions to test components that load data:

// UserList.jsx
import { useState, useEffect } from 'react';

export function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/users')
      .then(res => res.json())
      .then(data => { setUsers(data); setLoading(false); })
      .catch(err => { setError(err.message); setLoading(false); });
  }, []);

  if (loading) return <p>Loading users...</p>;
  if (error) return <p role="alert">Error: {error}</p>;

  return (
    <ul>
      {users.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}

// UserList.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import { UserList } from './UserList';

// Mock the global fetch
beforeEach(() => {
  global.fetch = jest.fn();
});

test('renders users after loading', async () => {
  fetch.mockResolvedValueOnce({
    json: () => Promise.resolve([
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' },
    ]),
  });

  render(<UserList />);

  // Loading state shows first
  expect(screen.getByText('Loading users...')).toBeInTheDocument();

  // Wait for data to load
  await waitFor(() => {
    expect(screen.getByText('Alice')).toBeInTheDocument();
    expect(screen.getByText('Bob')).toBeInTheDocument();
  });
});

test('shows error on fetch failure', async () => {
  fetch.mockRejectedValueOnce(new Error('Network error'));

  render(<UserList />);

  await waitFor(() => {
    expect(screen.getByRole('alert')).toHaveTextContent('Error: Network error');
  });
});

Testing Custom Hooks

Use renderHook to test custom hooks in isolation:

// 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 => c - 1), []);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);
  return { count, increment, decrement, reset };
}

// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './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 and decrements', () => {
  const { result } = renderHook(() => useCounter(5));

  act(() => result.current.increment());
  expect(result.current.count).toBe(6);

  act(() => result.current.decrement());
  expect(result.current.count).toBe(5);

  act(() => result.current.reset());
  expect(result.current.count).toBe(5);
});

Common Query Methods

Method Use Case Fails if not found?
getByRoleBest for buttons, links, headingsYes (throws)
getByLabelTextBest for form inputsYes
getByTextFind by visible textYes
getByTestIdLast resort β€” use data-testidYes
queryByTextAssert something is NOT presentNo (returns null)
findByTextWait for async element to appearYes (async)

βœ… Testing Best Practices

  • β€’ Query elements by role and label first β€” avoid test IDs when possible
  • β€’ Test behavior, not implementation β€” don't test internal state directly
  • β€’ Use userEvent over fireEvent for more realistic interactions
  • β€’ Use waitFor or findBy for async operations
  • β€’ Keep tests focused β€” one behavior per test
  • β€’ Mock at the boundary (API calls, not internal functions)
  • β€’ Use screen.debug() to diagnose what the DOM looks like