React
06 / 07

Testing Strategies

React Testing Strategies

Comprehensive guide to testing React applications - from unit tests to end-to-end testing, covering tools, best practices, and real-world examples:

Testing Philosophy

The testing pyramid for React applications consists of three layers: Unit Tests (70%), Integration Tests (20%), and End-to-End Tests (10%). Focus on testing behavior, not implementation details.

Jest - The Testing Framework

Jest is a delightful JavaScript testing framework with a focus on simplicity. It works out of the box with React and provides mocking, assertions, and code coverage.

Basic Jest Test

// sum.js
export function sum(a, b) {
  return a + b;
}

// sum.test.js
import { sum } from './sum';

describe('sum function', () => {
  it('adds two numbers correctly', () => {
    expect(sum(1, 2)).toBe(3);
  });
  
  it('handles negative numbers', () => {
    expect(sum(-1, 1)).toBe(0);
  });
});

Mocking with Jest

// Mock API calls
jest.mock('./api');

import { fetchUser } from './api';

test('fetches user data', async () => {
  fetchUser.mockResolvedValue({
    id: 1,
    name: 'John Doe',
    email: 'john@example.com'
  });
  
  const user = await fetchUser(1);
  expect(user.name).toBe('John Doe');
  expect(fetchUser).toHaveBeenCalledWith(1);
});

React Testing Library

React Testing Library builds on top of DOM Testing Library by adding APIs for working with React components. It encourages testing from a user's perspective.

Basic Component Test

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

describe('Counter Component', () => {
  it('renders with initial count', () => {
    render(<Counter initialCount={0} />);
    expect(screen.getByText(/count: 0/i)).toBeInTheDocument();
  });
  
  it('increments count when button is clicked', () => {
    render(<Counter initialCount={0} />);
    const button = screen.getByRole('button', { name: /increment/i });
    
    fireEvent.click(button);
    expect(screen.getByText(/count: 1/i)).toBeInTheDocument();
    
    fireEvent.click(button);
    expect(screen.getByText(/count: 2/i)).toBeInTheDocument();
  });
});

Testing Async Components

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

test('loads and displays user data', async () => {
  // Mock API
  global.fetch = jest.fn(() =>
    Promise.resolve({
      json: () => Promise.resolve({ name: 'John', email: 'john@example.com' }),
    })
  );
  
  render(<UserProfile userId={1} />);
  
  // Initially shows loading
  expect(screen.getByText(/loading/i)).toBeInTheDocument();
  
  // Wait for data to load
  await waitFor(() => {
    expect(screen.getByText('John')).toBeInTheDocument();
  });
  
  expect(screen.getByText('john@example.com')).toBeInTheDocument();
});

Testing User Interactions

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

test('submits form with user credentials', async () => {
  const user = userEvent.setup();
  const handleSubmit = jest.fn();
  
  render(<LoginForm onSubmit={handleSubmit} />);
  
  // Type in email and password
  await user.type(screen.getByLabelText(/email/i), 'test@example.com');
  await user.type(screen.getByLabelText(/password/i), 'password123');
  
  // Click submit button
  await user.click(screen.getByRole('button', { name: /submit/i }));
  
  // Verify form submission
  expect(handleSubmit).toHaveBeenCalledWith({
    email: 'test@example.com',
    password: 'password123',
  });
});

Testing Hooks

Use @testing-library/react-hooks to test custom hooks in isolation without rendering components.

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

test('useCounter hook', () => {
  const { result } = renderHook(() => useCounter(0));
  
  // Initial state
  expect(result.current.count).toBe(0);
  
  // Increment
  act(() => {
    result.current.increment();
  });
  expect(result.current.count).toBe(1);
  
  // Decrement
  act(() => {
    result.current.decrement();
  });
  expect(result.current.count).toBe(0);
  
  // Reset
  act(() => {
    result.current.increment();
    result.current.increment();
    result.current.reset();
  });
  expect(result.current.count).toBe(0);
});

Testing Context and Providers

When testing components that use Context, wrap them in the appropriate provider or create a custom render function.

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

// Custom render with providers
function renderWithProviders(ui, options = {}) {
  const { theme = 'light', ...renderOptions } = options;
  
  return render(
    <ThemeProvider initialTheme={theme}>
      {ui}
    </ThemeProvider>,
    renderOptions
  );
}

test('button uses theme context', () => {
  renderWithProviders(<ThemedButton>Click Me</ThemedButton>, {
    theme: 'dark'
  });
  
  const button = screen.getByRole('button');
  expect(button).toHaveClass('dark-theme');
});

Integration Testing

Integration tests verify that multiple components work together correctly. They test component interactions, data flow, and complex user scenarios.

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

test('complete todo workflow', async () => {
  const user = userEvent.setup();
  render(<TodoApp />);
  
  // Add a todo
  const input = screen.getByPlaceholderText(/add todo/i);
  await user.type(input, 'Buy groceries');
  await user.click(screen.getByRole('button', { name: /add/i }));
  
  // Verify todo appears
  expect(screen.getByText('Buy groceries')).toBeInTheDocument();
  
  // Toggle todo completion
  const checkbox = screen.getByRole('checkbox', { name: /buy groceries/i });
  await user.click(checkbox);
  
  // Verify todo is marked complete
  expect(checkbox).toBeChecked();
  
  // Filter to show only completed
  await user.click(screen.getByRole('button', { name: /completed/i }));
  expect(screen.getByText('Buy groceries')).toBeInTheDocument();
  
  // Filter to show only active
  await user.click(screen.getByRole('button', { name: /active/i }));
  expect(screen.queryByText('Buy groceries')).not.toBeInTheDocument();
});

End-to-End Testing with Cypress

Cypress provides a complete end-to-end testing solution that runs in the browser and provides excellent debugging capabilities.

// cypress/e2e/todo-app.cy.js
describe('Todo App', () => {
  beforeEach(() => {
    cy.visit('http://localhost:3000');
  });
  
  it('allows users to add and complete todos', () => {
    // Add a todo
    cy.get('[data-testid="todo-input"]').type('Buy groceries');
    cy.get('[data-testid="add-button"]').click();
    
    // Verify todo appears
    cy.contains('Buy groceries').should('be.visible');
    
    // Complete the todo
    cy.get('[data-testid="todo-checkbox"]').first().click();
    cy.get('[data-testid="todo-item"]').first().should('have.class', 'completed');
  });
  
  it('persists todos after page reload', () => {
    // Add todo
    cy.get('[data-testid="todo-input"]').type('Test persistence');
    cy.get('[data-testid="add-button"]').click();
    
    // Reload page
    cy.reload();
    
    // Verify todo still exists
    cy.contains('Test persistence').should('be.visible');
  });
  
  it('filters todos correctly', () => {
    // Add multiple todos
    ['Todo 1', 'Todo 2', 'Todo 3'].forEach(todo => {
      cy.get('[data-testid="todo-input"]').type(todo);
      cy.get('[data-testid="add-button"]').click();
    });
    
    // Complete first todo
    cy.get('[data-testid="todo-checkbox"]').first().click();
    
    // Filter to show only completed
    cy.get('[data-testid="filter-completed"]').click();
    cy.get('[data-testid="todo-item"]').should('have.length', 1);
    cy.contains('Todo 1').should('be.visible');
    
    // Filter to show only active
    cy.get('[data-testid="filter-active"]').click();
    cy.get('[data-testid="todo-item"]').should('have.length', 2);
  });
});

End-to-End Testing with Playwright

Playwright enables reliable end-to-end testing for modern web apps with support for all browsers and provides powerful auto-waiting and debugging features.

import { test, expect } from '@playwright/test';

test.describe('Todo Application', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:3000');
  });
  
  test('should add and complete a todo', async ({ page }) => {
    // Add a new todo
    await page.fill('[data-testid="todo-input"]', 'Buy groceries');
    await page.click('[data-testid="add-button"]');
    
    // Verify todo is added
    await expect(page.locator('text=Buy groceries')).toBeVisible();
    
    // Complete the todo
    await page.click('[data-testid="todo-checkbox"]');
    
    // Verify completed state
    await expect(page.locator('[data-testid="todo-item"]')).toHaveClass(/completed/);
  });
  
  test('should filter todos', async ({ page }) => {
    // Add multiple todos
    const todos = ['Todo 1', 'Todo 2', 'Todo 3'];
    
    for (const todo of todos) {
      await page.fill('[data-testid="todo-input"]', todo);
      await page.click('[data-testid="add-button"]');
    }
    
    // Complete first todo
    await page.locator('[data-testid="todo-checkbox"]').first().click();
    
    // Filter completed
    await page.click('text=Completed');
    await expect(page.locator('[data-testid="todo-item"]')).toHaveCount(1);
    
    // Filter active
    await page.click('text=Active');
    await expect(page.locator('[data-testid="todo-item"]')).toHaveCount(2);
  });
  
  test('should handle network errors gracefully', async ({ page }) => {
    // Intercept and fail API request
    await page.route('**/api/todos', route => route.abort());
    
    await page.fill('[data-testid="todo-input"]', 'Test error');
    await page.click('[data-testid="add-button"]');
    
    // Verify error message appears
    await expect(page.locator('text=Failed to add todo')).toBeVisible();
  });
});

Testing Best Practices

  • Test behavior, not implementation: Focus on what users see and do

  • Use semantic queries: getByRole, getByLabelText over getByTestId

  • Avoid testing implementation details: Don't test state or internal methods

  • Mock external dependencies: APIs, timers, external libraries

  • Write descriptive test names: Describe what the test does and expects

  • Keep tests isolated: Each test should be independent

Snapshot Testing

Snapshot tests are useful for catching unexpected UI changes, but should be used sparingly and reviewed carefully.

import { render } from '@testing-library/react';
import Button from './Button';

test('Button snapshot', () => {
  const { container } = render(
    <Button variant="primary" size="large">
      Click Me
    </Button>
  );
  
  expect(container.firstChild).toMatchSnapshot();
});

// Update snapshots with: jest -u

Code Coverage

Measure and track code coverage to ensure your tests cover critical paths. Aim for 80%+ coverage on business logic.

# Run tests with coverage
npm test -- --coverage

# Generate HTML coverage report
npm test -- --coverage --coverageReporters=html

# Coverage thresholds in package.json
{
  "jest": {
    "coverageThreshold": {
      "global": {
        "branches": 80,
        "functions": 80,
        "lines": 80,
        "statements": 80
      }
    }
  }
}

Visual Regression Testing

Catch visual bugs by comparing screenshots before and after changes using tools like Percy or Chromatic.

// Percy with Cypress
import '@percy/cypress';

it('matches button snapshot', () => {
  cy.visit('/components/button');
  cy.percySnapshot('Button Component');
});

// Chromatic with Storybook
// chromatic.yml
name: Chromatic

on: push

jobs:
  chromatic:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: chromaui/action@v1
        with:
          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}

Keep your own version of these notes — editable, searchable, and organised by your stack.

Start free