React.js Fundamentals

Testing React Components

20 min Lesson 26 of 40

Introduction to Testing React Components

Testing is a critical part of building reliable React applications. In this lesson, we'll explore how to test React components using Jest and React Testing Library, the industry-standard tools for testing React applications.

Why Test React Components?

Testing React components provides several benefits:

  • Confidence: Ensure your components work as expected
  • Regression Prevention: Catch bugs when making changes
  • Documentation: Tests serve as living documentation
  • Refactoring Safety: Change code confidently with test coverage
  • Better Design: Writing tests encourages better component design
Testing Philosophy: React Testing Library encourages testing components the way users interact with them, focusing on behavior rather than implementation details.

Setting Up Jest and React Testing Library

Create React App comes with Jest and React Testing Library pre-configured. For custom setups:

Installation:
npm install --save-dev @testing-library/react @testing-library/jest-dom
npm install --save-dev @testing-library/user-event
Setup File (setupTests.js):
// Import Jest DOM matchers
import '@testing-library/jest-dom';

// Optional: Add custom matchers or global test setup
global.matchMedia = global.matchMedia || function() {
  return {
    matches: false,
    addListener: jest.fn(),
    removeListener: jest.fn(),
  };
};

Your First Component Test

Let's start with a simple component and test it:

Component (Button.jsx):
function Button({ onClick, children, disabled = false }) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className="btn"
    >
      {children}
    </button>
  );
}

export default Button;
Test File (Button.test.jsx):
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button';

describe('Button Component', () => {
  test('renders button with text', () => {
    render(<Button>Click Me</Button>);

    const button = screen.getByRole('button', { name: /click me/i });
    expect(button).toBeInTheDocument();
  });

  test('calls onClick when clicked', async () => {
    const handleClick = jest.fn();
    const user = userEvent.setup();

    render(<Button onClick={handleClick}>Click Me</Button>);

    const button = screen.getByRole('button');
    await user.click(button);

    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  test('does not call onClick when disabled', async () => {
    const handleClick = jest.fn();
    const user = userEvent.setup();

    render(<Button onClick={handleClick} disabled>Click Me</Button>);

    const button = screen.getByRole('button');
    await user.click(button);

    expect(handleClick).not.toHaveBeenCalled();
    expect(button).toBeDisabled();
  });
});
Testing Best Practice: Use describe blocks to group related tests and give each test a clear, descriptive name that explains what it's testing.

Query Methods in React Testing Library

React Testing Library provides several query methods to find elements:

Query Priority (Recommended Order):
// 1. Accessible queries (preferred)
screen.getByRole('button', { name: /submit/i })
screen.getByLabelText(/username/i)
screen.getByPlaceholderText(/enter email/i)
screen.getByText(/welcome back/i)

// 2. Semantic queries
screen.getByAltText(/profile picture/i)
screen.getByTitle(/close dialog/i)

// 3. Test IDs (last resort)
screen.getByTestId('custom-element')

// Query variants:
// getBy* - throws error if not found (single element)
// queryBy* - returns null if not found (single element)
// findBy* - returns promise, waits for element (async)
// getAllBy* - returns array, throws if none found
// queryAllBy* - returns empty array if none found
// findAllBy* - returns promise with array

Testing Component with State

Let's test a component that manages state:

Counter Component (Counter.jsx):
import { useState } from 'react';

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

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

export default Counter;
Counter Test (Counter.test.jsx):
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';

describe('Counter Component', () => {
  test('renders with initial count', () => {
    render(<Counter initialCount={5} />);

    const countValue = screen.getByTestId('count-value');
    expect(countValue).toHaveTextContent('5');
  });

  test('increments count when increment button is clicked', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    const incrementButton = screen.getByRole('button', { name: /increment/i });
    await user.click(incrementButton);

    const countValue = screen.getByTestId('count-value');
    expect(countValue).toHaveTextContent('1');
  });

  test('decrements count when decrement button is clicked', async () => {
    const user = userEvent.setup();
    render(<Counter initialCount={10} />);

    const decrementButton = screen.getByRole('button', { name: /decrement/i });
    await user.click(decrementButton);

    const countValue = screen.getByTestId('count-value');
    expect(countValue).toHaveTextContent('9');
  });

  test('resets count to zero', async () => {
    const user = userEvent.setup();
    render(<Counter initialCount={5} />);

    const resetButton = screen.getByRole('button', { name: /reset/i });
    await user.click(resetButton);

    const countValue = screen.getByTestId('count-value');
    expect(countValue).toHaveTextContent('0');
  });

  test('performs multiple interactions correctly', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    const incrementButton = screen.getByRole('button', { name: /increment/i });
    const decrementButton = screen.getByRole('button', { name: /decrement/i });
    const countValue = screen.getByTestId('count-value');

    await user.click(incrementButton);
    await user.click(incrementButton);
    await user.click(incrementButton);
    expect(countValue).toHaveTextContent('3');

    await user.click(decrementButton);
    expect(countValue).toHaveTextContent('2');
  });
});

Testing Async Operations

Many components perform asynchronous operations. React Testing Library provides utilities for testing async behavior:

User List Component (UserList.jsx):
import { useState, useEffect } from 'react';

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

  useEffect(() => {
    fetch('https://api.example.com/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>Error: {error}</p>;

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

export default UserList;
Async Test (UserList.test.jsx):
import { render, screen, waitFor } from '@testing-library/react';
import UserList from './UserList';

// Mock fetch globally
global.fetch = jest.fn();

describe('UserList Component', () => {
  beforeEach(() => {
    fetch.mockClear();
  });

  test('displays loading state initially', () => {
    fetch.mockImplementation(() => new Promise(() => {})); // Never resolves

    render(<UserList />);

    expect(screen.getByText(/loading users/i)).toBeInTheDocument();
  });

  test('displays users after successful fetch', async () => {
    const mockUsers = [
      { id: 1, name: 'John Doe' },
      { id: 2, name: 'Jane Smith' }
    ];

    fetch.mockResolvedValueOnce({
      json: async () => mockUsers
    });

    render(<UserList />);

    // Wait for users to appear
    const userItems = await screen.findAllByRole('listitem');
    expect(userItems).toHaveLength(2);
    expect(screen.getByText('John Doe')).toBeInTheDocument();
    expect(screen.getByText('Jane Smith')).toBeInTheDocument();
  });

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

    render(<UserList />);

    // Wait for error message
    const errorMessage = await screen.findByText(/error: network error/i);
    expect(errorMessage).toBeInTheDocument();
  });

  test('loading indicator disappears after data loads', async () => {
    fetch.mockResolvedValueOnce({
      json: async () => [{ id: 1, name: 'Test User' }]
    });

    render(<UserList />);

    expect(screen.getByText(/loading users/i)).toBeInTheDocument();

    await waitFor(() => {
      expect(screen.queryByText(/loading users/i)).not.toBeInTheDocument();
    });
  });
});
Common Mistake: Forgetting to use await with async queries like findBy* or waitFor can lead to flaky tests. Always await async operations in tests.

User Event vs FireEvent

React Testing Library provides two ways to simulate user interactions:

FireEvent (Low-level):
import { render, screen, fireEvent } from '@testing-library/react';

test('using fireEvent', () => {
  render(<Input />);
  const input = screen.getByRole('textbox');

  // Fires a single event
  fireEvent.change(input, { target: { value: 'Hello' } });
  fireEvent.click(screen.getByRole('button'));
});
User Event (Recommended - High-level):
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

test('using userEvent', async () => {
  const user = userEvent.setup();
  render(<Input />);
  const input = screen.getByRole('textbox');

  // Simulates real user interactions (multiple events)
  await user.type(input, 'Hello');
  await user.click(screen.getByRole('button'));

  // Other useful methods:
  await user.hover(element);
  await user.unhover(element);
  await user.selectOptions(select, 'option1');
  await user.clear(input);
  await user.keyboard('{Enter}');
  await user.tab();
});
Best Practice: Prefer userEvent over fireEvent because it more closely simulates real user interactions by firing multiple events in the correct sequence.

Exercise 1: Test a Form Component

Create a login form component with email and password fields, and write tests for:

  • Rendering all form elements correctly
  • Validating empty fields show error messages
  • Calling onSubmit with correct data when form is valid
  • Disabling submit button while submitting

Exercise 2: Test a Search Component

Create a search component that fetches results from an API. Write tests for:

  • Showing no results initially
  • Displaying loading state while searching
  • Showing search results after successful API call
  • Handling API errors gracefully
  • Debouncing search input (advanced)

Exercise 3: Test a Toggle Component

Create a toggle switch component and write tests for:

  • Initial checked/unchecked state
  • Toggling state on click
  • Calling onChange callback with correct value
  • Accessibility (can be toggled with keyboard)
  • Disabled state prevents toggling

Jest Matchers for React Testing

Common matchers from @testing-library/jest-dom:

Useful Matchers:
// Presence
expect(element).toBeInTheDocument()
expect(element).not.toBeInTheDocument()

// Visibility
expect(element).toBeVisible()
expect(element).not.toBeVisible()

// Values
expect(input).toHaveValue('test')
expect(checkbox).toBeChecked()
expect(button).toBeDisabled()

// Text content
expect(element).toHaveTextContent('Hello')
expect(element).toContainHTML('<span>Test</span>')

// Attributes
expect(element).toHaveAttribute('href', '/home')
expect(element).toHaveClass('active')
expect(element).toHaveStyle({ color: 'red' })

// Form elements
expect(input).toBeRequired()
expect(input).toBeInvalid()
expect(select).toHaveDisplayValue('Option 1')

// Focus
expect(input).toHaveFocus()

// Accessibility
expect(element).toHaveAccessibleName('Submit button')
expect(element).toHaveAccessibleDescription('Click to submit')
Running Tests: Use npm test to run tests in watch mode, or npm test -- --coverage to see code coverage reports.