Mocking in React Tests: Strategies and Best Practices

Shunku

Mocking is essential for isolating components and testing them independently. This guide covers common mocking patterns in React testing with Jest and Vitest.

Why Mock?

Mocking helps you:

  • Isolate the code under test
  • Control dependencies' behavior
  • Avoid slow or unreliable operations (network, filesystem)
  • Test edge cases and error scenarios
flowchart TD
    A[Component Under Test] --> B{External Dependency}
    B -->|Real| C[Slow/Unreliable]
    B -->|Mocked| D[Fast/Controllable]

    style C fill:#ef4444,color:#fff
    style D fill:#10b981,color:#fff

Mocking Modules

Basic Module Mock

// api.ts
export async function fetchUsers() {
  const response = await fetch('/api/users');
  return response.json();
}

// UserList.tsx
import { fetchUsers } from './api';

function UserList() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetchUsers().then(setUsers);
  }, []);

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}
// UserList.test.tsx
import { render, screen } from '@testing-library/react';
import { UserList } from './UserList';
import { fetchUsers } from './api';

// Mock the entire module
jest.mock('./api');

// Type the mocked function
const mockFetchUsers = fetchUsers as jest.MockedFunction<typeof fetchUsers>;

test('displays users', async () => {
  mockFetchUsers.mockResolvedValue([
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' },
  ]);

  render(<UserList />);

  expect(await screen.findByText('Alice')).toBeInTheDocument();
  expect(screen.getByText('Bob')).toBeInTheDocument();
});

test('handles empty list', async () => {
  mockFetchUsers.mockResolvedValue([]);

  render(<UserList />);

  // Assert empty state
  await waitFor(() => {
    expect(screen.queryByRole('listitem')).not.toBeInTheDocument();
  });
});

Partial Module Mock

Mock only specific exports:

// utils.ts
export function formatDate(date: Date) { /* ... */ }
export function formatCurrency(amount: number) { /* ... */ }
export function calculateTax(amount: number) { /* ... */ }
// Mock only formatDate, keep others real
jest.mock('./utils', () => ({
  ...jest.requireActual('./utils'),
  formatDate: jest.fn(() => '2024-01-01'),
}));

Mock with Factory

jest.mock('./api', () => ({
  fetchUsers: jest.fn(),
  createUser: jest.fn(),
  deleteUser: jest.fn(),
}));

Mocking Components

Child Component Mock

// Dashboard.tsx
import { HeavyChart } from './HeavyChart';
import { UserStats } from './UserStats';

function Dashboard({ userId }) {
  return (
    <div>
      <h1>Dashboard</h1>
      <UserStats userId={userId} />
      <HeavyChart data={chartData} />
    </div>
  );
}
// Dashboard.test.tsx
// Mock child components
jest.mock('./HeavyChart', () => ({
  HeavyChart: () => <div data-testid="mock-chart">Chart</div>,
}));

jest.mock('./UserStats', () => ({
  UserStats: ({ userId }) => <div data-testid="mock-stats">Stats for {userId}</div>,
}));

test('renders dashboard with mocked children', () => {
  render(<Dashboard userId={123} />);

  expect(screen.getByText('Dashboard')).toBeInTheDocument();
  expect(screen.getByTestId('mock-chart')).toBeInTheDocument();
  expect(screen.getByTestId('mock-stats')).toHaveTextContent('Stats for 123');
});

Mock with Props Capture

jest.mock('./UserStats', () => ({
  UserStats: jest.fn(() => null),
}));

import { UserStats } from './UserStats';

test('passes correct props to UserStats', () => {
  render(<Dashboard userId={123} />);

  expect(UserStats).toHaveBeenCalledWith(
    expect.objectContaining({ userId: 123 }),
    expect.anything()
  );
});

Spying on Functions

Spy vs Mock

flowchart LR
    subgraph Spy["jest.spyOn"]
        A[Observe calls]
        B[Keep original implementation]
        C[Optionally override]
    end

    subgraph Mock["jest.fn / jest.mock"]
        D[Replace entirely]
        E[Control return values]
        F[No original behavior]
    end

    style Spy fill:#3b82f6,color:#fff
    style Mock fill:#10b981,color:#fff

Using Spies

// Spy on object method
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();

test('logs error on failure', async () => {
  render(<ComponentThatMightError />);

  await waitFor(() => {
    expect(consoleSpy).toHaveBeenCalledWith('Something went wrong');
  });

  consoleSpy.mockRestore();
});

Spy on Module Functions

import * as api from './api';

test('calls fetchUsers on mount', () => {
  const spy = jest.spyOn(api, 'fetchUsers').mockResolvedValue([]);

  render(<UserList />);

  expect(spy).toHaveBeenCalledTimes(1);

  spy.mockRestore();
});

Mock Functions (jest.fn)

Basic Usage

test('calls onClick handler', async () => {
  const handleClick = jest.fn();
  const user = userEvent.setup();

  render(<Button onClick={handleClick}>Click me</Button>);

  await user.click(screen.getByRole('button'));

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

Mock Return Values

const mockFn = jest.fn();

// Return value
mockFn.mockReturnValue('hello');

// Return value once
mockFn.mockReturnValueOnce('first').mockReturnValueOnce('second');

// Async return
mockFn.mockResolvedValue({ data: 'value' });
mockFn.mockRejectedValue(new Error('Failed'));

Assertions

// Was called
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(3);

// Called with specific arguments
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenLastCalledWith('final');
expect(mockFn).toHaveBeenNthCalledWith(2, 'second call');

// Not called
expect(mockFn).not.toHaveBeenCalled();

// Check all calls
expect(mockFn.mock.calls).toEqual([
  ['first call'],
  ['second call'],
  ['third call'],
]);

Mocking Browser APIs

localStorage

const localStorageMock = {
  getItem: jest.fn(),
  setItem: jest.fn(),
  removeItem: jest.fn(),
  clear: jest.fn(),
};

Object.defineProperty(window, 'localStorage', {
  value: localStorageMock,
});

test('saves to localStorage', () => {
  render(<SettingsForm />);

  // ... interact with form

  expect(localStorageMock.setItem).toHaveBeenCalledWith(
    'settings',
    expect.any(String)
  );
});

window.matchMedia

beforeAll(() => {
  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(),
    })),
  });
});

test('detects dark mode preference', () => {
  window.matchMedia = jest.fn().mockImplementation((query) => ({
    matches: query === '(prefers-color-scheme: dark)',
    media: query,
    // ... rest of mock
  }));

  render(<ThemeProvider><App /></ThemeProvider>);

  expect(screen.getByTestId('theme')).toHaveTextContent('dark');
});

IntersectionObserver

const mockIntersectionObserver = jest.fn();
mockIntersectionObserver.mockReturnValue({
  observe: jest.fn(),
  unobserve: jest.fn(),
  disconnect: jest.fn(),
});

window.IntersectionObserver = mockIntersectionObserver;

test('lazy loads images', () => {
  render(<LazyImage src="photo.jpg" />);

  expect(mockIntersectionObserver).toHaveBeenCalled();
});

Mocking External Libraries

React Router

import { useNavigate, useParams } from 'react-router-dom';

jest.mock('react-router-dom', () => ({
  ...jest.requireActual('react-router-dom'),
  useNavigate: jest.fn(),
  useParams: jest.fn(),
}));

test('navigates on submit', async () => {
  const navigate = jest.fn();
  (useNavigate as jest.Mock).mockReturnValue(navigate);
  (useParams as jest.Mock).mockReturnValue({ id: '123' });

  const user = userEvent.setup();
  render(<EditForm />);

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

  expect(navigate).toHaveBeenCalledWith('/success');
});

Date/Time Libraries

// Mock current date
beforeEach(() => {
  jest.useFakeTimers();
  jest.setSystemTime(new Date('2024-01-15'));
});

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

test('shows current date', () => {
  render(<DateDisplay />);

  expect(screen.getByText('January 15, 2024')).toBeInTheDocument();
});

Clearing and Resetting Mocks

In Individual Tests

const mockFn = jest.fn();

afterEach(() => {
  mockFn.mockClear(); // Clear call history
  // or
  mockFn.mockReset(); // Clear + remove return values
  // or
  mockFn.mockRestore(); // Restore original (for spies)
});

Global Configuration

// jest.config.js or vitest.config.ts
{
  clearMocks: true, // Clear mock calls between tests
  resetMocks: true, // Reset mock state between tests
  restoreMocks: true, // Restore original implementations
}

Mock Patterns

Factory Pattern for Complex Mocks

// test-utils/mockUser.ts
export function createMockUser(overrides = {}) {
  return {
    id: 1,
    name: 'Test User',
    email: 'test@example.com',
    role: 'user',
    ...overrides,
  };
}

// Usage
test('shows admin badge for admin users', () => {
  const adminUser = createMockUser({ role: 'admin' });

  render(<UserProfile user={adminUser} />);

  expect(screen.getByText('Admin')).toBeInTheDocument();
});

Context Mock Helper

// test-utils/renderWithProviders.tsx
export function renderWithAuth(ui, { user = null } = {}) {
  return render(
    <AuthContext.Provider value={{ user, isAuthenticated: !!user }}>
      {ui}
    </AuthContext.Provider>
  );
}

// Usage
test('shows login button when not authenticated', () => {
  renderWithAuth(<Header />);

  expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument();
});

test('shows user name when authenticated', () => {
  renderWithAuth(<Header />, { user: { name: 'Alice' } });

  expect(screen.getByText('Alice')).toBeInTheDocument();
});

Best Practices

1. Don't Over-Mock

// Bad: Mocking everything
jest.mock('./utils');
jest.mock('./helpers');
jest.mock('./formatters');

// Good: Mock only external dependencies
jest.mock('./api'); // External API calls only

2. Mock at the Right Level

// Bad: Mocking implementation details
jest.mock('./useUserState'); // Internal hook

// Good: Mock external boundaries
jest.mock('./api'); // Network boundary

3. Keep Mocks Close to Real Behavior

// Bad: Unrealistic mock
mockFetch.mockResolvedValue({ anything: 'goes' });

// Good: Match API shape
mockFetch.mockResolvedValue({
  users: [{ id: 1, name: 'Alice', email: 'alice@example.com' }],
  total: 1,
  page: 1,
});

4. Reset Mocks Between Tests

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

Summary

Technique Use Case
jest.mock() Mock entire modules
jest.spyOn() Observe calls, optionally mock
jest.fn() Create mock functions
mockReturnValue Set return values
mockResolvedValue Set async return values
mockImplementation Custom mock behavior

Key takeaways:

  • Mock external dependencies, not internal implementation
  • Use spies when you want to observe without changing behavior
  • Create mock factories for complex data structures
  • Clear mocks between tests to avoid pollution
  • Keep mocks realistic to catch integration issues
  • Prefer MSW for API mocking over manual fetch mocks

Effective mocking makes tests faster, more reliable, and easier to write. Focus on mocking at the boundaries of your application.

References

  • Jest Mock Functions
  • Vitest Mocking
  • Crump, Scottie. Simplify Testing with React Testing Library. Packt, 2021.
  • Ruscio, Daniel. Testing JavaScript Applications. Manning Publications, 2021.