Learn Jest in 10 DaysDay 4: Mocks, Stubs, and Spies
Chapter 4Learn Jest in 10 Days

Day 4: Mocks, Stubs, and Spies

What You'll Learn Today

  • What test doubles are (mocks, stubs, spies)
  • Creating mock functions with jest.fn()
  • Controlling return values and implementations
  • Watching existing methods with jest.spyOn()
  • Mocking entire modules with jest.mock()
  • Resetting and cleaning up mocks

What Are Test Doubles?

In unit testing, the function under test often depends on external modules (APIs, databases, file systems). Replacing these dependencies with stand-ins is called using test doubles.

flowchart TB
    subgraph Real["Production Code"]
        CODE["Function"]
        API["External API"]
        DB["Database"]
        FS["File System"]
    end
    subgraph Test["Test Code"]
        TCODE["Function"]
        MOCK_API["Mock API"]
        MOCK_DB["Mock DB"]
        MOCK_FS["Mock FS"]
    end
    CODE --> API
    CODE --> DB
    CODE --> FS
    TCODE --> MOCK_API
    TCODE --> MOCK_DB
    TCODE --> MOCK_FS
    style Real fill:#ef4444,color:#fff
    style Test fill:#22c55e,color:#fff

Types of Test Doubles

Type Purpose Jest Implementation
Stub Returns fixed values jest.fn().mockReturnValue()
Mock Records calls for verification jest.fn()
Spy Watches real implementation jest.spyOn()
flowchart LR
    subgraph Doubles["Test Double Types"]
        STUB["Stub\nReturns fixed values"]
        MOCK["Mock\nRecords calls"]
        SPY["Spy\nWatches real code"]
    end
    STUB -->|"Control returns"| U1["Test output\nfor given input"]
    MOCK -->|"Verify calls"| U2["Test that code\ncalls correctly"]
    SPY -->|"Keep real impl"| U3["Monitor side effects"]
    style STUB fill:#3b82f6,color:#fff
    style MOCK fill:#8b5cf6,color:#fff
    style SPY fill:#22c55e,color:#fff

jest.fn() β€” Mock Function Basics

jest.fn() creates a mock function that records all calls, arguments, and return values.

test('jest.fn() creates a mock function', () => {
  const mockFn = jest.fn();

  mockFn('hello');
  mockFn('world');

  // called twice
  expect(mockFn).toHaveBeenCalledTimes(2);

  // called with specific arguments
  expect(mockFn).toHaveBeenCalledWith('hello');
  expect(mockFn).toHaveBeenCalledWith('world');

  // last called with
  expect(mockFn).toHaveBeenLastCalledWith('world');
});

TypeScript version:

test('jest.fn() creates a mock function', () => {
  const mockFn = jest.fn<void, [string]>();

  mockFn('hello');
  mockFn('world');

  expect(mockFn).toHaveBeenCalledTimes(2);
  expect(mockFn).toHaveBeenCalledWith('hello');
  expect(mockFn).toHaveBeenLastCalledWith('world');
});

Mock Function Matchers

Matcher Description
toHaveBeenCalled() Called at least once
toHaveBeenCalledTimes(n) Called exactly n times
toHaveBeenCalledWith(arg1, arg2, ...) Called with specific arguments
toHaveBeenLastCalledWith(arg1, ...) Last call's arguments
toHaveBeenNthCalledWith(n, arg1, ...) Nth call's arguments
toHaveReturned() Returned successfully
toHaveReturnedWith(value) Returned a specific value

Controlling Return Values

mockReturnValue β€” Fixed Return Value

test('mockReturnValue returns a fixed value', () => {
  const getPrice = jest.fn().mockReturnValue(100);

  expect(getPrice()).toBe(100);
  expect(getPrice()).toBe(100); // always returns 100
});

mockReturnValueOnce β€” One-Time Return Value

test('mockReturnValueOnce returns different values per call', () => {
  const random = jest.fn()
    .mockReturnValueOnce(1)
    .mockReturnValueOnce(2)
    .mockReturnValueOnce(3);

  expect(random()).toBe(1);
  expect(random()).toBe(2);
  expect(random()).toBe(3);
  expect(random()).toBeUndefined(); // no more mocked values
});

mockResolvedValue β€” Promise Return Values

For async function mocks, use mockResolvedValue.

test('mockResolvedValue returns a resolved promise', async () => {
  const fetchUser = jest.fn().mockResolvedValue({ name: 'Alice', age: 25 });

  const user = await fetchUser();

  expect(user).toEqual({ name: 'Alice', age: 25 });
  expect(fetchUser).toHaveBeenCalledTimes(1);
});

test('mockRejectedValue returns a rejected promise', async () => {
  const fetchUser = jest.fn().mockRejectedValue(new Error('Network error'));

  await expect(fetchUser()).rejects.toThrow('Network error');
});

TypeScript version:

interface User {
  name: string;
  age: number;
}

test('mockResolvedValue returns a resolved promise', async () => {
  const fetchUser = jest.fn<Promise<User>, []>()
    .mockResolvedValue({ name: 'Alice', age: 25 });

  const user = await fetchUser();

  expect(user).toEqual({ name: 'Alice', age: 25 });
});
Method Purpose
mockReturnValue(val) Always return a fixed value
mockReturnValueOnce(val) Return a value once
mockResolvedValue(val) Always return a resolved Promise
mockResolvedValueOnce(val) Return a resolved Promise once
mockRejectedValue(err) Always return a rejected Promise
mockRejectedValueOnce(err) Return a rejected Promise once

mockImplementation β€” Custom Implementation

For more complex mocks, use mockImplementation.

test('mockImplementation provides custom behavior', () => {
  const add = jest.fn().mockImplementation((a, b) => a + b);

  expect(add(1, 2)).toBe(3);
  expect(add(10, 20)).toBe(30);
});

// shorthand: pass implementation to jest.fn()
test('jest.fn() accepts an implementation directly', () => {
  const add = jest.fn((a, b) => a + b);

  expect(add(1, 2)).toBe(3);
});

Practical Example: Testing Callbacks

Mock functions are ideal for testing callbacks.

// forEach.js
function forEach(items, callback) {
  for (let i = 0; i < items.length; i++) {
    callback(items[i], i);
  }
}

module.exports = forEach;

TypeScript version:

// forEach.ts
export function forEach<T>(items: T[], callback: (item: T, index: number) => void): void {
  for (let i = 0; i < items.length; i++) {
    callback(items[i], i);
  }
}
// forEach.test.js
const forEach = require('./forEach');

test('calls callback for each item', () => {
  const mockCallback = jest.fn();

  forEach(['a', 'b', 'c'], mockCallback);

  // called 3 times
  expect(mockCallback).toHaveBeenCalledTimes(3);

  // check arguments for each call
  expect(mockCallback).toHaveBeenNthCalledWith(1, 'a', 0);
  expect(mockCallback).toHaveBeenNthCalledWith(2, 'b', 1);
  expect(mockCallback).toHaveBeenNthCalledWith(3, 'c', 2);
});

jest.spyOn() β€” Watching Existing Methods

jest.spyOn() watches an existing method on an object. The original implementation is preserved, so real behavior continues while calls are recorded.

// calculator.js
const calculator = {
  add(a, b) {
    return a + b;
  },
  subtract(a, b) {
    return a - b;
  },
};

module.exports = calculator;
// calculator.test.js
const calculator = require('./calculator');

test('spyOn tracks calls without changing behavior', () => {
  const spy = jest.spyOn(calculator, 'add');

  const result = calculator.add(1, 2);

  expect(result).toBe(3); // original implementation
  expect(spy).toHaveBeenCalledWith(1, 2);
  expect(spy).toHaveBeenCalledTimes(1);

  spy.mockRestore(); // restore original
});

Overriding Return Values with spyOn

test('spyOn can override return value', () => {
  const spy = jest.spyOn(calculator, 'add').mockReturnValue(999);

  const result = calculator.add(1, 2);

  expect(result).toBe(999); // overridden
  expect(spy).toHaveBeenCalledWith(1, 2);

  spy.mockRestore();
});

Spying on console.log

test('spy on console.log', () => {
  const spy = jest.spyOn(console, 'log').mockImplementation();

  console.log('hello');
  console.log('world');

  expect(spy).toHaveBeenCalledTimes(2);
  expect(spy).toHaveBeenCalledWith('hello');

  spy.mockRestore();
});

TypeScript version:

test('spy on console.log', () => {
  const spy = jest.spyOn(console, 'log').mockImplementation();

  console.log('hello');

  expect(spy).toHaveBeenCalledWith('hello');

  spy.mockRestore();
});
Comparison jest.fn() jest.spyOn()
Target Creates a new function Watches an existing method
Original implementation None (returns undefined) Preserved
Restoration Not needed Use mockRestore()
Use case Callbacks, dependency stand-ins Monitoring existing code

jest.mock() β€” Mocking Entire Modules

jest.mock() replaces an entire module with a mock.

// userService.js
const axios = require('axios');

async function getUser(id) {
  const response = await axios.get(`https://api.example.com/users/${id}`);
  return response.data;
}

module.exports = { getUser };

TypeScript version:

// userService.ts
import axios from 'axios';

export interface User {
  id: number;
  name: string;
  email: string;
}

export async function getUser(id: number): Promise<User> {
  const response = await axios.get<User>(`https://api.example.com/users/${id}`);
  return response.data;
}
// userService.test.js
const axios = require('axios');
const { getUser } = require('./userService');

jest.mock('axios');

describe('getUser', () => {
  test('fetches user data from API', async () => {
    const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' };
    axios.get.mockResolvedValue({ data: mockUser });

    const user = await getUser(1);

    expect(user).toEqual(mockUser);
    expect(axios.get).toHaveBeenCalledWith('https://api.example.com/users/1');
    expect(axios.get).toHaveBeenCalledTimes(1);
  });

  test('handles API error', async () => {
    axios.get.mockRejectedValue(new Error('Network error'));

    await expect(getUser(1)).rejects.toThrow('Network error');
  });
});

TypeScript test version:

// userService.test.ts
import axios from 'axios';
import { getUser } from './userService';

jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

describe('getUser', () => {
  test('fetches user data from API', async () => {
    const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' };
    mockedAxios.get.mockResolvedValue({ data: mockUser });

    const user = await getUser(1);

    expect(user).toEqual(mockUser);
    expect(mockedAxios.get).toHaveBeenCalledWith(
      'https://api.example.com/users/1'
    );
  });
});
flowchart TB
    subgraph Without["Without Mocks"]
        T1["Test"] --> S1["userService"] --> A1["axios"] --> API["External API\n(unreliable)"]
    end
    subgraph With["With Mocks"]
        T2["Test"] --> S2["userService"] --> A2["axios (mock)\nFixed response"]
    end
    style Without fill:#ef4444,color:#fff
    style With fill:#22c55e,color:#fff

Factory Function Mocks

Specify the mock implementation as the second argument to jest.mock().

jest.mock('./logger', () => ({
  log: jest.fn(),
  error: jest.fn(),
  warn: jest.fn(),
}));

Resetting and Cleaning Up Mocks

Resetting mock state between tests is essential.

describe('mock cleanup', () => {
  const mockFn = jest.fn();

  afterEach(() => {
    mockFn.mockClear(); // or mockReset() or mockRestore()
  });

  test('first test', () => {
    mockFn('hello');
    expect(mockFn).toHaveBeenCalledTimes(1);
  });

  test('second test starts fresh', () => {
    mockFn('world');
    expect(mockFn).toHaveBeenCalledTimes(1); // not 2
  });
});
Method What It Resets
mockClear() Clears call records (keeps implementation)
mockReset() Clears records + resets implementation (returns undefined)
mockRestore() Restores original implementation (use with spyOn)
flowchart LR
    subgraph Clear["mockClear()"]
        C1["Call records β†’ Cleared"]
        C2["Implementation β†’ Kept"]
    end
    subgraph Reset["mockReset()"]
        R1["Call records β†’ Cleared"]
        R2["Implementation β†’ Reset"]
    end
    subgraph Restore["mockRestore()"]
        RE1["Call records β†’ Cleared"]
        RE2["Implementation β†’ Original"]
    end
    style Clear fill:#3b82f6,color:#fff
    style Reset fill:#f59e0b,color:#fff
    style Restore fill:#22c55e,color:#fff

Best practice: Set clearMocks: true in jest.config.js to automatically run mockClear() after each test.

// jest.config.js
module.exports = {
  clearMocks: true,
};

Summary

Concept Description
Test Doubles Replace external dependencies with stand-ins
jest.fn() Creates a mock function that records calls
mockReturnValue Sets a fixed return value
mockResolvedValue Sets mock to return a Promise
mockImplementation Provides custom mock behavior
jest.spyOn() Watches existing methods (preserves implementation)
jest.mock() Replaces an entire module with mocks
mockClear / mockReset / mockRestore Reset mock state

Key Takeaways

  1. jest.fn() mocks callbacks and dependency functions
  2. jest.spyOn() watches real code without changing its behavior
  3. jest.mock() replaces entire modules to eliminate external dependencies
  4. Always reset mock state between tests

Exercises

Exercise 1: Basics

Write tests for the following notifyUsers function. Pass sendEmail as a mock function.

function notifyUsers(users, sendEmail) {
  users.forEach(user => {
    sendEmail(user.email, `Hello, ${user.name}!`);
  });
}

Exercise 2: Intermediate

Write tests for the following fetchAndSave function using jest.mock().

const api = require('./api');
const db = require('./db');

async function fetchAndSave(id) {
  const data = await api.fetch(id);
  await db.save(data);
  return data;
}

Challenge

Use jest.spyOn() to control Math.random() so it always returns 0.5, then test the following function.

function getRandomItem(arr) {
  const index = Math.floor(Math.random() * arr.length);
  return arr[index];
}

References


Next up: In Day 5, we'll learn about "Testing Asynchronous Code." You'll explore how to test async/await, Promises, and timers (setTimeout/setInterval) with Jest!