React Component Testing
Introduction
Testing your React components is a critical part of creating reliable, maintainable applications. Component testing helps you verify that your UI components render correctly and behave as expected when users interact with them. This practice gives you confidence that your application will work properly and makes it easier to refactor code without introducing bugs.
In this guide, we'll explore how to test React components using Jest as a test runner and React Testing Library for rendering and interacting with your components in a way that simulates real user behavior.
Why Test React Components?
Before diving into the "how," let's understand the "why" behind component testing:
- Catch bugs early: Identify issues before they reach production
- Document expected behavior: Tests serve as living documentation
- Support refactoring: Change implementation details without breaking functionality
- Enable collaboration: Help team members understand how components should behave
- Improve component design: Testing often reveals ways to improve your component structure
Setting Up Your Testing Environment
Prerequisites
To follow along, you'll need:
- A React project (either Create React App or custom setup)
- Jest testing framework
- React Testing Library
If you're using Create React App, Jest and Testing Library are already set up. Otherwise, install the required dependencies:
npm install --save-dev jest @testing-library/react @testing-library/jest-dom @testing-library/user-event
Configure Jest
Create a jest.config.js file in your project root (if you don't have one already):
module.exports = {
  testEnvironment: "jsdom",
  moduleNameMapper: {
    "\\.(css|less|scss|sass)$": "identity-obj-proxy"
  },
  setupFilesAfterEnv: [
    "<rootDir>/src/setupTests.js"
  ]
};
Create src/setupTests.js to import Jest DOM matchers:
import '@testing-library/jest-dom';
Your First Component Test
Let's start with a simple component test. Here's a basic Button component:
// Button.js
import React from 'react';
function Button({ onClick, children }) {
  return (
    <button 
      className="button"
      onClick={onClick}
    >
      {children}
    </button>
  );
}
export default Button;
Now, let's create a test file for this component:
// Button.test.js
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';
describe('Button component', () => {
  test('renders button with correct text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });
  test('calls onClick prop when clicked', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click me</Button>);
    
    fireEvent.click(screen.getByText('Click me'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });
});
Breaking Down the Tests
- Import testing utilities: We import the necessary functions from Testing Library
- Group related tests: We use describeto group related tests
- Rendering: The renderfunction creates a virtual DOM for the component
- Querying: We use screen.getByText()to find elements by their text content
- Assertions: We use expect()with matchers liketoBeInTheDocument()
- Event simulation: We use fireEventto simulate user interactions
- Mock functions: We use jest.fn()to create a mock function to verify it gets called
Testing Complex Components
Now, let's test a more complex component:
// UserProfile.js
import React, { useState } from 'react';
function UserProfile({ user, onUpdate }) {
  const [isEditing, setIsEditing] = useState(false);
  const [name, setName] = useState(user.name);
  const handleSubmit = (e) => {
    e.preventDefault();
    onUpdate({ ...user, name });
    setIsEditing(false);
  };
  return (
    <div className="user-profile">
      <h2 data-testid="profile-title">User Profile</h2>
      
      {isEditing ? (
        <form onSubmit={handleSubmit}>
          <label htmlFor="name">Name:</label>
          <input
            id="name"
            value={name}
            onChange={(e) => setName(e.target.value)}
          />
          <button type="submit">Save</button>
        </form>
      ) : (
        <>
          <p>Name: {user.name}</p>
          <p>Email: {user.email}</p>
          <button onClick={() => setIsEditing(true)}>Edit</button>
        </>
      )}
    </div>
  );
}
export default UserProfile;
Let's write tests for this component:
// UserProfile.test.js
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import UserProfile from './UserProfile';
describe('UserProfile component', () => {
  const mockUser = {
    id: 1,
    name: 'John Doe',
    email: '[email protected]'
  };
  
  const mockUpdate = jest.fn();
  test('renders user information correctly', () => {
    render(<UserProfile user={mockUser} onUpdate={mockUpdate} />);
    
    expect(screen.getByTestId('profile-title')).toHaveTextContent('User Profile');
    expect(screen.getByText(`Name: ${mockUser.name}`)).toBeInTheDocument();
    expect(screen.getByText(`Email: ${mockUser.email}`)).toBeInTheDocument();
  });
  test('switches to edit mode when Edit button is clicked', () => {
    render(<UserProfile user={mockUser} onUpdate={mockUpdate} />);
    
    fireEvent.click(screen.getByText('Edit'));
    
    // Now in edit mode - input should be visible
    expect(screen.getByLabelText('Name:')).toBeInTheDocument();
    expect(screen.getByText('Save')).toBeInTheDocument();
  });
  test('updates user name and calls onUpdate when form is submitted', async () => {
    render(<UserProfile user={mockUser} onUpdate={mockUpdate} />);
    
    // Enter edit mode
    fireEvent.click(screen.getByText('Edit'));
    
    // Change input value
    const input = screen.getByLabelText('Name:');
    fireEvent.change(input, { target: { value: 'Jane Doe' } });
    
    // Submit form
    fireEvent.submit(screen.getByRole('form'));
    
    // Check that onUpdate was called with updated user
    expect(mockUpdate).toHaveBeenCalledWith({
      ...mockUser,
      name: 'Jane Doe'
    });
    
    // Component should exit edit mode
    expect(screen.queryByLabelText('Name:')).not.toBeInTheDocument();
  });
});
Advanced Testing Techniques
Testing with Context
Many React applications use Context API. Here's how to test components that use context:
// ThemeContext.js
import React, { createContext, useContext, useState } from 'react';
const ThemeContext = createContext();
export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  
  const toggleTheme = () => {
    setTheme(theme === 'light' ? 'dark' : 'light');
  };
  
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}
export function useTheme() {
  return useContext(ThemeContext);
}
// ThemeToggle.js
import React from 'react';
import { useTheme } from './ThemeContext';
function ThemeToggle() {
  const { theme, toggleTheme } = useTheme();
  
  return (
    <button onClick={toggleTheme}>
      Current theme: {theme} (Click to toggle)
    </button>
  );
}
export default ThemeToggle;
Testing this component:
// ThemeToggle.test.js
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import ThemeToggle from './ThemeToggle';
import { ThemeProvider } from './ThemeContext';
// Create a wrapper component that includes the context provider
const renderWithThemeContext = (ui) => {
  return render(
    <ThemeProvider>{ui}</ThemeProvider>
  );
};
describe('ThemeToggle component', () => {
  test('displays current theme and toggles when clicked', () => {
    renderWithThemeContext(<ThemeToggle />);
    
    // Initial theme should be light
    expect(screen.getByRole('button')).toHaveTextContent('Current theme: light');
    
    // Click to toggle theme
    fireEvent.click(screen.getByRole('button'));
    
    // Theme should be updated to dark
    expect(screen.getByRole('button')).toHaveTextContent('Current theme: dark');
  });
});
Testing Asynchronous Components
For components that make API calls, we need to test asynchronous behavior:
// UserList.js
import React, { useState, useEffect } from 'react';
function UserList({ api }) {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  useEffect(() => {
    const fetchUsers = async () => {
      try {
        setLoading(true);
        const data = await api.getUsers();
        setUsers(data);
        setLoading(false);
      } catch (err) {
        setError('Failed to fetch users');
        setLoading(false);
      }
    };
    
    fetchUsers();
  }, [api]);
  if (loading) return <div>Loading users...</div>;
  if (error) return <div>{error}</div>;
  
  return (
    <div>
      <h2>User List</h2>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}
export default UserList;
Testing this component:
// UserList.test.js
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import UserList from './UserList';
describe('UserList component', () => {
  test('displays loading state initially', () => {
    const mockApi = { getUsers: jest.fn() };
    render(<UserList api={mockApi} />);
    
    expect(screen.getByText('Loading users...')).toBeInTheDocument();
  });
  test('displays users when data is fetched successfully', async () => {
    const mockUsers = [
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' }
    ];
    
    const mockApi = { 
      getUsers: jest.fn().mockResolvedValue(mockUsers) 
    };
    
    render(<UserList api={mockApi} />);
    
    // Wait for users to be displayed
    await waitFor(() => {
      expect(screen.getByText('User List')).toBeInTheDocument();
    });
    
    // Check that both users are displayed
    expect(screen.getByText('Alice')).toBeInTheDocument();
    expect(screen.getByText('Bob')).toBeInTheDocument();
  });
  test('displays error message when API call fails', async () => {
    const mockApi = { 
      getUsers: jest.fn().mockRejectedValue(new Error('API Error')) 
    };
    
    render(<UserList api={mockApi} />);
    
    // Wait for error message
    await waitFor(() => {
      expect(screen.getByText('Failed to fetch users')).toBeInTheDocument();
    });
  });
});
Best Practices for React Component Testing
Do:
- Test behavior, not implementation: Focus on what the component does, not how it does it.
- Use data-testid for elements without accessible attributes: If an element doesn't have text or a role, add a data-testidattribute.
- Use user-centric queries: Prefer queries like getByRole,getByLabelText, andgetByTextovergetByTestId.
- Test accessibility: Ensure your components can be used by people who use assistive technologies.
- Test error states: Check that your components handle errors gracefully.
Don't:
- Test implementation details: Avoid testing state directly or internal functions.
- Use snapshot tests exclusively: They're useful but should complement, not replace, behavioral tests.
- Query by class or ID selectors: These are implementation details that may change.
- Mock everything: Sometimes it's better to test integration points.
Testing Component Composition
Let's test how components work together:
// TodoApp.js
import React, { useState } from 'react';
function TodoForm({ addTodo }) {
  const [input, setInput] = useState('');
  
  const handleSubmit = (e) => {
    e.preventDefault();
    if (!input.trim()) return;
    addTodo(input);
    setInput('');
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input 
        value={input} 
        onChange={(e) => setInput(e.target.value)} 
        placeholder="Add todo"
        aria-label="New todo"
      />
      <button type="submit">Add</button>
    </form>
  );
}
function TodoList({ todos, toggleTodo }) {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          <label>
            <input 
              type="checkbox" 
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)} 
            />
            <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
              {todo.text}
            </span>
          </label>
        </li>
      ))}
    </ul>
  );
}
function TodoApp() {
  const [todos, setTodos] = useState([]);
  
  const addTodo = (text) => {
    setTodos([...todos, { 
      id: Date.now(), 
      text, 
      completed: false 
    }]);
  };
  
  const toggleTodo = (id) => {
    setTodos(todos.map(todo => 
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };
  
  return (
    <div>
      <h1>Todo App</h1>
      <TodoForm addTodo={addTodo} />
      <TodoList todos={todos} toggleTodo={toggleTodo} />
    </div>
  );
}
export { TodoApp, TodoForm, TodoList };
Testing the composition:
// TodoApp.test.js
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { TodoApp } from './TodoApp';
describe('TodoApp integration', () => {
  test('allows adding and toggling todos', () => {
    render(<TodoApp />);
    
    // Verify initial state
    expect(screen.getByText('Todo App')).toBeInTheDocument();
    expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
    
    // Add a todo
    const input = screen.getByLabelText('New todo');
    fireEvent.change(input, { target: { value: 'Buy milk' } });
    fireEvent.click(screen.getByText('Add'));
    
    // Verify todo was added
    expect(screen.getByText('Buy milk')).toBeInTheDocument();
    
    // Check that input was cleared
    expect(input.value).toBe('');
    
    // Add another todo
    fireEvent.change(input, { target: { value: 'Take out trash' } });
    fireEvent.click(screen.getByText('Add'));
    
    // Now we should have two todos
    expect(screen.getByText('Buy milk')).toBeInTheDocument();
    expect(screen.getByText('Take out trash')).toBeInTheDocument();
    
    // Toggle a todo as completed
    const firstTodoCheckbox = screen.getAllByRole('checkbox')[0];
    fireEvent.click(firstTodoCheckbox);
    
    // Verify todo was marked as completed (has line-through style)
    const firstTodoText = screen.getByText('Buy milk');
    expect(firstTodoText).toHaveStyle('text-decoration: line-through');
    
    // The second todo should still be uncompleted
    const secondTodoText = screen.getByText('Take out trash');
    expect(secondTodoText).toHaveStyle('text-decoration: none');
  });
});
Understanding Testing Approaches
There are three primary testing approaches for React components:
Each approach has its strengths. In practice, you should use a combination of all three, with more emphasis on unit and integration tests due to their speed and reliability.
Summary
React component testing is a crucial skill for building reliable applications. In this guide, we've covered:
- Setting up a testing environment with Jest and React Testing Library
- Writing basic component tests that verify rendering and interactions
- Testing complex components with state and forms
- Handling context, asynchronous code, and component composition
- Best practices for effective component testing
By applying these techniques, you can build a robust test suite that gives you confidence in your React components and helps prevent regressions as your application evolves.
Exercises
To reinforce your learning, try these exercises:
- Write tests for a simple counter component that increments and decrements a value
- Create tests for a form component that validates user input
- Test a component that fetches data from an API
- Write tests for a component that uses React Router
- Test a component that uses a third-party library like a date picker or modal
Additional Resources
- React Testing Library Documentation
- Jest Documentation
- Common Testing Library Mistakes
- Testing Implementation Details
- Static vs Unit vs Integration vs E2E Testing
With these resources and the techniques you've learned, you'll be well-equipped to write effective tests for your React components.
💡 Found a typo or mistake? Click "Edit this page" to suggest a correction. Your feedback is greatly appreciated!