React Testing: Component and Integration Tests

• Component tests verify individual units in isolation while integration tests validate how multiple components work together—use component tests for reusable UI elements and integration tests for...

Key Insights

• Component tests verify individual units in isolation while integration tests validate how multiple components work together—use component tests for reusable UI elements and integration tests for complete user workflows • Query elements by accessibility roles and user-visible text rather than implementation details like CSS classes or component state to create resilient tests that survive refactoring • Mock external dependencies strategically at the boundaries of your application (API calls, routing, external services) but avoid mocking internal component logic that you should be testing

Introduction to React Testing Strategy

The testing pyramid applies to React applications with a specific twist: most of your tests should be component and integration tests, not unit tests of individual functions. React components are inherently integration points between markup, logic, and user interaction, so testing them in complete isolation often provides limited value.

Component tests focus on a single component’s behavior—does the button call the correct handler when clicked? Does the form validate inputs properly? These tests treat the component as a black box, interacting with it as a user would.

Integration tests verify that multiple components work together correctly. When a user adds an item to their cart, does it appear in the cart summary? When they submit a form, does the parent component receive the data? These tests provide higher confidence but take longer to run and can be harder to debug.

The key decision point: if the component has complex internal logic or will be reused across your application, write component tests. If you’re testing a complete user workflow that spans multiple components, write integration tests. Avoid testing implementation details in both cases.

Setting Up Your Testing Environment

Jest and React Testing Library form the standard testing stack for React applications. Jest provides the test runner, assertion library, and mocking capabilities. React Testing Library provides utilities for rendering components and querying the DOM in ways that resemble how users interact with your application.

Most Create React App and Next.js projects include Jest by default. For custom setups, install the necessary packages:

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

Configure Jest to use the jsdom environment in your jest.config.js:

module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  moduleNameMapper: {
    '\\.(css|less|scss)$': 'identity-obj-proxy',
  },
};

Create a setup file to extend Jest’s matchers:

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

Here’s a basic test structure:

import { render, screen } from '@testing-library/react';
import { Welcome } from './Welcome';

describe('Welcome', () => {
  it('renders the welcome message', () => {
    render(<Welcome name="Alice" />);
    expect(screen.getByText('Welcome, Alice!')).toBeInTheDocument();
  });
});

Component Testing Fundamentals

Component tests should verify behavior from the user’s perspective. Query elements by their accessible roles, labels, or visible text—not by test IDs, CSS classes, or component internals.

Testing a button component with event handlers:

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

describe('Button', () => {
  it('calls onClick handler when clicked', async () => {
    const handleClick = jest.fn();
    const user = userEvent.setup();
    
    render(<Button onClick={handleClick}>Click me</Button>);
    
    await user.click(screen.getByRole('button', { name: /click me/i }));
    
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('disables the button when loading', () => {
    render(<Button isLoading>Submit</Button>);
    
    expect(screen.getByRole('button')).toBeDisabled();
  });
});

Testing form inputs with state changes:

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

describe('SearchInput', () => {
  it('updates value when user types', async () => {
    const user = userEvent.setup();
    const handleChange = jest.fn();
    
    render(<SearchInput onChange={handleChange} />);
    
    const input = screen.getByRole('textbox', { name: /search/i });
    await user.type(input, 'React Testing');
    
    expect(input).toHaveValue('React Testing');
    expect(handleChange).toHaveBeenCalledTimes(14); // Once per character
  });
});

Testing conditional rendering:

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

describe('UserProfile', () => {
  it('shows login prompt when user is not authenticated', () => {
    render(<UserProfile user={null} />);
    
    expect(screen.getByText(/please log in/i)).toBeInTheDocument();
    expect(screen.queryByRole('button', { name: /logout/i })).not.toBeInTheDocument();
  });

  it('shows user info when authenticated', () => {
    const user = { name: 'Jane Doe', email: 'jane@example.com' };
    render(<UserProfile user={user} />);
    
    expect(screen.getByText('Jane Doe')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /logout/i })).toBeInTheDocument();
  });
});

Testing User Interactions and Events

Use @testing-library/user-event instead of fireEvent for simulating user interactions. userEvent more accurately replicates browser behavior, including focus changes, keyboard events, and timing.

Testing a multi-step form with validation:

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

describe('RegistrationForm', () => {
  it('validates email format before submission', async () => {
    const user = userEvent.setup();
    const handleSubmit = jest.fn();
    
    render(<RegistrationForm onSubmit={handleSubmit} />);
    
    const emailInput = screen.getByLabelText(/email/i);
    const submitButton = screen.getByRole('button', { name: /register/i });
    
    await user.type(emailInput, 'invalid-email');
    await user.click(submitButton);
    
    expect(screen.getByText(/invalid email format/i)).toBeInTheDocument();
    expect(handleSubmit).not.toHaveBeenCalled();
  });

  it('submits form with valid data', async () => {
    const user = userEvent.setup();
    const handleSubmit = jest.fn();
    
    render(<RegistrationForm onSubmit={handleSubmit} />);
    
    await user.type(screen.getByLabelText(/email/i), 'user@example.com');
    await user.type(screen.getByLabelText(/password/i), 'SecurePass123');
    await user.click(screen.getByRole('button', { name: /register/i }));
    
    expect(handleSubmit).toHaveBeenCalledWith({
      email: 'user@example.com',
      password: 'SecurePass123',
    });
  });
});

Integration Testing Multiple Components

Integration tests verify complete workflows. These tests are closer to how users actually interact with your application.

Testing a TodoList application:

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

describe('TodoApp Integration', () => {
  it('adds and completes a todo item', async () => {
    const user = userEvent.setup();
    render(<TodoApp />);
    
    // Add a new todo
    const input = screen.getByPlaceholderText(/add a todo/i);
    await user.type(input, 'Buy groceries');
    await user.keyboard('{Enter}');
    
    // Verify it appears in the list
    expect(screen.getByText('Buy groceries')).toBeInTheDocument();
    expect(input).toHaveValue(''); // Input should clear
    
    // Mark it as complete
    const checkbox = screen.getByRole('checkbox', { name: /buy groceries/i });
    await user.click(checkbox);
    
    // Verify completion state
    expect(checkbox).toBeChecked();
    expect(screen.getByText('Buy groceries')).toHaveStyle({ textDecoration: 'line-through' });
  });
});

Testing a shopping cart flow:

import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ShoppingApp } from './ShoppingApp';

describe('Shopping Cart Integration', () => {
  it('adds items to cart and calculates total', async () => {
    const user = userEvent.setup();
    render(<ShoppingApp />);
    
    // Add first product
    const productList = screen.getByRole('list', { name: /products/i });
    const firstProduct = within(productList).getAllByRole('listitem')[0];
    await user.click(within(firstProduct).getByRole('button', { name: /add to cart/i }));
    
    // Verify cart updates
    expect(screen.getByText(/items in cart: 1/i)).toBeInTheDocument();
    
    // Add second product
    const secondProduct = within(productList).getAllByRole('listitem')[1];
    await user.click(within(secondProduct).getByRole('button', { name: /add to cart/i }));
    
    // Verify total calculation
    expect(screen.getByText(/items in cart: 2/i)).toBeInTheDocument();
    expect(screen.getByText(/total: \$59\.98/i)).toBeInTheDocument();
  });
});

Mocking and Test Doubles

Mock external dependencies at the boundaries of your application. Avoid mocking React internals or components you control.

Mocking API calls:

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

global.fetch = jest.fn();

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

  it('displays users from API', async () => {
    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => [
        { id: 1, name: 'Alice' },
        { id: 2, name: 'Bob' },
      ],
    });
    
    render(<UserList />);
    
    await waitFor(() => {
      expect(screen.getByText('Alice')).toBeInTheDocument();
      expect(screen.getByText('Bob')).toBeInTheDocument();
    });
  });

  it('shows error message on API failure', async () => {
    fetch.mockRejectedValueOnce(new Error('API Error'));
    
    render(<UserList />);
    
    await waitFor(() => {
      expect(screen.getByText(/failed to load users/i)).toBeInTheDocument();
    });
  });
});

Mocking React Context:

import { render, screen } from '@testing-library/react';
import { AuthContext } from './AuthContext';
import { Dashboard } from './Dashboard';

describe('Dashboard', () => {
  it('shows admin panel for admin users', () => {
    const mockAuthValue = {
      user: { name: 'Admin', role: 'admin' },
      isAuthenticated: true,
    };
    
    render(
      <AuthContext.Provider value={mockAuthValue}>
        <Dashboard />
      </AuthContext.Provider>
    );
    
    expect(screen.getByRole('button', { name: /admin panel/i })).toBeInTheDocument();
  });
});

Best Practices and Common Pitfalls

Query by accessibility roles and labels, not implementation details. This makes tests resilient to refactoring and ensures your components are accessible.

Avoid this:

// Fragile - breaks when CSS changes
const button = container.querySelector('.submit-button');

// Fragile - breaks when state structure changes
expect(component.state.isLoading).toBe(true);

Do this instead:

// Resilient - queries by user-visible role
const button = screen.getByRole('button', { name: /submit/i });

// Tests behavior, not implementation
expect(button).toBeDisabled();
expect(screen.getByText(/loading/i)).toBeInTheDocument();

Write tests that verify behavior, not implementation. If you refactor your component’s internals but maintain the same external behavior, your tests should still pass.

Avoid testing too many scenarios in a single test. Each test should verify one specific behavior. This makes failures easier to diagnose and tests easier to maintain.

Use screen.debug() when tests fail to see the current DOM state. Use logRoles() to discover available accessibility roles for querying elements.

Remember that testing is about confidence, not coverage. A few well-written integration tests provide more value than dozens of fragile unit tests that break with every refactoring. Focus on testing critical user paths and complex component interactions.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.