DevToolBoxFREE
Blog

React Testing Guide: React Testing Library, Jest, Vitest, MSW, Playwright, and Code Coverage

16 min readby DevToolBox
TL;DR
  • Use React Testing Library (RTL) for component tests — query by role, label, and text like a real user
  • Prefer userEvent over fireEvent for realistic interaction simulation
  • Use MSW (Mock Service Worker) to mock API calls without touching application code
  • Vitest is the modern choice for Vite projects; Jest remains dominant in Next.js / CRA ecosystems
  • Test hooks with renderHook from @testing-library/react
  • Follow the testing pyramid: many unit tests, fewer integration tests, minimal E2E tests
  • Use Playwright or Cypress for end-to-end tests against a running browser
  • Aim for 70–80% meaningful coverage; enforce thresholds in CI via Istanbul/nyc
Key Takeaways
  • Tests are documentation — a well-named test tells you exactly what the component should do
  • Test behavior, not implementation details; avoid testing internal state or private methods
  • Async components require waitFor, findBy* queries, or act() wrappers
  • Snapshot tests are best for stable UI components; avoid them for frequently changing layouts
  • Integration tests with real Redux/Zustand stores are more valuable than mocking the store
  • E2E tests should cover the critical user paths: login, checkout, core CRUD workflows

The React testing ecosystem has evolved rapidly. React Testing Library has become the de facto standard for component testing, Vitest has redefined test execution speed, and Playwright and Cypress have brought end-to-end testing into the mainstream. This guide gives you a complete picture of testing React applications — from unit tests to integration tests to end-to-end tests — with practical code examples you can use in real projects.

The Testing Pyramid: Unit, Integration, and E2E Tests

The testing pyramid is a framework that helps teams balance their testing investment. At the base are many fast unit tests, in the middle are fewer integration tests, and at the top are a small number of slow but high-value end-to-end tests.

Test TypeScopeSpeedToolsVolume
UnitSingle function/componentVery fast (ms)Jest / Vitest + RTL70%
IntegrationMultiple components + mocked APIFast (seconds)Jest / Vitest + RTL + MSW20%
E2EFull user journeySlow (minutes)Playwright / Cypress10%

For React applications: unit tests cover individual components, utility functions, and custom hooks in isolation; integration tests cover feature slices such as a form that makes API calls; E2E tests cover critical user journeys like login flows and checkout processes.

React Testing Library: Philosophy and Setup

React Testing Library (RTL) is built on one core principle: "The more your tests resemble the way your software is used, the more confidence they can give you." This means querying the DOM through user-visible elements — roles, labels, text — rather than component internals, CSS classes, or test IDs.

Installation and Configuration

# Install React Testing Library + Jest DOM matchers
npm install --save-dev @testing-library/react @testing-library/user-event @testing-library/jest-dom

# For Vitest projects
npm install --save-dev @testing-library/react @testing-library/user-event @testing-library/jest-dom vitest @vitest/coverage-v8 jsdom

# For Jest projects (Next.js, CRA)
npm install --save-dev @testing-library/react @testing-library/user-event @testing-library/jest-dom jest jest-environment-jsdom

Jest Configuration (jest.config.ts)

import type { Config } from 'jest';

const config: Config = {
  testEnvironment: 'jsdom',
  setupFilesAfterFramework: ['<rootDir>/jest.setup.ts'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\.(css|less|scss)$': 'identity-obj-proxy',
    '\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.ts',
  },
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.tsx',
    '!src/main.tsx',
  ],
  coverageThresholds: {
    global: {
      branches: 70,
      functions: 70,
      lines: 80,
      statements: 80,
    },
  },
};

export default config;
// jest.setup.ts
import '@testing-library/jest-dom';

Vitest Configuration (vite.config.ts)

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test/setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      thresholds: {
        branches: 70,
        functions: 70,
        lines: 80,
        statements: 80,
      },
    },
  },
});

Rendering Components with render() and screen Queries

RTL's render() function mounts a component into a virtual DOM, and the screen object provides various query methods to locate elements. Queries come in three variants: getBy (throws if not found), queryBy (returns null if not found), and findBy (async, waits for the element to appear).

// src/components/UserCard.tsx
interface User {
  name: string;
  email: string;
  role: 'admin' | 'user';
}

export function UserCard({ user, onEdit }: { user: User; onEdit: () => void }) {
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      {user.role === 'admin' && <span>Admin</span>}
      <button onClick={onEdit}>Edit Profile</button>
    </div>
  );
}

// src/components/UserCard.test.tsx
import { render, screen } from '@testing-library/react';
import { UserCard } from './UserCard';

const mockUser = { name: 'Alice Johnson', email: 'alice@example.com', role: 'admin' as const };

test('renders user name and email', () => {
  render(<UserCard user={mockUser} onEdit={() => {}} />);

  // getBy* — throws if not found (use for elements that must exist)
  expect(screen.getByRole('heading', { name: 'Alice Johnson' })).toBeInTheDocument();
  expect(screen.getByText('alice@example.com')).toBeInTheDocument();
});

test('shows admin badge for admin users', () => {
  render(<UserCard user={mockUser} onEdit={() => {}} />);
  expect(screen.getByText('Admin')).toBeInTheDocument();
});

test('hides admin badge for regular users', () => {
  const regularUser = { ...mockUser, role: 'user' as const };
  render(<UserCard user={regularUser} onEdit={() => {}} />);

  // queryBy* — returns null if not found (use for elements that may not exist)
  expect(screen.queryByText('Admin')).not.toBeInTheDocument();
});

test('calls onEdit when Edit Profile button is clicked', async () => {
  const handleEdit = jest.fn();
  render(<UserCard user={mockUser} onEdit={handleEdit} />);

  // getByRole is the preferred query — matches accessible roles
  const button = screen.getByRole('button', { name: /edit profile/i });
  button.click();

  expect(handleEdit).toHaveBeenCalledTimes(1);
});

Query Priority Guide

QueryUse ForPriority
getByRoleARIA roles (button, heading, textbox, etc.)1st
getByLabelTextForm inputs associated with labels2nd
getByPlaceholderTextInputs with placeholder text3rd
getByTextVisible text content4th
getByDisplayValueCurrent value (input/select/textarea)5th
getByAltTextImage alt attributes6th
getByTitletitle attribute7th
getByTestIddata-testid (last resort)8th

Firing Events: userEvent vs fireEvent

userEvent is the preferred way to test user interactions. It simulates real browser behavior including pointer down/up, focus, blur, and other intermediate events that a real user would trigger. fireEvent only dispatches a single synthetic event and can miss the intermediate steps that real browsers fire.

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

// Always set up userEvent with userEvent.setup() for better control
const user = userEvent.setup();

test('submits login form with email and password', async () => {
  const handleSubmit = jest.fn();
  render(<LoginForm onSubmit={handleSubmit} />);

  // userEvent.type() simulates real keystroke-by-keystroke typing
  await user.type(screen.getByLabelText(/email/i), 'alice@example.com');
  await user.type(screen.getByLabelText(/password/i), 'secret123');

  // userEvent.click() fires pointer events + focus + click
  await user.click(screen.getByRole('button', { name: /sign in/i }));

  expect(handleSubmit).toHaveBeenCalledWith({
    email: 'alice@example.com',
    password: 'secret123',
  });
});

test('clears input and types new value', async () => {
  render(<SearchInput />);
  const input = screen.getByRole('textbox');

  await user.type(input, 'first query');
  expect(input).toHaveValue('first query');

  // clear() empties the input before typing
  await user.clear(input);
  await user.type(input, 'second query');
  expect(input).toHaveValue('second query');
});

test('selects from dropdown', async () => {
  render(<CountrySelect />);
  await user.selectOptions(
    screen.getByRole('combobox', { name: /country/i }),
    'United States'
  );
  expect(screen.getByRole('combobox')).toHaveValue('US');
});

test('uploads a file', async () => {
  render(<FileUpload />);
  const file = new File(['hello'], 'test.txt', { type: 'text/plain' });
  const input = screen.getByLabelText(/upload file/i);

  await user.upload(input, file);
  expect(input.files[0]).toBe(file);
});

// fireEvent: only for cases userEvent cannot handle
import { fireEvent } from '@testing-library/react';

test('handles scroll event', () => {
  render(<InfiniteList />);
  const list = screen.getByRole('list');
  fireEvent.scroll(list, { target: { scrollTop: 500 } });
  // Assert something happens on scroll
});

Testing React Hooks with renderHook

renderHook lets you call and test custom hooks directly in tests without creating a full component. It renders a minimal wrapper component that calls your hook, exposes the return value through result.current, and uses act() to trigger state updates.

// src/hooks/useCounter.ts
import { useState, useCallback } from 'react';

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

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

test('initializes with provided value', () => {
  const { result } = renderHook(() => useCounter(10));
  expect(result.current.count).toBe(10);
});

test('increments count', () => {
  const { result } = renderHook(() => useCounter(0));
  act(() => {
    result.current.increment();
  });
  expect(result.current.count).toBe(1);
});

test('decrements count', () => {
  const { result } = renderHook(() => useCounter(5));
  act(() => {
    result.current.decrement();
  });
  expect(result.current.count).toBe(4);
});

test('resets to initial value', () => {
  const { result } = renderHook(() => useCounter(3));
  act(() => {
    result.current.increment();
    result.current.increment();
    result.current.reset();
  });
  expect(result.current.count).toBe(3);
});

// Testing hooks that use context
import { ThemeProvider } from '../context/ThemeContext';
import { useTheme } from './useTheme';

test('useTheme reads from ThemeContext', () => {
  const wrapper = ({ children }: { children: React.ReactNode }) => (
    <ThemeProvider initialTheme="dark">{children}</ThemeProvider>
  );
  const { result } = renderHook(() => useTheme(), { wrapper });
  expect(result.current.theme).toBe('dark');
});

// Testing async hooks
import { useUserData } from './useUserData';

test('fetches and returns user data', async () => {
  const { result } = renderHook(() => useUserData('user-123'));

  // Initially loading
  expect(result.current.loading).toBe(true);
  expect(result.current.user).toBeNull();

  // Wait for async update
  await act(async () => {
    await new Promise(resolve => setTimeout(resolve, 0));
  });

  expect(result.current.loading).toBe(false);
  expect(result.current.user).toMatchObject({ id: 'user-123' });
});

Mocking: jest.mock() and Mock Service Worker (MSW)

Mocking in tests comes in two main flavors: jest.mock() for module-level mocking (replacing entire modules or functions), and MSW for network-layer mocking (intercepting HTTP requests and returning mocked responses). MSW produces more realistic tests because application code remains unchanged.

jest.mock() Basics

// Mocking an entire module
jest.mock('../api/userService', () => ({
  fetchUser: jest.fn(),
  updateUser: jest.fn(),
  deleteUser: jest.fn(),
}));

import { fetchUser } from '../api/userService';
import { UserProfile } from './UserProfile';

test('displays user data after fetch', async () => {
  (fetchUser as jest.Mock).mockResolvedValue({
    id: '1',
    name: 'Bob Smith',
    email: 'bob@example.com',
  });

  render(<UserProfile userId="1" />);

  await screen.findByText('Bob Smith'); // findBy* is async
  expect(screen.getByText('bob@example.com')).toBeInTheDocument();
  expect(fetchUser).toHaveBeenCalledWith('1');
});

test('shows error state when fetch fails', async () => {
  (fetchUser as jest.Mock).mockRejectedValue(new Error('Network error'));

  render(<UserProfile userId="1" />);

  await screen.findByText(/something went wrong/i);
});

// Mocking specific named exports
jest.mock('next/navigation', () => ({
  useRouter: () => ({
    push: jest.fn(),
    replace: jest.fn(),
    back: jest.fn(),
    pathname: '/dashboard',
  }),
  useSearchParams: () => new URLSearchParams(),
  usePathname: () => '/dashboard',
}));

// Mocking with factory and partial override
jest.mock('../utils/date', () => ({
  ...jest.requireActual('../utils/date'),
  getCurrentDate: jest.fn(() => new Date('2026-01-01')),
}));

// Mock timer functions
jest.useFakeTimers();
test('debounce waits before calling handler', () => {
  const handler = jest.fn();
  const debouncedHandler = debounce(handler, 300);

  debouncedHandler('a');
  debouncedHandler('b');
  debouncedHandler('c');

  expect(handler).not.toHaveBeenCalled();
  jest.advanceTimersByTime(300);
  expect(handler).toHaveBeenCalledTimes(1);
  expect(handler).toHaveBeenCalledWith('c');
});
jest.useRealTimers();

Mock Service Worker (MSW) Setup

# Install MSW
npm install --save-dev msw
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  // GET /api/users/:id
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params;
    return HttpResponse.json({
      id,
      name: 'Alice Johnson',
      email: 'alice@example.com',
      role: 'admin',
    });
  }),

  // POST /api/users
  http.post('/api/users', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json(
      { id: 'new-user-id', ...body },
      { status: 201 }
    );
  }),

  // Simulate error
  http.delete('/api/users/:id', () => {
    return HttpResponse.json(
      { error: 'Unauthorized' },
      { status: 403 }
    );
  }),
];
// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
// src/test/setup.ts (add to setupFiles in config)
import { server } from '../mocks/server';
import '@testing-library/jest-dom';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers()); // Reset per-test overrides
afterAll(() => server.close());
// src/components/UserProfile.test.tsx
import { render, screen } from '@testing-library/react';
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
import { UserProfile } from './UserProfile';

test('displays user profile from API', async () => {
  render(<UserProfile userId="42" />);

  // findBy* waits for the element to appear (async)
  expect(await screen.findByText('Alice Johnson')).toBeInTheDocument();
  expect(screen.getByText('alice@example.com')).toBeInTheDocument();
});

test('shows error when API returns 500', async () => {
  // Override handler for this test only
  server.use(
    http.get('/api/users/:id', () => {
      return HttpResponse.json({ error: 'Server Error' }, { status: 500 });
    })
  );

  render(<UserProfile userId="42" />);
  expect(await screen.findByText(/error loading profile/i)).toBeInTheDocument();
});

Vitest: The Modern Alternative to Jest

Vitest is built by the Vite team, shares Vite's configuration, has native ESM support, and typically runs 2–5x faster than Jest. It is fully API-compatible with Jest (same describe/test/expect syntax), making migration straightforward for projects already using Jest.

// vitest.config.ts — full configuration example
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,           // No need to import describe/test/expect
    setupFiles: ['./src/test/setup.ts'],
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    exclude: ['node_modules', 'dist', 'e2e'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      include: ['src/**/*.{ts,tsx}'],
      exclude: ['src/**/*.d.ts', 'src/**/*.stories.tsx', 'src/main.tsx'],
    },
  },
  resolve: {
    alias: { '@': path.resolve(__dirname, 'src') },
  },
});
// Vitest-specific features
import { vi, describe, test, expect, beforeEach } from 'vitest';

// vi.fn() replaces jest.fn()
const mockFetch = vi.fn();

// vi.mock() replaces jest.mock()
vi.mock('../api/client', () => ({
  apiClient: {
    get: vi.fn(),
    post: vi.fn(),
  },
}));

// vi.spyOn() replaces jest.spyOn()
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

// Fake timers
vi.useFakeTimers();
vi.advanceTimersByTime(1000);
vi.useRealTimers();

// Inline snapshots (Vitest supports these natively)
test('renders correctly', () => {
  const { container } = render(<Button label="Click me" />);
  expect(container).toMatchInlineSnapshot(`
    <div>
      <button>Click me</button>
    </div>
  `);
});

// Run tests in parallel with concurrent
describe.concurrent('parallel tests', () => {
  test('test 1', async () => { /* runs in parallel */ });
  test('test 2', async () => { /* runs in parallel */ });
});

// Bench testing (unique to Vitest)
import { bench } from 'vitest';
bench('sort array', () => {
  [3, 1, 2].sort();
});

Testing Async Components and Data Fetching

Async components that use useEffect + fetch, React Query, SWR, or similar patterns require special handling. RTL provides waitFor, findBy* queries, and act() to handle asynchronous state updates.

// Testing a component with useEffect + fetch
import { render, screen, waitFor } from '@testing-library/react';

test('loads and displays posts list', async () => {
  render(<PostsList />);

  // Loading state appears first
  expect(screen.getByText(/loading/i)).toBeInTheDocument();

  // Wait for async update (MSW will intercept the fetch)
  await waitFor(() => {
    expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
  });

  // Alternatively, use findBy* which combines waitFor + getBy
  const posts = await screen.findAllByRole('listitem');
  expect(posts).toHaveLength(3);
});

// Testing React Query
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

function createWrapper() {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },  // Disable retries for faster test failures
    },
  });
  return ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}

test('displays user list from React Query', async () => {
  render(<UserList />, { wrapper: createWrapper() });

  // React Query starts in loading state
  expect(screen.getByText(/loading users/i)).toBeInTheDocument();

  // MSW handles the API call, React Query caches the result
  const userItems = await screen.findAllByRole('article');
  expect(userItems).toHaveLength(5);
});

// Testing error boundaries
test('renders error boundary on fetch failure', async () => {
  // MSW already configured to return 500 for this route
  server.use(
    http.get('/api/posts', () => HttpResponse.error())
  );

  render(
    <ErrorBoundary fallback={<p>Failed to load</p>}>
      <PostsList />
    </ErrorBoundary>
  );

  expect(await screen.findByText('Failed to load')).toBeInTheDocument();
});

// waitFor with custom timeout and interval
await waitFor(
  () => expect(screen.getByTestId('chart')).toBeVisible(),
  { timeout: 5000, interval: 100 }
);

Snapshot Testing with Jest

Snapshot testing captures a component's rendered output and saves it to a file. Subsequent test runs compare against the saved snapshot, and any difference causes the test to fail. Snapshot tests are best for stable presentational components and should be avoided for frequently changing layouts.

// Basic snapshot test
import { render } from '@testing-library/react';
import { Badge } from './Badge';

test('Badge renders correctly', () => {
  const { container } = render(<Badge label="New" variant="success" />);
  expect(container.firstChild).toMatchSnapshot();
});

// Inline snapshot — snapshot stored in the test file itself
test('Badge inline snapshot', () => {
  const { container } = render(<Badge label="New" variant="success" />);
  expect(container.firstChild).toMatchInlineSnapshot(`
    <span
      class="badge badge-success"
    >
      New
    </span>
  `);
});

// Snapshot with serializer for better diffs
import { render } from '@testing-library/react';
import pretty from 'pretty-format';

test('renders with specific props', () => {
  const { baseElement } = render(
    <Modal title="Confirm Delete" isOpen={true}>
      Are you sure you want to delete this item?
    </Modal>
  );
  expect(pretty(baseElement.innerHTML)).toMatchSnapshot();
});

// Update snapshots when intentional change is made:
// jest --updateSnapshot  or  jest -u
// vitest --update-snapshots  or  vitest -u

// When to use snapshots:
// - Design system components (Button, Badge, Card)
// - Email templates
// - Static data transformations
// When NOT to use:
// - Components with dynamic dates/IDs
// - Complex interactive components
// - Components under active development

Testing Redux and Zustand State Management

When testing state management, prefer using a real store rather than mocking it. This tests the genuine integration between your components and the state layer, avoiding false-positive tests that pass even when the real integration is broken.

Testing with Redux Toolkit

// src/store/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0, status: 'idle' as 'idle' | 'loading' },
  reducers: {
    increment: (state) => { state.value += 1; },
    decrement: (state) => { state.value -= 1; },
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
  },
});

// Test the slice directly (pure function tests)
import { counterSlice } from './counterSlice';
const { increment, decrement, incrementByAmount } = counterSlice.actions;
const reducer = counterSlice.reducer;

describe('counterSlice', () => {
  test('increments value', () => {
    expect(reducer({ value: 5, status: 'idle' }, increment())).toEqual({
      value: 6, status: 'idle'
    });
  });

  test('increments by amount', () => {
    expect(reducer({ value: 0, status: 'idle' }, incrementByAmount(10))).toEqual({
      value: 10, status: 'idle'
    });
  });
});

// src/test/renderWithRedux.tsx — helper for component tests
import { configureStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import { render, RenderOptions } from '@testing-library/react';
import { counterReducer } from '../store/counterSlice';
import type { RootState } from '../store';

interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
  preloadedState?: Partial<RootState>;
}

export function renderWithRedux(
  ui: React.ReactElement,
  { preloadedState = {}, ...renderOptions }: ExtendedRenderOptions = {}
) {
  const store = configureStore({
    reducer: { counter: counterReducer },
    preloadedState,
  });
  const Wrapper = ({ children }: { children: React.ReactNode }) => (
    <Provider store={store}>{children}</Provider>
  );
  return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) };
}

// Component test with preloaded Redux state
import { renderWithRedux } from '../test/renderWithRedux';
import { Counter } from './Counter';

test('displays initial count from Redux store', () => {
  renderWithRedux(<Counter />, { preloadedState: { counter: { value: 42, status: 'idle' } } });
  expect(screen.getByText('42')).toBeInTheDocument();
});

test('increments count when button clicked', async () => {
  const { store } = renderWithRedux(<Counter />);
  await user.click(screen.getByRole('button', { name: /increment/i }));
  expect(store.getState().counter.value).toBe(1);
  expect(screen.getByText('1')).toBeInTheDocument();
});

Testing with Zustand

// src/store/useCartStore.ts
import { create } from 'zustand';

interface CartItem { id: string; name: string; price: number; quantity: number; }
interface CartStore {
  items: CartItem[];
  total: number;
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: string) => void;
  clearCart: () => void;
}

export const useCartStore = create<CartStore>((set, get) => ({
  items: [],
  total: 0,
  addItem: (item) => set((state) => {
    const existing = state.items.find(i => i.id === item.id);
    const items = existing
      ? state.items.map(i => i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i)
      : [...state.items, { ...item, quantity: 1 }];
    return { items, total: items.reduce((sum, i) => sum + i.price * i.quantity, 0) };
  }),
  removeItem: (id) => set((state) => {
    const items = state.items.filter(i => i.id !== id);
    return { items, total: items.reduce((sum, i) => sum + i.price * i.quantity, 0) };
  }),
  clearCart: () => set({ items: [], total: 0 }),
}));

// Testing Zustand store directly
import { act, renderHook } from '@testing-library/react';
import { useCartStore } from './useCartStore';

beforeEach(() => {
  // Reset store state between tests
  useCartStore.setState({ items: [], total: 0 });
});

test('adds item to cart', () => {
  const { result } = renderHook(() => useCartStore());
  act(() => {
    result.current.addItem({ id: 'p1', name: 'Widget', price: 9.99 });
  });
  expect(result.current.items).toHaveLength(1);
  expect(result.current.total).toBeCloseTo(9.99);
});

test('increments quantity for duplicate items', () => {
  const { result } = renderHook(() => useCartStore());
  act(() => {
    result.current.addItem({ id: 'p1', name: 'Widget', price: 9.99 });
    result.current.addItem({ id: 'p1', name: 'Widget', price: 9.99 });
  });
  expect(result.current.items[0].quantity).toBe(2);
  expect(result.current.total).toBeCloseTo(19.98);
});

// Testing component with Zustand (no wrapper needed — Zustand is global)
import { render, screen } from '@testing-library/react';
import { CartIcon } from './CartIcon';

test('shows item count badge', () => {
  useCartStore.setState({
    items: [{ id: 'p1', name: 'Widget', price: 9.99, quantity: 3 }],
    total: 29.97,
  });
  render(<CartIcon />);
  expect(screen.getByText('3')).toBeInTheDocument();
});

Playwright and Cypress: End-to-End Testing

End-to-end tests run in a real browser and test complete user journeys. Playwright and Cypress are the two leading tools, each with distinct strengths.

Playwright Setup and Examples

# Install Playwright
npm init playwright@latest
# or
npm install --save-dev @playwright/test
npx playwright install
// playwright.config.ts
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', { open: 'never' }]],
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
    { name: 'mobile', use: { ...devices['iPhone 14'] } },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Authentication', () => {
  test('user can log in with valid credentials', async ({ page }) => {
    await page.goto('/login');

    await page.getByLabel(/email/i).fill('alice@example.com');
    await page.getByLabel(/password/i).fill('secret123');
    await page.getByRole('button', { name: /sign in/i }).click();

    // Wait for navigation
    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByText('Welcome, Alice')).toBeVisible();
  });

  test('shows error for invalid credentials', async ({ page }) => {
    await page.goto('/login');

    await page.getByLabel(/email/i).fill('wrong@example.com');
    await page.getByLabel(/password/i).fill('wrongpassword');
    await page.getByRole('button', { name: /sign in/i }).click();

    await expect(page.getByText(/invalid email or password/i)).toBeVisible();
    await expect(page).toHaveURL('/login'); // Stays on login page
  });

  test('redirects to login when accessing protected route', async ({ page }) => {
    await page.goto('/dashboard');
    await expect(page).toHaveURL('/login?redirect=/dashboard');
  });
});

// e2e/checkout.spec.ts
test.describe('Checkout Flow', () => {
  test.beforeEach(async ({ page }) => {
    // Login via API to avoid repeating UI login in every test
    await page.request.post('/api/auth/login', {
      data: { email: 'alice@example.com', password: 'secret123' },
    });
  });

  test('completes purchase with saved payment method', async ({ page }) => {
    await page.goto('/products/widget');
    await page.getByRole('button', { name: /add to cart/i }).click();
    await page.getByRole('link', { name: /checkout/i }).click();

    await expect(page.getByText('Widget')).toBeVisible();
    await expect(page.getByText('$9.99')).toBeVisible();

    await page.getByRole('button', { name: /place order/i }).click();

    await expect(page.getByText(/order confirmed/i)).toBeVisible();
    await expect(page.getByText(/order #/i)).toBeVisible();
  });
});

// Page Object Model (POM) pattern
// e2e/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(private page: Page) {
    this.emailInput = page.getByLabel(/email/i);
    this.passwordInput = page.getByLabel(/password/i);
    this.submitButton = page.getByRole('button', { name: /sign in/i });
    this.errorMessage = page.getByRole('alert');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

Cypress Setup and Examples

// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    specPattern: 'cypress/e2e/**/*.cy.{ts,tsx}',
    viewportWidth: 1280,
    viewportHeight: 720,
    video: false,
    screenshotOnRunFailure: true,
    experimentalStudio: true,
  },
  component: {
    devServer: {
      framework: 'next',
      bundler: 'webpack',
    },
  },
});
// cypress/e2e/dashboard.cy.ts
describe('Dashboard', () => {
  beforeEach(() => {
    // Custom command for login
    cy.login('alice@example.com', 'secret123');
    cy.visit('/dashboard');
  });

  it('displays user statistics', () => {
    cy.getByTestId('stats-card').should('have.length', 4);
    cy.contains('Total Revenue').should('be.visible');
    cy.getByTestId('revenue-chart').should('be.visible');
  });

  it('filters data by date range', () => {
    cy.getByTestId('date-range-picker').click();
    cy.contains('Last 30 days').click();
    cy.getByTestId('revenue-chart').should('be.visible');
    cy.getByTestId('loading-spinner').should('not.exist');
  });
});

// cypress/support/commands.ts
declare global {
  namespace Cypress {
    interface Chainable {
      login(email: string, password: string): Chainable<void>;
      getByTestId(id: string): Chainable<JQuery<HTMLElement>>;
    }
  }
}

Cypress.Commands.add('login', (email: string, password: string) => {
  cy.request({
    method: 'POST',
    url: '/api/auth/login',
    body: { email, password },
  }).then(({ body }) => {
    window.localStorage.setItem('authToken', body.token);
  });
});

Cypress.Commands.add('getByTestId', (id: string) => {
  return cy.get(`[data-testid="${id}"]`);
});

Code Coverage with Istanbul/nyc

Code coverage measures how much of your code is executed by tests. Istanbul (used via nyc, Babel plugin, or V8 provider) is the most widely used coverage tool in the React ecosystem. Coverage has four dimensions: statements, branches, functions, and lines.

# Run tests with coverage
npm test -- --coverage          # Jest
npx vitest run --coverage       # Vitest

# Generate HTML report
npx nyc report --reporter=html
// jest.config.ts — coverage configuration
export default {
  collectCoverage: true,
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.{ts,tsx}',
    '!src/main.tsx',
    '!src/test/**',
    '!src/**/__mocks__/**',
  ],
  coverageDirectory: 'coverage',
  coverageReporters: ['text', 'lcov', 'html', 'json-summary'],
  coverageThresholds: {
    global: {
      statements: 80,
      branches: 70,
      functions: 75,
      lines: 80,
    },
    // Per-file thresholds for critical modules
    './src/utils/auth.ts': {
      statements: 95,
      branches: 90,
      functions: 95,
      lines: 95,
    },
  },
};
// Coverage annotations — tell Istanbul to ignore specific code
/* istanbul ignore next */
function developmentOnlyHelper() {
  if (process.env.NODE_ENV !== 'production') {
    console.debug('Debug info');
  }
}

/* istanbul ignore if */
if (process.env.NODE_ENV === 'test') {
  // Test setup code
}

// Vitest coverage configuration
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',       // or 'istanbul'
      reporter: ['text', 'html', 'lcov'],
      include: ['src/**/*.{ts,tsx}'],
      exclude: ['src/**/*.stories.*', 'src/test/**'],
      thresholds: {
        statements: 80,
        branches: 70,
        functions: 75,
        lines: 80,
      },
    },
  },
});

Coverage in CI/CD

# .github/workflows/test.yml
name: Test & Coverage

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci
      - run: npm test -- --coverage --ci --forceExit

      # Upload coverage to Codecov
      - uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          files: ./coverage/lcov.info
          fail_ci_if_error: true

      # Or upload to Coveralls
      - uses: coverallsapp/github-action@v2
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}

Testing Tool Comparison: RTL vs Enzyme vs Cypress

FeatureReact Testing LibraryEnzymeCypress Component
PhilosophyUser behaviorImplementation detailsReal browser rendering
React 18 SupportFull supportNot supported (deprecated)Full support
SpeedFast (jsdom)Fast (jsdom)Medium (real browser)
DebuggingMedium (CLI output)MediumExcellent (time-travel)
Test Internal StateDiscouragedYes (.state() .props())Discouraged
CommunityVery activeLargely unmaintainedActive
Best ForUnit & integrationLegacy codebase maintenanceVisual component debugging
FeaturePlaywrightCypress
Browser SupportChromium, Firefox, WebKitChromium, Firefox (partial)
ParallelismNative built-inCypress Cloud (paid) or plugins
TypeScriptFirst-classSupported (config needed)
DebuggingTrace viewer, screenshots, videosTime-travel debugger (interactive)
CI/CDExcellentGood (needs Docker image)
Mobile EmulationBuilt-in device listLimited
Best ForCI/CD, cross-browser testingLocal dev, debugging interactions

Testing Best Practices

Organizing Test Files

# Co-locate tests with source files (recommended)
src/
  components/
    UserCard/
      UserCard.tsx
      UserCard.test.tsx     # Unit/integration test
      UserCard.stories.tsx  # Storybook stories
  hooks/
    useCounter.ts
    useCounter.test.ts
  utils/
    formatDate.ts
    formatDate.test.ts
  test/
    setup.ts                # Test setup (jest-dom, MSW server)
    renderWithProviders.tsx # Shared render helper
    factories.ts            # Test data factories

e2e/
  auth.spec.ts
  checkout.spec.ts
  pages/
    LoginPage.ts            # Page Object Models

Test Data Factories

// src/test/factories.ts
// Use factory functions to create test data — avoids brittle hardcoded objects

let idCounter = 0;

export function createUser(overrides: Partial<User> = {}): User {
  idCounter++;
  return {
    id: `user-${idCounter}`,
    name: `Test User ${idCounter}`,
    email: `user${idCounter}@example.com`,
    role: 'user',
    createdAt: new Date('2026-01-01'),
    ...overrides,
  };
}

export function createProduct(overrides: Partial<Product> = {}): Product {
  idCounter++;
  return {
    id: `product-${idCounter}`,
    name: `Test Product ${idCounter}`,
    price: 9.99,
    stock: 100,
    ...overrides,
  };
}

// Usage in tests
const adminUser = createUser({ role: 'admin', name: 'Alice' });
const outOfStockProduct = createProduct({ stock: 0 });

Avoiding Common Testing Anti-Patterns

// BAD: Testing implementation details
test('sets loading state', () => {
  const wrapper = shallow(<UserProfile />);
  wrapper.setState({ loading: true }); // Don't test internal state
  expect(wrapper.state('loading')).toBe(true);
});

// GOOD: Test what the user sees
test('shows loading spinner while fetching', async () => {
  render(<UserProfile userId="1" />);
  expect(screen.getByRole('progressbar')).toBeInTheDocument();
  await screen.findByText('Alice Johnson'); // Wait for data
  expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});

// BAD: Too many assertions in one test (hard to diagnose failures)
test('UserCard', () => {
  render(<UserCard user={user} />);
  expect(screen.getByText('Alice')).toBeInTheDocument();
  expect(screen.getByText('alice@example.com')).toBeInTheDocument();
  expect(screen.getByText('Admin')).toBeInTheDocument();
  expect(screen.getByRole('button')).toBeEnabled();
  // ... 10 more assertions
});

// GOOD: Focused, single-concern tests
test('renders user name', () => { /* ... */ });
test('renders user email', () => { /* ... */ });
test('shows admin badge for admin users', () => { /* ... */ });
test('hides admin badge for regular users', () => { /* ... */ });

// BAD: Depending on test order
let sharedState = null;
test('creates item', () => { sharedState = createItem(); });
test('deletes item', () => { deleteItem(sharedState.id); }); // Depends on previous test!

// GOOD: Each test is independent and self-contained
test('deletes item', () => {
  const item = createItem(); // Each test creates its own data
  deleteItem(item.id);
  expect(getItem(item.id)).toBeNull();
});

Conclusion

Building a robust React testing strategy requires balancing speed, confidence, and maintenance cost. Use React Testing Library for user-centric component tests, MSW to mock APIs without touching application code, renderHook to test custom hooks, real stores to test Redux/Zustand integration, and Playwright or Cypress to cover critical end-to-end user paths. Follow the testing pyramid, write tests that resemble real usage, and enforce coverage thresholds in CI to prevent regressions over time.

Remember that good tests are not just quality gates — they are living documentation of your system's expected behavior. A well-tested React application gives your team the confidence to refactor aggressively, ship features quickly, and sleep soundly at night.

Frequently Asked Questions

What is the difference between React Testing Library and Enzyme?

React Testing Library (RTL) tests components from a user perspective by querying the DOM the way a user would — by role, label, or visible text. Enzyme tests implementation details like component state and lifecycle methods. RTL is the modern standard and aligns with the "test behavior, not implementation" philosophy. Enzyme is largely unmaintained for React 18+.

When should I use userEvent over fireEvent in React Testing Library?

Prefer userEvent over fireEvent in almost all cases. userEvent simulates real browser interactions including hover, focus, keyboard navigation, and pointer events — just like a real user. fireEvent dispatches single synthetic events and can miss intermediate events (e.g., focus before a click). Use fireEvent only for low-level edge cases that userEvent does not support.

What is Mock Service Worker (MSW) and why should I use it for API mocking?

MSW intercepts network requests at the Service Worker level (in browser) or Node.js http module level (in tests). Unlike mocking fetch or axios directly, MSW lets your application code remain unchanged — the same handlers work for unit tests, integration tests, and local development. It produces more realistic tests because the actual HTTP stack is exercised up to the network boundary.

Should I use Jest or Vitest for React testing in 2026?

For new Vite-based projects, Vitest is the natural choice — it is faster, has native ESM support, and shares Vite's configuration. For Create React App or Next.js projects already using Jest, migration is optional but Vitest offers significantly faster test runs especially for large suites. Both support the same expect API so tests are portable.

How do I test custom React hooks?

Use renderHook from @testing-library/react. It renders a minimal component that calls your hook, exposes result.current for the return value, and act() to trigger updates. For hooks with context dependencies, pass a wrapper option with a provider component. Example: const { result } = renderHook(() => useCounter(0)); act(() => result.current.increment()); expect(result.current.count).toBe(1);

What is the testing pyramid and how does it apply to React apps?

The testing pyramid recommends many fast unit tests at the base, fewer integration tests in the middle, and a small number of slow E2E tests at the top. For React: unit tests cover individual components, utility functions, and hooks in isolation; integration tests cover feature slices with real component trees and mocked APIs; E2E tests with Playwright or Cypress cover critical user journeys end-to-end in a real browser.

How do I achieve good code coverage without over-testing?

Target 70-80% coverage as a guideline, not a goal. Focus on critical business logic, edge cases, and error paths. Avoid testing library code, trivial getters/setters, and auto-generated code. Use branch coverage (not just line coverage) to ensure conditional logic is exercised. Configure Istanbul/nyc thresholds in jest.config.js to fail CI when coverage drops below acceptable levels.

What is the difference between Playwright and Cypress for E2E testing?

Playwright supports Chromium, Firefox, and WebKit with a single API, runs tests in parallel natively, and has excellent TypeScript support. Cypress runs exclusively in a browser with a real-time interactive test runner great for debugging, but is limited to Chromium/Firefox and has weaker parallelism. Playwright is generally preferred for CI/CD pipelines; Cypress shines for local development and debugging complex interactions.

𝕏 Twitterin LinkedIn
Was this helpful?

Stay Updated

Get weekly dev tips and new tool announcements.

No spam. Unsubscribe anytime.

Try These Related Tools

.*Regex Tester{ }JSON FormatterB→Base64 Encoder

Related Articles

Jest Testing Guide: Mocking, React Testing Library, Snapshots, and Code Coverage

Master Jest for JavaScript testing. Covers unit testing with describe/it/expect, mocking with jest.fn() and jest.mock(), React Testing Library, async testing, snapshot testing, code coverage, and Jest vs Vitest vs Mocha comparison.

React Hooks Complete Guide: useState, useEffect, and Custom Hooks

Master React Hooks with practical examples. Learn useState, useEffect, useContext, useReducer, useMemo, useCallback, custom hooks, and React 18+ concurrent hooks.

Advanced TypeScript Guide: Generics, Conditional Types, Mapped Types, Decorators, and Type Narrowing

Master advanced TypeScript patterns. Covers generic constraints, conditional types with infer, mapped types (Partial/Pick/Omit), template literal types, discriminated unions, utility types deep dive, decorators, module augmentation, type narrowing, covariance/contravariance, and satisfies operator.