Skip to main content

Mocking in JavaScript & TypeScript

Unit tests should be fast and deterministic. The moment a test hits a real network, database, or file system, it becomes slow, flaky, and dependent on external state. Mocking solves this by replacing the real dependency with a controlled stand-in that you define in the test.

Jest and Vitest both ship a comprehensive mocking system. The APIs are largely identical — examples in this chapter work in both frameworks with minor import differences noted where relevant.

Why Mock?

Consider a UserService that fetches users from an API:

// src/userService.ts
export async function getUser(id: number) {
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) throw new Error(`User ${id} not found`);
return response.json();
}

A test that calls fetch for real:

  • Requires a network connection
  • Depends on the API being up
  • Is slow (hundreds of milliseconds)
  • May return different data over time
  • Cannot reliably test error paths (how do you make the server return a 404?)

A test with a mocked fetch:

  • Runs offline
  • Is deterministic
  • Takes microseconds
  • Can simulate any scenario you need

jest.fn() — Creating a Mock Function

jest.fn() creates a mock function — a function that records every call made to it and lets you define what it returns.

const mockFn = jest.fn();

mockFn('hello');
mockFn('world', 42);

// Inspect calls
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('hello');
expect(mockFn).toHaveBeenLastCalledWith('world', 42);

Configuring Return Values

const fn = jest.fn();

// Always return the same value
fn.mockReturnValue(42);
expect(fn()).toBe(42);
expect(fn()).toBe(42);

// Return different values per call
fn.mockReturnValueOnce(1)
.mockReturnValueOnce(2)
.mockReturnValue(99); // fallback
expect(fn()).toBe(1);
expect(fn()).toBe(2);
expect(fn()).toBe(99);
expect(fn()).toBe(99);

// Return a resolved promise
fn.mockResolvedValue({ id: 1, name: 'Alice' });
const result = await fn();
expect(result.name).toBe('Alice');

// Return a rejected promise
fn.mockRejectedValue(new Error('Network error'));
await expect(fn()).rejects.toThrow('Network error');

Using a Mock in a Test

// src/notificationService.ts
type Sender = (to: string, body: string) => Promise<void>;

export async function sendWelcomeEmail(sender: Sender, userEmail: string) {
await sender(userEmail, 'Welcome to our platform!');
}
// src/notificationService.test.ts
import { sendWelcomeEmail } from './notificationService';

it('calls the sender with the correct arguments', async () => {
const mockSender = jest.fn().mockResolvedValue(undefined);

await sendWelcomeEmail(mockSender, 'alice@example.com');

expect(mockSender).toHaveBeenCalledOnce();
expect(mockSender).toHaveBeenCalledWith(
'alice@example.com',
'Welcome to our platform!'
);
});

jest.spyOn() — Spying on Existing Methods

jest.fn() creates a brand new function. jest.spyOn() wraps an existing method on an object, letting you observe calls without fully replacing the implementation.

import * as fs from 'fs';

it('reads the config file', () => {
const spy = jest.spyOn(fs, 'readFileSync').mockReturnValue('{}');

readConfig('/path/to/config.json');

expect(spy).toHaveBeenCalledWith('/path/to/config.json', 'utf-8');
spy.mockRestore(); // restore the real fs.readFileSync
});

Without .mockReturnValue(), the spy calls the real implementation and just records the call. With it, you replace the behaviour.

Always call spy.mockRestore() after the test (or in afterEach) to prevent the spy from leaking into other tests.

describe('Logger', () => {
let consoleSpy: jest.SpyInstance;

beforeEach(() => {
consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
consoleSpy.mockRestore();
});

it('logs an error when the API fails', () => {
logger.error('Something went wrong');
expect(consoleSpy).toHaveBeenCalledWith('Something went wrong');
});
});

jest.mock() — Mocking Entire Modules

jest.mock() replaces an entire module with a mocked version. Every export from the module becomes a jest.fn() (or a hoisted auto-mock). This is the most powerful mocking mechanism.

Automatic Module Mock

jest.mock('./emailService'); // all exports become jest.fn()

import { sendEmail } from './emailService';

it('sends an email on registration', async () => {
await registerUser('alice@example.com');

expect(sendEmail).toHaveBeenCalledWith(
'alice@example.com',
'Welcome!'
);
});

Manual Module Mock with Factory

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

import { findUser, createUser } from './database';

beforeEach(() => {
(findUser as jest.Mock).mockResolvedValue({ id: 1, name: 'Alice' });
});

it('returns the user when found', async () => {
const user = await userService.getUser(1);
expect(user.name).toBe('Alice');
});

Mocking a Default Export

jest.mock('./ApiClient', () => {
return jest.fn().mockImplementation(() => ({
get: jest.fn(),
post: jest.fn(),
}));
});

import ApiClient from './ApiClient';

it('instantiates the API client', () => {
new ApiClient('https://api.example.com');
expect(ApiClient).toHaveBeenCalledWith('https://api.example.com');
});

Mocking fetch

The global fetch is available in Node 18+ and all browsers. Mock it directly on globalThis:

// src/api.ts
export async function fetchPost(id: number) {
const res = await fetch(`/api/posts/${id}`);
if (!res.ok) throw new Error('Post not found');
return res.json() as Promise<{ id: number; title: string }>;
}
// src/api.test.ts
import { fetchPost } from './api';

function mockFetch(body: unknown, status = 200) {
global.fetch = jest.fn().mockResolvedValue({
ok: status >= 200 && status < 300,
status,
json: jest.fn().mockResolvedValue(body),
} as unknown as Response);
}

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

it('returns post data on success', async () => {
mockFetch({ id: 1, title: 'Hello World' });

const post = await fetchPost(1);

expect(post.title).toBe('Hello World');
expect(global.fetch).toHaveBeenCalledWith('/api/posts/1');
});

it('throws when the response is not ok', async () => {
mockFetch({ error: 'not found' }, 404);

await expect(fetchPost(1)).rejects.toThrow('Post not found');
});

Using msw (Mock Service Worker) for HTTP mocking

For more complex scenarios, msw intercepts requests at the network level:

import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
http.get('/api/posts/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, title: 'Test Post' });
}),
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

it('fetches a post', async () => {
const post = await fetchPost(1);
expect(post.title).toBe('Test Post');
});

it('handles server errors', async () => {
server.use(
http.get('/api/posts/:id', () => new HttpResponse(null, { status: 500 })),
);

await expect(fetchPost(1)).rejects.toThrow();
});

Mocking axios

import axios from 'axios';
jest.mock('axios');

const mockedAxios = jest.mocked(axios);

it('fetches data via axios', async () => {
mockedAxios.get.mockResolvedValue({
data: { id: 1, name: 'Alice' },
status: 200,
});

const user = await userService.getUser(1);

expect(user.name).toBe('Alice');
expect(mockedAxios.get).toHaveBeenCalledWith('/users/1');
});

Clearing and Resetting Mocks

Mock state accumulates across tests if you are not careful. The three reset operations:

MethodWhat it does
mockFn.mockClear()Resets .mock.calls and .mock.results but keeps the implementation
mockFn.mockReset()Clears call history AND removes the return value / implementation
mockFn.mockRestore()Everything mockReset() does, plus restores the original implementation (only for spies)

Configure automatic clearing in jest.config.ts:

const config: Config = {
clearMocks: true, // mockClear() before each test
resetMocks: false, // mockReset() before each test
restoreMocks: true, // mockRestore() before each test
};

For most projects, clearMocks: true and restoreMocks: true is the right default. This prevents call counts from accumulating while keeping your mock implementations.

Testing Async Code Patterns

async/await (preferred)

it('resolves with user data', async () => {
(findUser as jest.Mock).mockResolvedValue({ id: 1, name: 'Alice' });

const user = await userService.getUser(1);

expect(user.name).toBe('Alice');
});

it('rejects when user is not found', async () => {
(findUser as jest.Mock).mockRejectedValue(new Error('Not found'));

await expect(userService.getUser(99)).rejects.toThrow('Not found');
});

Fake timers

When testing code that uses setTimeout, setInterval, or Date.now(), fake timers let you advance time manually:

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

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

it('only calls the function once after the delay', () => {
const fn = jest.fn();
const debounced = debounce(fn, 300);

debounced();
debounced();
debounced();

expect(fn).not.toHaveBeenCalled(); // not yet

jest.advanceTimersByTime(300);

expect(fn).toHaveBeenCalledOnce();
});
});

A Complete Realistic Example

Here is a UserService class tested with full mocking of its HTTP dependency:

// src/userService.ts
export interface User {
id: number;
name: string;
email: string;
}

export class UserService {
constructor(private baseUrl: string) {}

async findById(id: number): Promise<User> {
const res = await fetch(`${this.baseUrl}/users/${id}`);
if (!res.ok) {
throw new Error(`User ${id} not found`);
}
return res.json();
}

async create(data: Omit<User, 'id'>): Promise<User> {
const res = await fetch(`${this.baseUrl}/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) {
throw new Error('Failed to create user');
}
return res.json();
}
}
// src/userService.test.ts
import { UserService } from './userService';

const BASE_URL = 'https://api.example.com';

function makeFetchMock(body: unknown, status = 200) {
return jest.fn().mockResolvedValue({
ok: status < 400,
status,
json: jest.fn().mockResolvedValue(body),
});
}

describe('UserService', () => {
let service: UserService;

beforeEach(() => {
service = new UserService(BASE_URL);
});

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

describe('findById', () => {
it('returns the user when found', async () => {
global.fetch = makeFetchMock({ id: 1, name: 'Alice', email: 'alice@example.com' });

const user = await service.findById(1);

expect(user.name).toBe('Alice');
expect(global.fetch).toHaveBeenCalledWith(`${BASE_URL}/users/1`);
});

it('throws when the user is not found', async () => {
global.fetch = makeFetchMock({ error: 'not found' }, 404);

await expect(service.findById(99)).rejects.toThrow('User 99 not found');
});
});

describe('create', () => {
it('POSTs to the correct endpoint with JSON body', async () => {
const created = { id: 2, name: 'Bob', email: 'bob@example.com' };
global.fetch = makeFetchMock(created, 201);

const user = await service.create({ name: 'Bob', email: 'bob@example.com' });

expect(user.id).toBe(2);
expect(global.fetch).toHaveBeenCalledWith(
`${BASE_URL}/users`,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ name: 'Bob', email: 'bob@example.com' }),
}),
);
});
});
});

Chapter 5 covers the same mocking concepts for Java using Mockito.