React Component Testing

Effective strategies for testing React components with Jest and Testing Library

Why Test React Components?

React components are the building blocks of your user interface, making them critical to your application's functionality and user experience. Testing them thoroughly helps ensure:

Real-world analogy: Think of component testing as quality control for car manufacturing. Before assembling the entire car (your application), you need to ensure that each part (component) functions correctly both individually and when integrated with other parts.

graph TD A[Component Behavior] --> B[Rendering] A --> C[User Interactions] A --> D[Props Handling] A --> E[State Management] A --> F[Side Effects] A --> G[Error States] B --> H[Testing Library] C --> H D --> H E --> H F --> H G --> H H --> I[Reliable Application]

React Testing Tools

React Testing Library

React Testing Library (RTL) has become the standard for testing React components. It's a lightweight solution focused on testing components from the user's perspective, encouraging good testing practices.

flowchart LR A[Traditional Testing] --> B[Tests Implementation] C[React Testing Library] --> D[Tests User Behavior] B --> E[Brittle Tests] D --> F[Resilient Tests]

Setting Up the Testing Environment

If you're using Create React App, Jest and React Testing Library are included out of the box. Otherwise, you'll need to install:

npm install --save-dev @testing-library/react @testing-library/jest-dom jest

And add to your Jest setup file:

// setupTests.js
import '@testing-library/jest-dom';

Other Useful Testing Tools

React Testing Library Philosophy

"The more your tests resemble the way your software is used, the more confidence they can give you." - Kent C. Dodds, creator of Testing Library

Key principles that guide React Testing Library:

Real-world example: Testing Library encourages you to think like a user, not a developer. Users don't care about component state or props - they care about what they see and interact with. Similarly, shoppers don't care about the internal store inventory system - they care about finding and purchasing products.

Basic Component Testing

Let's create and test a simple button component that changes text when clicked:

// ToggleButton.jsx
import React, { useState } from 'react';

function ToggleButton({ initialText, toggledText }) {
  const [isToggled, setIsToggled] = useState(false);
  
  return (
    <button 
      onClick={() => setIsToggled(!isToggled)}
      data-testid="toggle-button"
    >
      {isToggled ? toggledText : initialText}
    </button>
  );
}

export default ToggleButton;

Now, let's write a test for this component:

// ToggleButton.test.jsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import ToggleButton from './ToggleButton';

describe('ToggleButton', () => {
  test('displays initial text when not clicked', () => {
    render(<ToggleButton initialText="Click me" toggledText="Clicked!" />);
    
    // Find the button by its text
    const buttonElement = screen.getByText(/click me/i);
    
    // Assert that the button exists and has the right text
    expect(buttonElement).toBeInTheDocument();
  });
  
  test('displays toggled text after being clicked', () => {
    render(<ToggleButton initialText="Click me" toggledText="Clicked!" />);
    
    // Find the button (using the test ID this time, for demonstration)
    const buttonElement = screen.getByTestId('toggle-button');
    
    // Simulate a user clicking the button
    fireEvent.click(buttonElement);
    
    // Check that the text changed
    expect(buttonElement).toHaveTextContent(/clicked!/i);
    
    // Click again and check that it goes back to initial text
    fireEvent.click(buttonElement);
    expect(buttonElement).toHaveTextContent(/click me/i);
  });
});

Querying Elements

React Testing Library provides several methods to query elements, with each having a different priority based on accessibility best practices:

Query Variants

Query Methods (from highest to lowest priority)

// Recommended queries (in order of preference)
const button = screen.getByRole('button', { name: /submit/i });
const input = screen.getByLabelText(/email/i);
const heading = screen.getByText(/welcome/i);
const img = screen.getByAltText(/profile picture/i);
const element = screen.getByTitle(/information/i);

// Less recommended queries
const custom = screen.getByTestId('custom-element');

// Examples of variant usage
// Check if an error message is NOT present
expect(screen.queryByText(/error/i)).not.toBeInTheDocument();

// Wait for loading message to disappear
await waitForElementToBeRemoved(() => 
  screen.queryByText(/loading/i)
);

Real-world analogy: Think of these query methods as different ways to identify people in a crowd. You could identify someone by their role ("the chef"), by their name tag, by what they're saying, or as a last resort, by a special badge you gave them. The most natural and accessible methods should be preferred.

Testing User Interactions

To test user interactions, React Testing Library provides two approaches: fireEvent and userEvent.

Using fireEvent

// Basic fireEvent usage
import { fireEvent } from '@testing-library/react';

test('input value updates when typed into', () => {
  render(<input data-testid="input" />);
  const input = screen.getByTestId('input');
  
  fireEvent.change(input, { target: { value: 'Hello World' } });
  
  expect(input).toHaveValue('Hello World');
});

Using userEvent (preferred)

userEvent provides a more realistic simulation of user behavior:

// More realistic userEvent usage
import userEvent from '@testing-library/user-event';

test('input value updates when typed into', async () => {
  const user = userEvent.setup();
  render(<input data-testid="input" />);
  const input = screen.getByTestId('input');
  
  // This actually simulates keypress events for each character
  await user.type(input, 'Hello World');
  
  expect(input).toHaveValue('Hello World');
});

Common User Interactions

// Click
await user.click(button);

// Double-click
await user.dblClick(button);

// Typing
await user.type(input, 'Hello');

// Keyboard interactions
await user.keyboard('{Enter}');

// Clearing input
await user.clear(input);

// Selecting options
await user.selectOptions(select, ['option1', 'option2']);

Testing a Form Component

Let's test a more complex signup form component:

// SignupForm.jsx
import React, { useState } from 'react';

function SignupForm({ onSubmit }) {
  const [formData, setFormData] = useState({
    username: '',
    email: '',
    password: ''
  });
  const [errors, setErrors] = useState({});
  
  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
  };
  
  const validateForm = () => {
    const newErrors = {};
    
    if (!formData.username) {
      newErrors.username = 'Username is required';
    }
    
    if (!formData.email) {
      newErrors.email = 'Email is required';
    } else if (!/\S+@\S+\.\S+/.test(formData.email)) {
      newErrors.email = 'Email is invalid';
    }
    
    if (!formData.password) {
      newErrors.password = 'Password is required';
    } else if (formData.password.length < 6) {
      newErrors.password = 'Password must be at least 6 characters';
    }
    
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    if (validateForm()) {
      onSubmit(formData);
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="username">Username</label>
        <input
          id="username"
          name="username"
          value={formData.username}
          onChange={handleChange}
          aria-describedby={errors.username ? "username-error" : undefined}
        />
        {errors.username && (
          <span id="username-error" role="alert">{errors.username}</span>
        )}
      </div>
      
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          value={formData.email}
          onChange={handleChange}
          aria-describedby={errors.email ? "email-error" : undefined}
        />
        {errors.email && (
          <span id="email-error" role="alert">{errors.email}</span>
        )}
      </div>
      
      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          name="password"
          type="password"
          value={formData.password}
          onChange={handleChange}
          aria-describedby={errors.password ? "password-error" : undefined}
        />
        {errors.password && (
          <span id="password-error" role="alert">{errors.password}</span>
        )}
      </div>
      
      <button type="submit">Sign Up</button>
    </form>
  );
}

export default SignupForm;

Now, let's write tests for this component:

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

describe('SignupForm', () => {
  test('renders all form fields and submit button', () => {
    render(<SignupForm onSubmit={() => {}} />);
    
    // Check that all inputs and button exist
    expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /sign up/i })).toBeInTheDocument();
  });
  
  test('displays validation errors when form is submitted with empty fields', async () => {
    const user = userEvent.setup();
    render(<SignupForm onSubmit={() => {}} />);
    
    // Submit the form without filling any fields
    await user.click(screen.getByRole('button', { name: /sign up/i }));
    
    // Check that error messages are displayed
    expect(screen.getByText(/username is required/i)).toBeInTheDocument();
    expect(screen.getByText(/email is required/i)).toBeInTheDocument();
    expect(screen.getByText(/password is required/i)).toBeInTheDocument();
  });
  
  test('displays validation error for invalid email format', async () => {
    const user = userEvent.setup();
    render(<SignupForm onSubmit={() => {}} />);
    
    // Enter an invalid email
    await user.type(screen.getByLabelText(/email/i), 'invalid-email');
    
    // Submit the form
    await user.click(screen.getByRole('button', { name: /sign up/i }));
    
    // Check that the correct error message is displayed
    expect(screen.getByText(/email is invalid/i)).toBeInTheDocument();
  });
  
  test('displays validation error for short password', async () => {
    const user = userEvent.setup();
    render(<SignupForm onSubmit={() => {}} />);
    
    // Enter a short password
    await user.type(screen.getByLabelText(/password/i), 'short');
    
    // Submit the form
    await user.click(screen.getByRole('button', { name: /sign up/i }));
    
    // Check that the correct error message is displayed
    expect(screen.getByText(/password must be at least 6 characters/i)).toBeInTheDocument();
  });
  
  test('calls onSubmit with form data when valid form is submitted', async () => {
    const user = userEvent.setup();
    const mockSubmit = jest.fn();
    render(<SignupForm onSubmit={mockSubmit} />);
    
    // Fill out the form with valid data
    await user.type(screen.getByLabelText(/username/i), 'testuser');
    await user.type(screen.getByLabelText(/email/i), 'test@example.com');
    await user.type(screen.getByLabelText(/password/i), 'password123');
    
    // Submit the form
    await user.click(screen.getByRole('button', { name: /sign up/i }));
    
    // Check that onSubmit was called with the correct data
    expect(mockSubmit).toHaveBeenCalledTimes(1);
    expect(mockSubmit).toHaveBeenCalledWith({
      username: 'testuser',
      email: 'test@example.com',
      password: 'password123'
    });
  });
});

Testing Asynchronous Components

Many React components perform asynchronous operations, such as API calls or delayed updates. Testing these requires special approaches.

A Simple Async Component

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

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);
        setError(null);
        
        // Simulate API call
        const response = await fetch(`https://api.example.com/users/${userId}`);
        
        if (!response.ok) {
          throw new Error('Failed to fetch user');
        }
        
        const userData = await response.json();
        setUser(userData);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }
    
    fetchUser();
  }, [userId]);
  
  if (loading) {
    return <div>Loading user data...</div>;
  }
  
  if (error) {
    return <div role="alert">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>
  );
}

Mocking Fetch Calls

We need to mock the fetch API to test this component:

// UserProfile.test.jsx
import React from 'react';
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react';
import UserProfile from './UserProfile';

// Mock the fetch function
global.fetch = jest.fn();

describe('UserProfile', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });
  
  test('shows loading state initially', () => {
    // Mock fetch to return a promise that never resolves
    global.fetch.mockImplementation(() => new Promise(() => {}));
    
    render(<UserProfile userId="123" />);
    
    expect(screen.getByText(/loading user data/i)).toBeInTheDocument();
  });
  
  test('renders user data when fetch succeeds', async () => {
    // Mock successful fetch response
    global.fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => ({
        id: '123',
        name: 'John Doe',
        email: 'john@example.com',
        role: 'Admin'
      })
    });
    
    render(<UserProfile userId="123" />);
    
    // Wait for loading message to disappear
    await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
    
    // Check that user data is displayed
    expect(screen.getByText('John Doe')).toBeInTheDocument();
    expect(screen.getByText(/email: john@example.com/i)).toBeInTheDocument();
    expect(screen.getByText(/role: admin/i)).toBeInTheDocument();
  });
  
  test('shows error message when fetch fails', async () => {
    // Mock failed fetch response
    global.fetch.mockResolvedValueOnce({
      ok: false
    });
    
    render(<UserProfile userId="123" />);
    
    // Wait for loading message to disappear
    await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
    
    // Check that error message is displayed
    expect(screen.getByRole('alert')).toHaveTextContent(/failed to fetch user/i);
  });
});

Using Mock Service Worker (MSW)

For more complex API mocking, Mock Service Worker is a better solution:

// Example setup with MSW
import { rest } from 'msw';
import { setupServer } from 'msw/node';

// Setup request handlers
const server = setupServer(
  rest.get('https://api.example.com/users/:userId', (req, res, ctx) => {
    const { userId } = req.params;
    
    if (userId === '123') {
      return res(
        ctx.json({
          id: '123',
          name: 'John Doe',
          email: 'john@example.com',
          role: 'Admin'
        })
      );
    }
    
    return res(ctx.status(404));
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Testing React Hooks

Testing custom hooks requires a slightly different approach. React Testing Library provides a renderHook utility for this purpose.

// useCounter.js - a simple custom hook
import { useState, useCallback } from 'react';

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 };
}

export default useCounter;

Testing this hook:

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

describe('useCounter', () => {
  test('initializes with default value of 0', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });
  
  test('initializes with provided value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });
  
  test('increments the counter', () => {
    const { result } = renderHook(() => useCounter());
    
    act(() => {
      result.current.increment();
    });
    
    expect(result.current.count).toBe(1);
  });
  
  test('decrements the counter', () => {
    const { result } = renderHook(() => useCounter(5));
    
    act(() => {
      result.current.decrement();
    });
    
    expect(result.current.count).toBe(4);
  });
  
  test('resets the counter', () => {
    const { result } = renderHook(() => useCounter(5));
    
    act(() => {
      result.current.increment();
      result.current.increment();
    });
    
    expect(result.current.count).toBe(7);
    
    act(() => {
      result.current.reset();
    });
    
    expect(result.current.count).toBe(5);
  });
  
  test('updates when initial value changes', () => {
    const { result, rerender } = renderHook(({ initialValue }) => useCounter(initialValue), {
      initialProps: { initialValue: 0 }
    });
    
    // Rerender the hook with new props
    rerender({ initialValue: 10 });
    
    act(() => {
      result.current.reset();
    });
    
    expect(result.current.count).toBe(10);
  });
});

Best Practices for React Component Testing

mindmap root((React Testing
Best Practices)) Test from user perspective Find elements by accessibility attributes Test interactions as a user would Keep tests focused Test one behavior per test Use clear test descriptions Avoid testing implementation details Organize tests effectively Group related tests Use descriptive test names Consistent patterns Handle async properly Wait for elements to appear Mock external dependencies Clear async patterns Consider edge cases Error states Loading states Empty states

Real-world example: At GitHub, testing React components is a critical part of their development process. They discovered that focusing on testing user interactions rather than implementation details made their tests more maintainable as the codebase evolved. When they rewrote parts of their UI, tests that followed RTL principles needed minimal changes, while tests that were tightly coupled to implementation details had to be completely rewritten.

Component Testing Patterns

Testing Props and Rendering

test('renders correctly with different props', () => {
  const { rerender } = render(<Greeting name="John" />);
  expect(screen.getByText(/hello, john/i)).toBeInTheDocument();
  
  // Re-render with different props
  rerender(<Greeting name="Jane" />);
  expect(screen.getByText(/hello, jane/i)).toBeInTheDocument();
});

Testing Conditional Rendering

test('shows premium content when user is premium', () => {
  render(<Dashboard userType="premium" />);
  expect(screen.getByText(/premium content/i)).toBeInTheDocument();
  
  // Use queryBy for elements that might not exist
  expect(screen.queryByText(/upgrade to premium/i)).not.toBeInTheDocument();
});

Testing User Interactions

test('updates counter when button is clicked', async () => {
  const user = userEvent.setup();
  render(<Counter initialCount={0} />);
  
  expect(screen.getByText(/count: 0/i)).toBeInTheDocument();
  
  await user.click(screen.getByRole('button', { name: /increment/i }));
  
  expect(screen.getByText(/count: 1/i)).toBeInTheDocument();
});

Testing Forms

test('submits form with user input', async () => {
  const user = userEvent.setup();
  const handleSubmit = jest.fn();
  
  render(<LoginForm onSubmit={handleSubmit} />);
  
  await user.type(screen.getByLabelText(/username/i), 'testuser');
  await user.type(screen.getByLabelText(/password/i), 'password123');
  await user.click(screen.getByRole('button', { name: /log in/i }));
  
  expect(handleSubmit).toHaveBeenCalledWith({
    username: 'testuser',
    password: 'password123'
  });
});

Debugging Component Tests

When tests fail, React Testing Library provides several utilities to help you debug:

// Visual debugging
import { screen } from '@testing-library/react';

test('debugging example', () => {
  render(<MyComponent />);
  
  // Print the DOM to console
  screen.debug();
  
  // Print a specific element
  screen.debug(screen.getByRole('button'));
});

// logRoles helper for accessibility debugging
import { logRoles } from '@testing-library/dom';

test('log roles for debugging', () => {
  const { container } = render(<MyComponent />);
  
  // Log all the accessible roles
  logRoles(container);
});

Common debugging techniques:

Testing Connected Components

Testing components connected to state management libraries like Redux or Context API requires some special handling:

Testing Components with Context

// Custom render function with ThemeContext provider
const customRender = (ui, { theme = 'light', ...renderOptions } = {}) => {
  return render(
    <ThemeContext.Provider value={{ theme }}>
      {ui}
    </ThemeContext.Provider>,
    renderOptions
  );
};

test('renders with theme from context', () => {
  customRender(<ThemedButton />, { theme: 'dark' });
  
  expect(screen.getByRole('button')).toHaveClass('dark-theme');
});

Testing Redux Connected Components

// test-utils.js
import { render } from '@testing-library/react';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import rootReducer from '../reducers';

function renderWithRedux(
  ui,
  { initialState, store = createStore(rootReducer, initialState) } = {}
) {
  return {
    ...render(<Provider store={store}>{ui}</Provider>),
    store
  };
}

// Component test
test('counter works with redux store', async () => {
  const user = userEvent.setup();
  const { store } = renderWithRedux(<ConnectedCounter />, {
    initialState: { counter: { value: 5 } }
  });
  
  expect(screen.getByText(/count: 5/i)).toBeInTheDocument();
  
  await user.click(screen.getByRole('button', { name: /increment/i }));
  
  expect(screen.getByText(/count: 6/i)).toBeInTheDocument();
  expect(store.getState().counter.value).toBe(6);
});

Practical Exercise

Exercise: Testing a Todo List Component

Let's practice by writing tests for a todo list component:

// TodoList.jsx
import React, { useState } from 'react';

function TodoList() {
  const [todos, setTodos] = useState([]);
  const [inputValue, setInputValue] = useState('');
  
  const handleInputChange = (e) => {
    setInputValue(e.target.value);
  };
  
  const handleAddTodo = () => {
    if (inputValue.trim()) {
      setTodos([...todos, { id: Date.now(), text: inputValue, completed: false }]);
      setInputValue('');
    }
  };
  
  const handleToggleTodo = (id) => {
    setTodos(
      todos.map(todo =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };
  
  const handleDeleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };
  
  return (
    <div>
      <h2>Todo List</h2>
      
      <div>
        <label htmlFor="new-todo">Add Todo:</label>
        <input
          id="new-todo"
          value={inputValue}
          onChange={handleInputChange}
          aria-label="New todo"
        />
        <button onClick={handleAddTodo}>Add</button>
      </div>
      
      {todos.length === 0 ? (
        <p>No todos yet. Add one above!</p>
      ) : (
        <ul>
          {todos.map(todo => (
            <li key={todo.id}>
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => handleToggleTodo(todo.id)}
                aria-label={`Mark "${todo.text}" as ${todo.completed ? 'incomplete' : 'complete'}`}
              />
              <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
                {todo.text}
              </span>
              <button 
                onClick={() => handleDeleteTodo(todo.id)}
                aria-label={`Delete "${todo.text}"`}
              >
                Delete
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

export default TodoList;

Write tests to verify:

  1. Initial state shows "No todos yet" message
  2. Adding a todo updates the list
  3. Marking a todo as complete adds line-through styling
  4. Deleting a todo removes it from the list

Summary

Remember: Good component tests serve as both a safety net for refactoring and as documentation for how components should behave.

Assignment

Create a complete test suite for a shopping cart component with the following features:

  1. Display a list of products with name, price, and "Add to Cart" button
  2. Show a cart summary with added items, quantities, and total price
  3. Allow increasing/decreasing quantities of items in the cart
  4. Remove items from the cart
  5. Apply a discount code

Your assignment should include:

Bonus: Use context API for state management and test components that consume the context.