DEVELOPMENT

React testing complete guide: Jest, Testing Library, and E2E strategies

Master React testing with comprehensive guide covering Jest, Testing Library, E2E testing, and advanced patterns for testing hooks, context, and async operations in production applications.

Vladimir Siedykh

Why Every Deployment Feels Like a Gamble

Your React application works perfectly in development, but every deployment feels like a gamble. A seemingly harmless component update breaks the checkout flow. A new feature accidentally removes critical accessibility attributes. A performance optimization introduces a race condition that only appears under load.

Sound familiar? The difference between React applications that scale confidently and those that accumulate technical debt isn't just the code quality - it's the testing strategy that catches problems before users experience them.

The challenge isn't just writing tests; it's writing the right tests. I've seen teams with 90% test coverage still shipping broken features because they tested implementation details instead of user behavior. Conversely, I've worked with applications that had modest test coverage but rock-solid reliability because every test validated actual user workflows.

This guide focuses on building a testing strategy that provides real confidence in your React applications. You'll learn how to test user interactions rather than internal component mechanics, how to structure tests that remain valuable as your codebase evolves, and how to balance testing speed with thoroughness.

By the end, you'll have a systematic approach to React testing that catches real bugs, documents intended behavior, and enables confident refactoring as your application grows.

React testing philosophy and foundations

Before diving into specific testing techniques, it's essential to understand the philosophy that makes React testing effective and maintainable.

Testing user behavior, not implementation

The fundamental principle of effective React testing is focusing on what users experience rather than how components work internally:

// ❌ Bad: Testing implementation details
test('should call setState when button is clicked', () => {
  const component = mount(<Counter />);
  const button = component.find('button');
  const spy = jest.spyOn(component.instance(), 'setState');
  
  button.simulate('click');
  
  expect(spy).toHaveBeenCalledWith({ count: 1 });
});

// ✅ Good: Testing user behavior
test('should increment counter when button is clicked', () => {
  render(<Counter />);
  const button = screen.getByRole('button', { name: /increment/i });
  const counter = screen.getByText('Count: 0');
  
  fireEvent.click(button);
  
  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

This approach makes tests more resilient to refactoring and better documents the component's intended behavior.

Test setup and configuration

Establish a solid testing foundation with proper configuration:

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
  
  // Module path mapping
  moduleNameMapping: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
    '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2)$': '<rootDir>/__mocks__/fileMock.js'
  },
  
  // Coverage configuration
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/index.js',
    '!src/reportWebVitals.js'
  ],
  
  coverageThreshold: {
    global: {
      branches: 70,
      functions: 70,
      lines: 70,
      statements: 70
    }
  },
  
  // Transform configuration
  transform: {
    '^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', {
      presets: [
        ['@babel/preset-env', { targets: { node: 'current' } }],
        ['@babel/preset-react', { runtime: 'automatic' }],
        '@babel/preset-typescript'
      ]
    }]
  }
};
// src/setupTests.js
import '@testing-library/jest-dom';
import { configure } from '@testing-library/react';

// Configure Testing Library
configure({
  // Increase timeout for async operations
  asyncUtilTimeout: 5000,
  
  // Better error messages
  getElementError: (message, container) => {
    const error = new Error(message);
    error.name = 'TestingLibraryElementError';
    error.stack = null;
    return error;
  }
});

// Global test utilities
global.mockConsoleError = () => {
  const originalError = console.error;
  console.error = jest.fn();
  return () => {
    console.error = originalError;
  };
};

// Mock IntersectionObserver for components that use it
global.IntersectionObserver = class IntersectionObserver {
  constructor() {}
  disconnect() {}
  observe() {}
  unobserve() {}
};

// Mock matchMedia for responsive components
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: jest.fn().mockImplementation(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(),
    removeListener: jest.fn(),
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn(),
  })),
});

Testing utilities and helpers

Create reusable testing utilities to reduce boilerplate:

// src/test-utils.jsx
import { render as rtlRender } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext';

// Custom render function with providers
function render(ui, options = {}) {
  const {
    preloadedState = {},
    route = '/',
    queryClient = new QueryClient({
      defaultOptions: {
        queries: { retry: false },
        mutations: { retry: false }
      }
    }),
    ...renderOptions
  } = options;

  function Wrapper({ children }) {
    return (
      <QueryClientProvider client={queryClient}>
        <BrowserRouter>
          <ThemeProvider>
            <AuthProvider initialAuth={preloadedState.auth}>
              {children}
            </AuthProvider>
          </ThemeProvider>
        </BrowserRouter>
      </QueryClientProvider>
    );
  }

  // Navigate to route if provided
  if (route !== '/') {
    window.history.pushState({}, 'Test page', route);
  }

  return rtlRender(ui, { wrapper: Wrapper, ...renderOptions });
}

// Factory functions for test data
export const createMockUser = (overrides = {}) => ({
  id: 1,
  name: 'John Doe',
  email: 'john@example.com',
  role: 'user',
  createdAt: '2023-01-01T00:00:00Z',
  ...overrides
});

export const createMockProduct = (overrides = {}) => ({
  id: 1,
  name: 'Test Product',
  price: 99.99,
  description: 'A test product',
  inStock: true,
  category: 'electronics',
  ...overrides
});

// Custom matchers
expect.extend({
  toBeVisible(received) {
    const pass = received.style.display !== 'none' && 
                 received.style.visibility !== 'hidden' &&
                 received.style.opacity !== '0';
    
    if (pass) {
      return {
        message: () => `expected element not to be visible`,
        pass: true,
      };
    } else {
      return {
        message: () => `expected element to be visible`,
        pass: false,
      };
    }
  },
});

// Re-export everything
export * from '@testing-library/react';
export { render };

Unit testing React components

Unit testing focuses on individual components in isolation, verifying their behavior under different conditions.

Testing component rendering and props

Start with basic rendering tests that verify component behavior:

import { render, screen } from '@/test-utils';
import UserCard from './UserCard';

describe('UserCard', () => {
  const defaultProps = {
    user: {
      id: 1,
      name: 'Jane Smith',
      email: 'jane@example.com',
      avatar: 'https://example.com/avatar.jpg',
      role: 'admin'
    },
    onEdit: jest.fn(),
    onDelete: jest.fn()
  };

  beforeEach(() => {
    jest.clearAllMocks();
  });

  test('renders user information correctly', () => {
    render(<UserCard {...defaultProps} />);
    
    expect(screen.getByText('Jane Smith')).toBeInTheDocument();
    expect(screen.getByText('jane@example.com')).toBeInTheDocument();
    expect(screen.getByText('admin')).toBeInTheDocument();
    
    const avatar = screen.getByAltText('Jane Smith');
    expect(avatar).toHaveAttribute('src', 'https://example.com/avatar.jpg');
  });

  test('calls onEdit when edit button is clicked', async () => {
    const user = userEvent.setup();
    render(<UserCard {...defaultProps} />);
    
    const editButton = screen.getByRole('button', { name: /edit/i });
    await user.click(editButton);
    
    expect(defaultProps.onEdit).toHaveBeenCalledWith(defaultProps.user.id);
    expect(defaultProps.onEdit).toHaveBeenCalledTimes(1);
  });

  test('calls onDelete when delete button is clicked', async () => {
    const user = userEvent.setup();
    render(<UserCard {...defaultProps} />);
    
    const deleteButton = screen.getByRole('button', { name: /delete/i });
    await user.click(deleteButton);
    
    expect(defaultProps.onDelete).toHaveBeenCalledWith(defaultProps.user.id);
  });

  test('displays fallback avatar when avatar URL is missing', () => {
    const userWithoutAvatar = {
      ...defaultProps.user,
      avatar: null
    };
    
    render(<UserCard {...defaultProps} user={userWithoutAvatar} />);
    
    const avatar = screen.getByAltText('Jane Smith');
    expect(avatar).toHaveAttribute('src', '/default-avatar.png');
  });

  test('applies correct styling for different roles', () => {
    const { rerender } = render(<UserCard {...defaultProps} />);
    
    expect(screen.getByTestId('user-card')).toHaveClass('role-admin');
    
    const regularUser = { ...defaultProps.user, role: 'user' };
    rerender(<UserCard {...defaultProps} user={regularUser} />);
    
    expect(screen.getByTestId('user-card')).toHaveClass('role-user');
  });
});

Testing component state and interactions

Test components that manage internal state:

import { render, screen, waitFor } from '@/test-utils';
import userEvent from '@testing-library/user-event';
import SearchForm from './SearchForm';

describe('SearchForm', () => {
  const defaultProps = {
    onSearch: jest.fn(),
    placeholder: 'Search products...',
    debounceMs: 300
  };

  beforeEach(() => {
    jest.clearAllMocks();
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  test('updates input value when user types', async () => {
    const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
    render(<SearchForm {...defaultProps} />);
    
    const searchInput = screen.getByPlaceholderText('Search products...');
    
    await user.type(searchInput, 'laptop');
    
    expect(searchInput).toHaveValue('laptop');
  });

  test('debounces search calls', async () => {
    const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
    render(<SearchForm {...defaultProps} />);
    
    const searchInput = screen.getByPlaceholderText('Search products...');
    
    // Type multiple characters quickly
    await user.type(searchInput, 'lap');
    
    // Should not call onSearch immediately
    expect(defaultProps.onSearch).not.toHaveBeenCalled();
    
    // Advance timers to trigger debounce
    jest.advanceTimersByTime(300);
    
    expect(defaultProps.onSearch).toHaveBeenCalledWith('lap');
    expect(defaultProps.onSearch).toHaveBeenCalledTimes(1);
  });

  test('clears search when clear button is clicked', async () => {
    const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
    render(<SearchForm {...defaultProps} />);
    
    const searchInput = screen.getByPlaceholderText('Search products...');
    
    // Type something first
    await user.type(searchInput, 'laptop');
    jest.advanceTimersByTime(300);
    
    // Clear button should appear
    const clearButton = screen.getByRole('button', { name: /clear/i });
    await user.click(clearButton);
    
    expect(searchInput).toHaveValue('');
    expect(defaultProps.onSearch).toHaveBeenCalledWith('');
  });

  test('shows loading state during search', async () => {
    const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
    render(<SearchForm {...defaultProps} isLoading={true} />);
    
    expect(screen.getByTestId('search-loading')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /search/i })).toBeDisabled();
  });

  test('handles form submission', async () => {
    const user = userEvent.setup();
    render(<SearchForm {...defaultProps} />);
    
    const searchInput = screen.getByPlaceholderText('Search products...');
    const form = searchInput.closest('form');
    
    await user.type(searchInput, 'laptop');
    await user.click(screen.getByRole('button', { name: /search/i }));
    
    expect(defaultProps.onSearch).toHaveBeenCalledWith('laptop');
  });
});

Testing conditional rendering

Verify components render correctly under different conditions:

import { render, screen } from '@/test-utils';
import ProductList from './ProductList';
import { createMockProduct } from '@/test-utils';

describe('ProductList', () => {
  test('displays loading state', () => {
    render(<ProductList loading={true} products={[]} />);
    
    expect(screen.getByTestId('product-list-loading')).toBeInTheDocument();
    expect(screen.getByText(/loading products/i)).toBeInTheDocument();
  });

  test('displays error state', () => {
    const error = 'Failed to load products';
    render(<ProductList error={error} products={[]} />);
    
    expect(screen.getByTestId('product-list-error')).toBeInTheDocument();
    expect(screen.getByText(error)).toBeInTheDocument();
    
    const retryButton = screen.getByRole('button', { name: /retry/i });
    expect(retryButton).toBeInTheDocument();
  });

  test('displays empty state when no products', () => {
    render(<ProductList products={[]} />);
    
    expect(screen.getByTestId('product-list-empty')).toBeInTheDocument();
    expect(screen.getByText(/no products found/i)).toBeInTheDocument();
  });

  test('renders product list correctly', () => {
    const products = [
      createMockProduct({ id: 1, name: 'Laptop' }),
      createMockProduct({ id: 2, name: 'Mouse' }),
      createMockProduct({ id: 3, name: 'Keyboard' })
    ];
    
    render(<ProductList products={products} />);
    
    expect(screen.getByText('Laptop')).toBeInTheDocument();
    expect(screen.getByText('Mouse')).toBeInTheDocument();
    expect(screen.getByText('Keyboard')).toBeInTheDocument();
    
    // Should not show loading, error, or empty states
    expect(screen.queryByTestId('product-list-loading')).not.toBeInTheDocument();
    expect(screen.queryByTestId('product-list-error')).not.toBeInTheDocument();
    expect(screen.queryByTestId('product-list-empty')).not.toBeInTheDocument();
  });

  test('handles product interactions', async () => {
    const user = userEvent.setup();
    const onProductClick = jest.fn();
    const products = [createMockProduct({ id: 1, name: 'Laptop' })];
    
    render(<ProductList products={products} onProductClick={onProductClick} />);
    
    const productCard = screen.getByText('Laptop').closest('.product-card');
    await user.click(productCard);
    
    expect(onProductClick).toHaveBeenCalledWith(products[0]);
  });
});

Testing React hooks and custom logic

Building on the patterns from our React hooks guide, let's explore comprehensive hook testing strategies.

Testing custom hooks in isolation

Use renderHook to test custom hooks independently:

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

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

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

  test('returns initial loading state', () => {
    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => ({ data: 'test' })
    });

    const { result } = renderHook(() => useApi('/api/test'));

    expect(result.current.loading).toBe(true);
    expect(result.current.data).toBe(null);
    expect(result.current.error).toBe(null);
  });

  test('fetches data successfully', async () => {
    const mockData = { id: 1, name: 'Test Item' };
    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => mockData
    });

    const { result } = renderHook(() => useApi('/api/test'));

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toEqual(mockData);
    expect(result.current.error).toBe(null);
    expect(fetch).toHaveBeenCalledWith('/api/test');
  });

  test('handles fetch errors', async () => {
    const errorMessage = 'Network error';
    fetch.mockRejectedValueOnce(new Error(errorMessage));

    const { result } = renderHook(() => useApi('/api/test'));

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toBe(null);
    expect(result.current.error).toBe(errorMessage);
  });

  test('refetches data when refetch is called', async () => {
    const mockData = { id: 1, name: 'Test Item' };
    fetch.mockResolvedValue({
      ok: true,
      json: async () => mockData
    });

    const { result } = renderHook(() => useApi('/api/test'));

    // Wait for initial fetch
    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    // Clear previous calls
    fetch.mockClear();

    // Trigger refetch
    act(() => {
      result.current.refetch();
    });

    expect(result.current.loading).toBe(true);

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(fetch).toHaveBeenCalledWith('/api/test');
  });

  test('cancels request when component unmounts', async () => {
    fetch.mockImplementationOnce(() => 
      new Promise(resolve => setTimeout(resolve, 1000))
    );

    const { result, unmount } = renderHook(() => useApi('/api/test'));

    expect(result.current.loading).toBe(true);

    // Unmount before request completes
    unmount();

    // Wait a bit to ensure no state updates occur
    await new Promise(resolve => setTimeout(resolve, 100));

    // Should not throw any warnings about state updates on unmounted component
  });
});

Testing hooks with dependencies

Test hooks that depend on props or external values:

import { renderHook } from '@testing-library/react';
import { useDebounce } from './useDebounce';

describe('useDebounce', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  test('returns initial value immediately', () => {
    const { result } = renderHook(() => useDebounce('initial', 500));
    
    expect(result.current).toBe('initial');
  });

  test('debounces value changes', () => {
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: 'initial', delay: 500 } }
    );

    expect(result.current).toBe('initial');

    // Change the value
    rerender({ value: 'updated', delay: 500 });

    // Value should not change immediately
    expect(result.current).toBe('initial');

    // Advance timers by less than delay
    jest.advanceTimersByTime(400);
    expect(result.current).toBe('initial');

    // Advance timers past delay
    jest.advanceTimersByTime(100);
    expect(result.current).toBe('updated');
  });

  test('cancels previous timeout when value changes quickly', () => {
    const { result, rerender } = renderHook(
      ({ value }) => useDebounce(value, 500),
      { initialProps: { value: 'initial' } }
    );

    // Change value multiple times quickly
    rerender({ value: 'first' });
    jest.advanceTimersByTime(200);
    
    rerender({ value: 'second' });
    jest.advanceTimersByTime(200);
    
    rerender({ value: 'final' });
    jest.advanceTimersByTime(500);

    // Only the final value should be set
    expect(result.current).toBe('final');
  });
});

Testing hooks with context

Test hooks that consume React context:

import { renderHook } from '@testing-library/react';
import { AuthProvider, useAuth } from './AuthContext';
import { createMockUser } from '@/test-utils';

const createWrapper = (initialAuth = null) => {
  return function Wrapper({ children }) {
    return (
      <AuthProvider initialAuth={initialAuth}>
        {children}
      </AuthProvider>
    );
  };
};

describe('useAuth', () => {
  test('returns null user when not authenticated', () => {
    const { result } = renderHook(() => useAuth(), {
      wrapper: createWrapper()
    });

    expect(result.current.user).toBe(null);
    expect(result.current.isAuthenticated).toBe(false);
  });

  test('returns user when authenticated', () => {
    const mockUser = createMockUser();
    const { result } = renderHook(() => useAuth(), {
      wrapper: createWrapper(mockUser)
    });

    expect(result.current.user).toEqual(mockUser);
    expect(result.current.isAuthenticated).toBe(true);
  });

  test('login updates authentication state', async () => {
    const mockUser = createMockUser();
    
    // Mock the login API
    global.fetch = jest.fn().mockResolvedValueOnce({
      ok: true,
      json: async () => mockUser
    });

    const { result } = renderHook(() => useAuth(), {
      wrapper: createWrapper()
    });

    expect(result.current.isAuthenticated).toBe(false);

    await act(async () => {
      await result.current.login('test@example.com', 'password');
    });

    expect(result.current.user).toEqual(mockUser);
    expect(result.current.isAuthenticated).toBe(true);
  });

  test('logout clears authentication state', async () => {
    const mockUser = createMockUser();
    const { result } = renderHook(() => useAuth(), {
      wrapper: createWrapper(mockUser)
    });

    expect(result.current.isAuthenticated).toBe(true);

    act(() => {
      result.current.logout();
    });

    expect(result.current.user).toBe(null);
    expect(result.current.isAuthenticated).toBe(false);
  });
});

Integration testing and component interactions

Integration tests verify that multiple components work together correctly, testing the interfaces between components.

Testing component composition

Test how parent and child components interact:

import { render, screen, waitFor } from '@/test-utils';
import userEvent from '@testing-library/user-event';
import ProductPage from './ProductPage';
import { createMockProduct } from '@/test-utils';

// Mock the API module
jest.mock('@/services/api', () => ({
  getProduct: jest.fn(),
  addToCart: jest.fn(),
  getRelatedProducts: jest.fn()
}));

import * as api from '@/services/api';

describe('ProductPage Integration', () => {
  const mockProduct = createMockProduct({
    id: 1,
    name: 'Gaming Laptop',
    price: 1299.99,
    description: 'High-performance gaming laptop',
    images: ['laptop1.jpg', 'laptop2.jpg'],
    specifications: {
      processor: 'Intel i7',
      memory: '16GB RAM',
      storage: '512GB SSD'
    }
  });

  const relatedProducts = [
    createMockProduct({ id: 2, name: 'Gaming Mouse' }),
    createMockProduct({ id: 3, name: 'Gaming Keyboard' })
  ];

  beforeEach(() => {
    jest.clearAllMocks();
    api.getProduct.mockResolvedValue(mockProduct);
    api.getRelatedProducts.mockResolvedValue(relatedProducts);
    api.addToCart.mockResolvedValue({ success: true });
  });

  test('loads and displays product information', async () => {
    render(<ProductPage productId="1" />);

    // Should show loading initially
    expect(screen.getByText(/loading/i)).toBeInTheDocument();

    // Wait for product to load
    await waitFor(() => {
      expect(screen.getByText('Gaming Laptop')).toBeInTheDocument();
    });

    expect(screen.getByText('$1,299.99')).toBeInTheDocument();
    expect(screen.getByText('High-performance gaming laptop')).toBeInTheDocument();
    
    // Check specifications are displayed
    expect(screen.getByText('Intel i7')).toBeInTheDocument();
    expect(screen.getByText('16GB RAM')).toBeInTheDocument();
    expect(screen.getByText('512GB SSD')).toBeInTheDocument();
  });

  test('displays product images with navigation', async () => {
    const user = userEvent.setup();
    render(<ProductPage productId="1" />);

    await waitFor(() => {
      expect(screen.getByText('Gaming Laptop')).toBeInTheDocument();
    });

    // Should display first image by default
    const mainImage = screen.getByAltText('Gaming Laptop');
    expect(mainImage).toHaveAttribute('src', expect.stringContaining('laptop1.jpg'));

    // Should have image navigation
    const nextButton = screen.getByRole('button', { name: /next image/i });
    await user.click(nextButton);

    // Should show second image
    expect(mainImage).toHaveAttribute('src', expect.stringContaining('laptop2.jpg'));
  });

  test('adds product to cart', async () => {
    const user = userEvent.setup();
    render(<ProductPage productId="1" />);

    await waitFor(() => {
      expect(screen.getByText('Gaming Laptop')).toBeInTheDocument();
    });

    const addToCartButton = screen.getByRole('button', { name: /add to cart/i });
    await user.click(addToCartButton);

    expect(api.addToCart).toHaveBeenCalledWith({
      productId: 1,
      quantity: 1
    });

    // Should show success message
    await waitFor(() => {
      expect(screen.getByText(/added to cart/i)).toBeInTheDocument();
    });
  });

  test('displays related products', async () => {
    render(<ProductPage productId="1" />);

    await waitFor(() => {
      expect(screen.getByText('Gaming Laptop')).toBeInTheDocument();
    });

    // Should load and display related products
    await waitFor(() => {
      expect(screen.getByText('Related Products')).toBeInTheDocument();
    });

    expect(screen.getByText('Gaming Mouse')).toBeInTheDocument();
    expect(screen.getByText('Gaming Keyboard')).toBeInTheDocument();

    expect(api.getRelatedProducts).toHaveBeenCalledWith(1);
  });

  test('handles quantity selection', async () => {
    const user = userEvent.setup();
    render(<ProductPage productId="1" />);

    await waitFor(() => {
      expect(screen.getByText('Gaming Laptop')).toBeInTheDocument();
    });

    // Change quantity
    const quantitySelect = screen.getByLabelText(/quantity/i);
    await user.selectOptions(quantitySelect, '3');

    // Add to cart with selected quantity
    const addToCartButton = screen.getByRole('button', { name: /add to cart/i });
    await user.click(addToCartButton);

    expect(api.addToCart).toHaveBeenCalledWith({
      productId: 1,
      quantity: 3
    });
  });

  test('handles API errors gracefully', async () => {
    api.getProduct.mockRejectedValueOnce(new Error('Product not found'));

    render(<ProductPage productId="999" />);

    await waitFor(() => {
      expect(screen.getByText(/error loading product/i)).toBeInTheDocument();
    });

    expect(screen.getByText(/product not found/i)).toBeInTheDocument();
  });
});

Testing form workflows

Test complete form interactions including validation and submission:

import { render, screen, waitFor } from '@/test-utils';
import userEvent from '@testing-library/user-event';
import ContactForm from './ContactForm';

// Mock form submission
const mockSubmit = jest.fn();

describe('ContactForm Workflow', () => {
  beforeEach(() => {
    mockSubmit.mockClear();
  });

  test('completes full form submission workflow', async () => {
    const user = userEvent.setup();
    render(<ContactForm onSubmit={mockSubmit} />);

    // Fill out form fields
    await user.type(
      screen.getByLabelText(/name/i),
      'John Doe'
    );
    
    await user.type(
      screen.getByLabelText(/email/i),
      'john@example.com'
    );
    
    await user.selectOptions(
      screen.getByLabelText(/subject/i),
      'general'
    );
    
    await user.type(
      screen.getByLabelText(/message/i),
      'This is a test message that is long enough to pass validation.'
    );

    // Submit form
    const submitButton = screen.getByRole('button', { name: /send message/i });
    await user.click(submitButton);

    // Should show loading state
    expect(screen.getByText(/sending/i)).toBeInTheDocument();
    expect(submitButton).toBeDisabled();

    // Wait for submission to complete
    await waitFor(() => {
      expect(mockSubmit).toHaveBeenCalledWith({
        name: 'John Doe',
        email: 'john@example.com',
        subject: 'general',
        message: 'This is a test message that is long enough to pass validation.'
      });
    });

    // Should show success message
    await waitFor(() => {
      expect(screen.getByText(/message sent successfully/i)).toBeInTheDocument();
    });

    // Form should be reset
    expect(screen.getByLabelText(/name/i)).toHaveValue('');
    expect(screen.getByLabelText(/email/i)).toHaveValue('');
  });

  test('validates required fields', async () => {
    const user = userEvent.setup();
    render(<ContactForm onSubmit={mockSubmit} />);

    // Try to submit empty form
    const submitButton = screen.getByRole('button', { name: /send message/i });
    await user.click(submitButton);

    // Should show validation errors
    await waitFor(() => {
      expect(screen.getByText(/name is required/i)).toBeInTheDocument();
    });
    
    expect(screen.getByText(/email is required/i)).toBeInTheDocument();
    expect(screen.getByText(/message is required/i)).toBeInTheDocument();

    // Should not submit form
    expect(mockSubmit).not.toHaveBeenCalled();
  });

  test('validates email format', async () => {
    const user = userEvent.setup();
    render(<ContactForm onSubmit={mockSubmit} />);

    // Enter invalid email
    const emailInput = screen.getByLabelText(/email/i);
    await user.type(emailInput, 'invalid-email');
    await user.tab(); // Trigger blur event

    await waitFor(() => {
      expect(screen.getByText(/please enter a valid email/i)).toBeInTheDocument();
    });

    expect(mockSubmit).not.toHaveBeenCalled();
  });

  test('handles submission errors', async () => {
    const user = userEvent.setup();
    mockSubmit.mockRejectedValueOnce(new Error('Server error'));
    
    render(<ContactForm onSubmit={mockSubmit} />);

    // Fill and submit form
    await user.type(screen.getByLabelText(/name/i), 'John Doe');
    await user.type(screen.getByLabelText(/email/i), 'john@example.com');
    await user.type(screen.getByLabelText(/message/i), 'Test message');
    
    await user.click(screen.getByRole('button', { name: /send message/i }));

    // Should show error message
    await waitFor(() => {
      expect(screen.getByText(/failed to send message/i)).toBeInTheDocument();
    });

    // Form should remain filled
    expect(screen.getByLabelText(/name/i)).toHaveValue('John Doe');
    expect(screen.getByLabelText(/email/i)).toHaveValue('john@example.com');
  });
});

Testing async operations and side effects

Modern React applications heavily rely on async operations. Testing these properly requires understanding how to handle timing and state changes.

Testing data fetching components

Test components that fetch data on mount or user interaction:

import { render, screen, waitFor } from '@/test-utils';
import userEvent from '@testing-library/user-event';
import UserDashboard from './UserDashboard';

// Mock the data fetching service
jest.mock('@/services/userService', () => ({
  getCurrentUser: jest.fn(),
  getUserStats: jest.fn(),
  getUserNotifications: jest.fn()
}));

import * as userService from '@/services/userService';

describe('UserDashboard Async Operations', () => {
  const mockUser = {
    id: 1,
    name: 'John Doe',
    email: 'john@example.com'
  };

  const mockStats = {
    totalOrders: 15,
    totalSpent: 1299.99,
    favoriteCategory: 'Electronics'
  };

  const mockNotifications = [
    { id: 1, message: 'Your order has shipped', read: false },
    { id: 2, message: 'New product recommendations', read: true }
  ];

  beforeEach(() => {
    jest.clearAllMocks();
  });

  test('loads user data on mount', async () => {
    userService.getCurrentUser.mockResolvedValue(mockUser);
    userService.getUserStats.mockResolvedValue(mockStats);
    userService.getUserNotifications.mockResolvedValue(mockNotifications);

    render(<UserDashboard />);

    // Should show loading initially
    expect(screen.getByText(/loading dashboard/i)).toBeInTheDocument();

    // Wait for data to load
    await waitFor(() => {
      expect(screen.getByText('Welcome, John Doe')).toBeInTheDocument();
    });

    // Verify all data is displayed
    expect(screen.getByText('15 orders')).toBeInTheDocument();
    expect(screen.getByText('$1,299.99 spent')).toBeInTheDocument();
    expect(screen.getByText('Electronics')).toBeInTheDocument();
    
    // Verify notifications
    expect(screen.getByText('Your order has shipped')).toBeInTheDocument();
    expect(screen.getByText('New product recommendations')).toBeInTheDocument();

    // Verify API calls
    expect(userService.getCurrentUser).toHaveBeenCalledTimes(1);
    expect(userService.getUserStats).toHaveBeenCalledTimes(1);
    expect(userService.getUserNotifications).toHaveBeenCalledTimes(1);
  });

  test('handles parallel data loading errors', async () => {
    userService.getCurrentUser.mockResolvedValue(mockUser);
    userService.getUserStats.mockRejectedValue(new Error('Stats service unavailable'));
    userService.getUserNotifications.mockResolvedValue(mockNotifications);

    render(<UserDashboard />);

    await waitFor(() => {
      expect(screen.getByText('Welcome, John Doe')).toBeInTheDocument();
    });

    // User and notifications should load successfully
    expect(screen.getByText('Your order has shipped')).toBeInTheDocument();
    
    // Stats should show error state
    expect(screen.getByText(/unable to load stats/i)).toBeInTheDocument();
    
    // Should have retry button for stats
    expect(screen.getByRole('button', { name: /retry stats/i })).toBeInTheDocument();
  });

  test('refreshes data when refresh button is clicked', async () => {
    const user = userEvent.setup();
    
    // Initial load
    userService.getCurrentUser.mockResolvedValue(mockUser);
    userService.getUserStats.mockResolvedValue(mockStats);
    userService.getUserNotifications.mockResolvedValue(mockNotifications);

    render(<UserDashboard />);

    await waitFor(() => {
      expect(screen.getByText('Welcome, John Doe')).toBeInTheDocument();
    });

    // Clear mocks to verify refresh calls
    jest.clearAllMocks();

    // Updated data for refresh
    const updatedStats = { ...mockStats, totalOrders: 16 };
    userService.getUserStats.mockResolvedValue(updatedStats);
    userService.getUserNotifications.mockResolvedValue([
      ...mockNotifications,
      { id: 3, message: 'New message', read: false }
    ]);

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

    // Should show refreshing state
    expect(screen.getByText(/refreshing/i)).toBeInTheDocument();

    // Wait for refresh to complete
    await waitFor(() => {
      expect(screen.getByText('16 orders')).toBeInTheDocument();
    });

    expect(screen.getByText('New message')).toBeInTheDocument();

    // Should have called the APIs again (but not getCurrentUser since it doesn't change)
    expect(userService.getUserStats).toHaveBeenCalledTimes(1);
    expect(userService.getUserNotifications).toHaveBeenCalledTimes(1);
  });

  test('handles race conditions in rapid refreshes', async () => {
    const user = userEvent.setup();
    
    userService.getCurrentUser.mockResolvedValue(mockUser);
    userService.getUserStats.mockResolvedValue(mockStats);
    userService.getUserNotifications.mockResolvedValue(mockNotifications);

    render(<UserDashboard />);

    await waitFor(() => {
      expect(screen.getByText('Welcome, John Doe')).toBeInTheDocument();
    });

    // Mock slow API responses
    let resolveFirstCall, resolveSecondCall;
    
    userService.getUserStats
      .mockReturnValueOnce(new Promise(resolve => { resolveFirstCall = resolve; }))
      .mockReturnValueOnce(new Promise(resolve => { resolveSecondCall = resolve; }));

    const refreshButton = screen.getByRole('button', { name: /refresh/i });
    
    // Trigger two rapid refreshes
    await user.click(refreshButton);
    await user.click(refreshButton);

    // Resolve second call first (race condition)
    resolveSecondCall({ ...mockStats, totalOrders: 20 });
    
    await waitFor(() => {
      expect(screen.getByText('20 orders')).toBeInTheDocument();
    });

    // Resolve first call later - should not override newer data
    resolveFirstCall({ ...mockStats, totalOrders: 18 });

    // Give time for potential state update
    await new Promise(resolve => setTimeout(resolve, 100));

    // Should still show the newer data
    expect(screen.getByText('20 orders')).toBeInTheDocument();
    expect(screen.queryByText('18 orders')).not.toBeInTheDocument();
  });
});

Testing components with side effects

Test components that trigger side effects like analytics or notifications:

import { render, screen, waitFor } from '@/test-utils';
import userEvent from '@testing-library/user-event';
import PurchaseButton from './PurchaseButton';

// Mock analytics and notification services
jest.mock('@/services/analytics', () => ({
  track: jest.fn()
}));

jest.mock('@/services/notifications', () => ({
  show: jest.fn()
}));

import * as analytics from '@/services/analytics';
import * as notifications from '@/services/notifications';

describe('PurchaseButton Side Effects', () => {
  const defaultProps = {
    productId: 123,
    productName: 'Gaming Laptop',
    price: 1299.99,
    onPurchaseComplete: jest.fn()
  };

  beforeEach(() => {
    jest.clearAllMocks();
    
    // Mock successful purchase API
    global.fetch = jest.fn().mockResolvedValue({
      ok: true,
      json: async () => ({ orderId: 'ORD-123', success: true })
    });
  });

  test('tracks analytics events during purchase flow', async () => {
    const user = userEvent.setup();
    render(<PurchaseButton {...defaultProps} />);

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

    expect(analytics.track).toHaveBeenCalledWith('purchase_initiated', {
      productId: 123,
      productName: 'Gaming Laptop',
      price: 1299.99
    });

    // Wait for purchase to complete
    await waitFor(() => {
      expect(screen.getByText(/purchase successful/i)).toBeInTheDocument();
    });

    // Track successful purchase
    expect(analytics.track).toHaveBeenCalledWith('purchase_completed', {
      productId: 123,
      orderId: 'ORD-123',
      price: 1299.99
    });

    expect(analytics.track).toHaveBeenCalledTimes(2);
  });

  test('shows notification on successful purchase', async () => {
    const user = userEvent.setup();
    render(<PurchaseButton {...defaultProps} />);

    await user.click(screen.getByRole('button', { name: /purchase/i }));

    await waitFor(() => {
      expect(notifications.show).toHaveBeenCalledWith({
        type: 'success',
        title: 'Purchase Successful!',
        message: 'Your order for Gaming Laptop has been placed.'
      });
    });
  });

  test('tracks errors and shows error notifications', async () => {
    const user = userEvent.setup();
    
    // Mock API failure
    global.fetch = jest.fn().mockRejectedValue(new Error('Payment failed'));

    render(<PurchaseButton {...defaultProps} />);

    await user.click(screen.getByRole('button', { name: /purchase/i }));

    await waitFor(() => {
      expect(analytics.track).toHaveBeenCalledWith('purchase_failed', {
        productId: 123,
        error: 'Payment failed'
      });
    });

    expect(notifications.show).toHaveBeenCalledWith({
      type: 'error',
      title: 'Purchase Failed',
      message: 'Payment failed. Please try again.'
    });
  });

  test('calls onPurchaseComplete callback with order data', async () => {
    const user = userEvent.setup();
    const orderData = { orderId: 'ORD-123', success: true };
    
    global.fetch = jest.fn().mockResolvedValue({
      ok: true,
      json: async () => orderData
    });

    render(<PurchaseButton {...defaultProps} />);

    await user.click(screen.getByRole('button', { name: /purchase/i }));

    await waitFor(() => {
      expect(defaultProps.onPurchaseComplete).toHaveBeenCalledWith(orderData);
    });
  });
});

End-to-end testing strategies

E2E tests verify complete user workflows across your entire application stack. These complement our comprehensive component and integration testing.

Setting up Playwright for React applications

Configure Playwright for reliable E2E testing:

// playwright.config.js
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] },
    },
  ],

  webServer: {
    command: 'npm run start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Testing complete user workflows

Test critical user journeys from start to finish:

// e2e/user-journey.spec.js
import { test, expect } from '@playwright/test';

test.describe('Complete User Journey', () => {
  test.beforeEach(async ({ page }) => {
    // Set up test data
    await page.route('**/api/products', async route => {
      await route.fulfill({
        json: [
          { id: 1, name: 'Gaming Laptop', price: 1299.99, inStock: true },
          { id: 2, name: 'Wireless Mouse', price: 79.99, inStock: true }
        ]
      });
    });
  });

  test('user can complete purchase flow', async ({ page }) => {
    // 1. Navigate to homepage
    await page.goto('/');
    await expect(page).toHaveTitle(/E-commerce App/);

    // 2. Search for product
    const searchInput = page.getByPlaceholder('Search products...');
    await searchInput.fill('Gaming Laptop');
    await searchInput.press('Enter');

    // 3. Navigate to product page
    await page.getByText('Gaming Laptop').click();
    await expect(page.getByText('$1,299.99')).toBeVisible();

    // 4. Add to cart
    await page.getByRole('button', { name: 'Add to Cart' }).click();
    await expect(page.getByText('Added to cart')).toBeVisible();

    // 5. View cart
    await page.getByRole('link', { name: 'Cart' }).click();
    await expect(page.getByText('Gaming Laptop')).toBeVisible();
    await expect(page.getByText('$1,299.99')).toBeVisible();

    // 6. Proceed to checkout
    await page.getByRole('button', { name: 'Checkout' }).click();

    // 7. Fill shipping information
    await page.getByLabel('Full Name').fill('John Doe');
    await page.getByLabel('Email').fill('john@example.com');
    await page.getByLabel('Address').fill('123 Main St');
    await page.getByLabel('City').fill('New York');
    await page.getByLabel('ZIP Code').fill('10001');

    // 8. Fill payment information
    await page.getByLabel('Card Number').fill('4111111111111111');
    await page.getByLabel('Expiry Date').fill('12/25');
    await page.getByLabel('CVV').fill('123');

    // 9. Complete purchase
    await page.getByRole('button', { name: 'Complete Purchase' }).click();

    // 10. Verify success
    await expect(page.getByText('Order Confirmed')).toBeVisible();
    await expect(page.getByText(/Order #ORD-/)).toBeVisible();
  });

  test('user registration and login flow', async ({ page }) => {
    // 1. Navigate to registration
    await page.goto('/');
    await page.getByRole('link', { name: 'Sign Up' }).click();

    // 2. Fill registration form
    await page.getByLabel('Full Name').fill('Jane Smith');
    await page.getByLabel('Email').fill('jane@example.com');
    await page.getByLabel('Password').fill('SecurePassword123!');
    await page.getByLabel('Confirm Password').fill('SecurePassword123!');

    // 3. Submit registration
    await page.getByRole('button', { name: 'Create Account' }).click();

    // 4. Verify welcome message
    await expect(page.getByText('Welcome, Jane Smith!')).toBeVisible();

    // 5. Logout
    await page.getByRole('button', { name: 'Account' }).click();
    await page.getByRole('button', { name: 'Logout' }).click();

    // 6. Login with new account
    await page.getByRole('link', { name: 'Login' }).click();
    await page.getByLabel('Email').fill('jane@example.com');
    await page.getByLabel('Password').fill('SecurePassword123!');
    await page.getByRole('button', { name: 'Login' }).click();

    // 7. Verify successful login
    await expect(page.getByText('Welcome back, Jane!')).toBeVisible();
  });

  test('responsive design on mobile', async ({ page }) => {
    // Set mobile viewport
    await page.setViewportSize({ width: 375, height: 667 });

    await page.goto('/');

    // 1. Mobile navigation menu
    const menuButton = page.getByRole('button', { name: 'Menu' });
    await expect(menuButton).toBeVisible();
    await menuButton.click();

    const mobileMenu = page.getByTestId('mobile-menu');
    await expect(mobileMenu).toBeVisible();

    // 2. Navigate to products
    await page.getByRole('link', { name: 'Products' }).click();
    await expect(mobileMenu).not.toBeVisible();

    // 3. Product grid layout on mobile
    const productGrid = page.getByTestId('product-grid');
    await expect(productGrid).toHaveClass(/mobile-grid/);

    // 4. Touch interactions
    const firstProduct = page.getByTestId('product-card').first();
    await firstProduct.tap();

    // 5. Mobile-specific UI elements
    await expect(page.getByTestId('mobile-quantity-selector')).toBeVisible();
    await expect(page.getByTestId('mobile-add-to-cart')).toBeVisible();
  });
});

Testing performance in E2E scenarios

Monitor performance metrics during E2E tests:

// e2e/performance.spec.js
import { test, expect } from '@playwright/test';

test.describe('Performance Tests', () => {
  test('homepage meets performance budgets', async ({ page }) => {
    // Start monitoring performance
    await page.goto('/', { waitUntil: 'networkidle' });

    // Measure Core Web Vitals
    const metrics = await page.evaluate(() => {
      return new Promise(resolve => {
        const observer = new PerformanceObserver(list => {
          const entries = list.getEntries();
          const vitals = {};

          entries.forEach(entry => {
            if (entry.entryType === 'largest-contentful-paint') {
              vitals.LCP = entry.startTime;
            }
            if (entry.entryType === 'layout-shift' && !entry.hadRecentInput) {
              vitals.CLS = (vitals.CLS || 0) + entry.value;
            }
          });

          resolve(vitals);
        });

        observer.observe({ entryTypes: ['largest-contentful-paint', 'layout-shift'] });

        // Fallback timeout
        setTimeout(() => resolve({}), 5000);
      });
    });

    // Assert performance budgets
    if (metrics.LCP) {
      expect(metrics.LCP).toBeLessThan(2500); // 2.5s budget
    }
    if (metrics.CLS) {
      expect(metrics.CLS).toBeLessThan(0.1); // 0.1 budget
    }

    // Measure bundle size
    const responses = [];
    page.on('response', response => {
      if (response.url().includes('.js') && response.status() === 200) {
        responses.push(response);
      }
    });

    await page.reload();
    
    let totalBundleSize = 0;
    for (const response of responses) {
      const buffer = await response.body();
      totalBundleSize += buffer.length;
    }

    // Assert bundle size budget (250KB)
    expect(totalBundleSize).toBeLessThan(250 * 1024);
  });

  test('search performance under load', async ({ page }) => {
    await page.goto('/');

    const searchInput = page.getByPlaceholder('Search products...');
    
    // Measure search response time
    const startTime = Date.now();
    
    await searchInput.fill('gaming');
    await page.waitForResponse('**/api/search**');
    
    const endTime = Date.now();
    const searchTime = endTime - startTime;

    // Search should respond within 1 second
    expect(searchTime).toBeLessThan(1000);

    // Results should be visible quickly
    await expect(page.getByTestId('search-results')).toBeVisible({ timeout: 500 });
  });
});

Advanced testing patterns and optimization

As applications grow more complex, these advanced patterns help maintain test quality and execution speed.

Testing with TypeScript integration

Leverage TypeScript for better test reliability, building on our TypeScript React patterns:

// src/test-utils.ts
import { render as rtlRender, RenderOptions } from '@testing-library/react';
import { ReactElement } from 'react';
import { QueryClient } from '@tanstack/react-query';

// Type-safe test data factories
interface TestUser {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'moderator';
}

export const createTestUser = (overrides: Partial<TestUser> = {}): TestUser => ({
  id: Math.floor(Math.random() * 1000),
  name: 'Test User',
  email: 'test@example.com',
  role: 'user',
  ...overrides
});

// Type-safe render options
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
  preloadedState?: {
    auth?: TestUser | null;
    cart?: any[];
  };
  queryClient?: QueryClient;
}

// Type-safe custom render function
export function render(
  ui: ReactElement,
  options: CustomRenderOptions = {}
): ReturnType<typeof rtlRender> {
  const { preloadedState = {}, queryClient, ...renderOptions } = options;

  // Implementation with proper TypeScript typing...
  return rtlRender(ui, renderOptions);
}

// Type-safe mock generators
export function createMockApiResponse<T>(
  data: T,
  options: { delay?: number; error?: boolean } = {}
): Promise<{ ok: boolean; json: () => Promise<T> }> {
  const { delay = 0, error = false } = options;

  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (error) {
        reject(new Error('Mock API error'));
      } else {
        resolve({
          ok: true,
          json: async () => data
        });
      }
    }, delay);
  });
}

Optimizing test performance

Implement strategies to keep test suites fast and reliable:

// jest.config.js - Performance optimizations
module.exports = {
  // Run tests in parallel
  maxWorkers: '50%',
  
  // Cache test results
  cache: true,
  cacheDirectory: '<rootDir>/.jest-cache',
  
  // Optimize for faster re-runs
  watchman: true,
  
  // Selective test execution
  testPathIgnorePatterns: [
    '/node_modules/',
    '/build/',
    '/coverage/'
  ],
  
  // Setup test timeouts
  testTimeout: 10000,
  
  // Optimize module resolution
  modulePathIgnorePatterns: ['<rootDir>/build/'],
  
  // Setup files for performance
  setupFilesAfterEnv: [
    '<rootDir>/src/setupTests.js',
    '<rootDir>/src/setupPerformanceTests.js'
  ]
};
// src/setupPerformanceTests.js
// Optimize React Testing Library
import { configure } from '@testing-library/react';

configure({
  // Reduce query timeouts for faster failures
  asyncUtilTimeout: 2000,
  
  // Optimize DOM cleanup
  asyncWrapper: async (cb) => {
    const result = await cb();
    // Force garbage collection if available
    if (global.gc) {
      global.gc();
    }
    return result;
  }
});

// Mock expensive operations
jest.mock('@/services/analytics', () => ({
  track: jest.fn()
}));

// Reduce timer precision for faster tests
jest.mock('lodash/debounce', () => {
  return jest.fn(fn => {
    let timeout;
    return (...args) => {
      clearTimeout(timeout);
      timeout = setTimeout(() => fn(...args), 0);
    };
  });
});

// Optimize image loading in tests
Object.defineProperty(HTMLImageElement.prototype, 'loading', {
  get() { return 'eager'; },
  set() { /* noop */ }
});

Test data management and factories

Create maintainable test data with factory patterns:

// src/__tests__/factories/index.js
import { faker } from '@faker-js/faker';

class TestDataFactory {
  constructor() {
    this.sequences = new Map();
  }

  sequence(name, fn) {
    if (!this.sequences.has(name)) {
      this.sequences.set(name, 0);
    }
    const current = this.sequences.get(name);
    this.sequences.set(name, current + 1);
    return fn(current);
  }

  user(overrides = {}) {
    return {
      id: this.sequence('user', n => n + 1),
      name: faker.person.fullName(),
      email: faker.internet.email(),
      role: 'user',
      createdAt: faker.date.past().toISOString(),
      avatar: faker.image.avatar(),
      isActive: true,
      ...overrides
    };
  }

  product(overrides = {}) {
    return {
      id: this.sequence('product', n => n + 1),
      name: faker.commerce.productName(),
      description: faker.commerce.productDescription(),
      price: parseFloat(faker.commerce.price()),
      category: faker.commerce.department(),
      inStock: faker.datatype.boolean(),
      images: [faker.image.url(), faker.image.url()],
      rating: faker.number.float({ min: 1, max: 5, precision: 0.1 }),
      ...overrides
    };
  }

  order(overrides = {}) {
    return {
      id: this.sequence('order', n => `ORD-${String(n).padStart(6, '0')}`),
      userId: this.sequence('user', n => n + 1),
      items: [this.product(), this.product()],
      total: parseFloat(faker.commerce.price({ min: 100, max: 1000 })),
      status: faker.helpers.arrayElement(['pending', 'confirmed', 'shipped', 'delivered']),
      createdAt: faker.date.recent().toISOString(),
      ...overrides
    };
  }

  // Batch creation methods
  userList(count = 5, overrides = {}) {
    return Array.from({ length: count }, () => this.user(overrides));
  }

  productList(count = 10, overrides = {}) {
    return Array.from({ length: count }, () => this.product(overrides));
  }

  // Relationship helpers
  userWithOrders(orderCount = 3) {
    const user = this.user();
    const orders = Array.from({ length: orderCount }, () => 
      this.order({ userId: user.id })
    );
    return { user, orders };
  }
}

export const factory = new TestDataFactory();

// Usage in tests
describe('UserDashboard', () => {
  test('displays user orders', () => {
    const { user, orders } = factory.userWithOrders(5);
    
    render(<UserDashboard user={user} orders={orders} />);
    
    expect(screen.getByText(user.name)).toBeInTheDocument();
    orders.forEach(order => {
      expect(screen.getByText(order.id)).toBeInTheDocument();
    });
  });
});

When building applications that require comprehensive testing strategies to ensure reliability and user satisfaction, our team specializes in creating robust testing frameworks that provide confidence in deployments while maintaining development velocity.

Testing best practices and continuous integration

Implementing systematic testing practices ensures your React applications remain reliable as they evolve.

Test organization and structure

Structure tests for maintainability and clarity:

// Example test file structure
describe('ProductCard Component', () => {
  // Group related functionality
  describe('rendering', () => {
    test('displays product information correctly');
    test('shows sale badge when product is on sale');
    test('handles missing product images gracefully');
  });

  describe('user interactions', () => {
    test('calls onAddToCart when add button is clicked');
    test('navigates to product page when card is clicked');
    test('updates quantity when quantity selector changes');
  });

  describe('accessibility', () => {
    test('has proper ARIA labels');
    test('supports keyboard navigation');
    test('announces status changes to screen readers');
  });

  describe('error states', () => {
    test('displays error message when product fails to load');
    test('disables interactions when product is out of stock');
  });
});

CI/CD integration and automated testing

Configure testing pipelines for reliable deployments:

# .github/workflows/test.yml
name: Test Suite

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run unit tests
        run: npm run test:unit -- --coverage --watchAll=false
      
      - name: Upload coverage reports
        uses: codecov/codecov-action@v3

  integration-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run integration tests
        run: npm run test:integration

  e2e-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Install Playwright browsers
        run: npx playwright install --with-deps
      
      - name: Build application
        run: npm run build
      
      - name: Run E2E tests
        run: npm run test:e2e
      
      - name: Upload test results
        uses: actions/upload-artifact@v3
        if: always()
        with:
          name: playwright-report
          path: playwright-report/

  visual-regression:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run visual tests
        run: npm run test:visual
        env:
          PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}

Quality gates and deployment confidence

Establish quality gates that ensure reliable deployments:

// scripts/quality-gate.js
const fs = require('fs');
const path = require('path');

class QualityGate {
  constructor() {
    this.criteria = {
      coverage: {
        statements: 80,
        branches: 70,
        functions: 80,
        lines: 80
      },
      performance: {
        bundleSize: 250 * 1024, // 250KB
        testSpeed: 60000, // 60 seconds max
      },
      accessibility: {
        minScore: 95
      }
    };
  }

  async checkCoverage() {
    const coveragePath = path.join(__dirname, '../coverage/coverage-summary.json');
    
    if (!fs.existsSync(coveragePath)) {
      throw new Error('Coverage report not found. Run tests with --coverage flag.');
    }

    const coverage = JSON.parse(fs.readFileSync(coveragePath, 'utf8'));
    const total = coverage.total;

    const failures = [];

    Object.entries(this.criteria.coverage).forEach(([metric, threshold]) => {
      const actual = total[metric].pct;
      if (actual < threshold) {
        failures.push(`${metric}: ${actual}% (required: ${threshold}%)`);
      }
    });

    if (failures.length > 0) {
      throw new Error(`Coverage requirements not met:\n${failures.join('\n')}`);
    }

    console.log('✅ Coverage requirements met');
    return true;
  }

  async checkBundleSize() {
    const buildPath = path.join(__dirname, '../build/static/js');
    
    if (!fs.existsSync(buildPath)) {
      throw new Error('Build directory not found. Run npm run build first.');
    }

    const jsFiles = fs.readdirSync(buildPath)
      .filter(file => file.endsWith('.js') && !file.includes('.map'));

    let totalSize = 0;
    jsFiles.forEach(file => {
      const stats = fs.statSync(path.join(buildPath, file));
      totalSize += stats.size;
    });

    if (totalSize > this.criteria.performance.bundleSize) {
      throw new Error(
        `Bundle size ${Math.round(totalSize / 1024)}KB exceeds limit of ${Math.round(this.criteria.performance.bundleSize / 1024)}KB`
      );
    }

    console.log(`✅ Bundle size ${Math.round(totalSize / 1024)}KB within limits`);
    return true;
  }

  async checkAccessibility() {
    // This would integrate with axe-core or similar tools
    console.log('✅ Accessibility requirements met');
    return true;
  }

  async run() {
    console.log('Running quality gate checks...\n');

    try {
      await this.checkCoverage();
      await this.checkBundleSize();
      await this.checkAccessibility();

      console.log('\n🎉 All quality gate checks passed!');
      process.exit(0);
    } catch (error) {
      console.error(`\n❌ Quality gate failed: ${error.message}`);
      process.exit(1);
    }
  }
}

if (require.main === module) {
  new QualityGate().run();
}

module.exports = QualityGate;

Building confidence through comprehensive testing

React testing is fundamentally about building confidence in your application's behavior under real-world conditions. The strategies we've explored - from unit testing individual components to end-to-end workflow validation - create a safety net that enables rapid development without sacrificing reliability.

The key insight is that effective testing isn't about achieving 100% coverage; it's about testing the right things in the right way. By focusing on user behavior rather than implementation details, using proper async testing patterns, and maintaining fast, reliable test suites, you create documentation that lives with your code and catches regressions before they reach production.

The testing patterns we've covered integrate seamlessly with modern React development practices. From the memoization techniques in our performance optimization guide to the type-safe patterns from our TypeScript integration guide, proper testing validates that these optimizations work correctly across different scenarios and user interactions.

Whether you're implementing a comprehensive testing strategy for a new React application or improving the reliability of an existing codebase, the systematic approach we've outlined provides the foundation for confident deployments and sustainable development practices.

Ready to implement a robust testing strategy that ensures your React applications perform reliably in production? Start your project brief to discuss how we can help you build applications with the testing infrastructure and confidence needed for successful long-term growth.

React testing - FAQ & implementation guide

Unit tests focus on individual components in isolation, while integration tests verify how multiple components work together. Integration tests catch more real-world bugs but are slower to run and harder to debug.

Both Jest and Vitest work well for React testing. Jest has broader ecosystem support and more resources, while Vitest offers faster performance and better ES modules support. Choose based on your build tool and performance requirements.

Test hooks by testing the components that use them, focusing on user interactions and state changes. Use @testing-library/react-hooks for complex hooks that need isolated testing, but prefer testing through component behavior.

Write E2E tests for critical user journeys and complex workflows. Use unit tests for component logic, edge cases, and isolated functionality. Aim for 70% unit tests, 20% integration tests, and 10% E2E tests for optimal coverage and speed.

Use waitFor, findBy queries, and act() for testing async operations. Mock API calls with MSW (Mock Service Worker) for realistic testing. Always test loading states, success states, and error handling scenarios.

Test user behavior not implementation details, use accessible queries, test error boundaries, mock external dependencies, and focus on critical user paths. Write tests that would fail if the feature broke from a user perspective.

Stay ahead with expert insights

Get practical tips on web design, business growth, SEO strategies, and development best practices delivered to your inbox.