JavaScript Mock Functions: jest.fn() and vi.fn()
Unit testing means testing code in isolation. But real code has dependencies—API clients, databases, file systems, third-party services. You don't want your unit tests making actual HTTP requests or...
Key Insights
- Mock functions let you isolate code under test by replacing real dependencies with controllable substitutes—use
jest.fn()in Jest orvi.fn()in Vitest with nearly identical APIs - The real power of mocks lies in their spy capabilities: tracking call counts, arguments, and return values to verify your code interacts correctly with dependencies
- Vitest offers better ESM support and faster execution while maintaining Jest compatibility, making migration straightforward for most codebases
Introduction to Mock Functions
Unit testing means testing code in isolation. But real code has dependencies—API clients, databases, file systems, third-party services. You don’t want your unit tests making actual HTTP requests or writing to production databases. That’s where mock functions come in.
A mock function is a fake implementation that replaces a real dependency during testing. It does two things: it provides controlled return values so you can test different scenarios, and it records how it was called so you can verify your code’s behavior.
Consider this user service that depends on an API client:
// userService.js
export async function getUserDisplayName(userId, apiClient) {
const user = await apiClient.fetchUser(userId);
return `${user.firstName} ${user.lastName}`;
}
Testing this function without mocking means hitting a real API. That’s slow, flaky, and requires network access. With mocking, you replace apiClient with a controlled substitute that returns exactly what you need.
jest.fn() Fundamentals
Jest’s jest.fn() creates a mock function with no implementation by default—it returns undefined when called. You then configure it to behave however your test requires.
// Basic mock creation
const mockFn = jest.fn();
mockFn(); // returns undefined
Controlling Return Values
Use mockReturnValue for synchronous returns and mockResolvedValue for promises:
const getName = jest.fn().mockReturnValue('Alice');
console.log(getName()); // 'Alice'
const fetchData = jest.fn().mockResolvedValue({ id: 1, name: 'Test' });
await fetchData(); // { id: 1, name: 'Test' }
For different return values on successive calls, use mockReturnValueOnce:
const getNext = jest.fn()
.mockReturnValueOnce(1)
.mockReturnValueOnce(2)
.mockReturnValue(0); // default after exhausting "once" values
getNext(); // 1
getNext(); // 2
getNext(); // 0
Mock Implementations
When you need more complex behavior, mockImplementation lets you define custom logic:
// userService.test.js
import { getUserDisplayName } from './userService';
describe('getUserDisplayName', () => {
it('formats user name correctly', async () => {
const mockApiClient = {
fetchUser: jest.fn().mockResolvedValue({
firstName: 'John',
lastName: 'Doe'
})
};
const result = await getUserDisplayName('user-123', mockApiClient);
expect(result).toBe('John Doe');
expect(mockApiClient.fetchUser).toHaveBeenCalledWith('user-123');
});
it('handles API errors', async () => {
const mockApiClient = {
fetchUser: jest.fn().mockRejectedValue(new Error('Network error'))
};
await expect(getUserDisplayName('user-123', mockApiClient))
.rejects.toThrow('Network error');
});
});
vi.fn() in Vitest
Vitest emerged as a faster, ESM-native alternative to Jest. Its mock API mirrors Jest almost exactly, making migration painless.
import { vi, describe, it, expect } from 'vitest';
const mockFn = vi.fn();
mockFn.mockReturnValue('test');
mockFn.mockResolvedValue({ data: 'async' });
mockFn.mockImplementation((x) => x * 2);
The same API service test in Vitest:
// userService.test.js
import { describe, it, expect, vi } from 'vitest';
import { getUserDisplayName } from './userService';
describe('getUserDisplayName', () => {
it('formats user name correctly', async () => {
const mockApiClient = {
fetchUser: vi.fn().mockResolvedValue({
firstName: 'John',
lastName: 'Doe'
})
};
const result = await getUserDisplayName('user-123', mockApiClient);
expect(result).toBe('John Doe');
expect(mockApiClient.fetchUser).toHaveBeenCalledWith('user-123');
});
});
The only change? jest.fn() becomes vi.fn(). Vitest’s advantages show up in execution speed (leveraging Vite’s transform pipeline) and native ESM support without configuration gymnastics.
Assertions and Spy Capabilities
Mock functions are also spies—they record every interaction. This lets you verify not just what your function returns, but how it uses its dependencies.
Call Tracking
const handler = jest.fn();
handler('first');
handler('second', 'arg');
handler();
expect(handler).toHaveBeenCalled();
expect(handler).toHaveBeenCalledTimes(3);
expect(handler).toHaveBeenCalledWith('first');
expect(handler).toHaveBeenLastCalledWith();
expect(handler).toHaveBeenNthCalledWith(2, 'second', 'arg');
Accessing Raw Call Data
The mock property exposes detailed call information:
const fn = jest.fn().mockImplementation((x) => x * 2);
fn(5);
fn(10);
console.log(fn.mock.calls); // [[5], [10]]
console.log(fn.mock.results); // [{ type: 'return', value: 10 }, { type: 'return', value: 20 }]
Practical Example: Form Submission Handler
// formHandler.js
export function createFormHandler(validator, apiClient, analytics) {
return async function handleSubmit(formData) {
const errors = validator.validate(formData);
if (errors.length > 0) {
analytics.track('form_validation_failed', { errors });
return { success: false, errors };
}
const result = await apiClient.submit(formData);
analytics.track('form_submitted', { id: result.id });
return { success: true, id: result.id };
};
}
// formHandler.test.js
import { createFormHandler } from './formHandler';
describe('form submission handler', () => {
let mockValidator, mockApiClient, mockAnalytics, handleSubmit;
beforeEach(() => {
mockValidator = { validate: jest.fn().mockReturnValue([]) };
mockApiClient = { submit: jest.fn().mockResolvedValue({ id: 'sub-123' }) };
mockAnalytics = { track: jest.fn() };
handleSubmit = createFormHandler(mockValidator, mockApiClient, mockAnalytics);
});
it('validates form data before submission', async () => {
const formData = { email: 'test@example.com' };
await handleSubmit(formData);
expect(mockValidator.validate).toHaveBeenCalledWith(formData);
expect(mockValidator.validate).toHaveBeenCalledBefore(mockApiClient.submit);
});
it('tracks successful submissions with result id', async () => {
await handleSubmit({ email: 'test@example.com' });
expect(mockAnalytics.track).toHaveBeenCalledWith(
'form_submitted',
expect.objectContaining({ id: 'sub-123' })
);
});
it('does not submit when validation fails', async () => {
mockValidator.validate.mockReturnValue(['Email is required']);
const result = await handleSubmit({});
expect(result.success).toBe(false);
expect(mockApiClient.submit).not.toHaveBeenCalled();
expect(mockAnalytics.track).toHaveBeenCalledWith(
'form_validation_failed',
expect.objectContaining({ errors: ['Email is required'] })
);
});
});
Mocking Modules and Dependencies
When dependencies are imported rather than injected, you need module-level mocking.
Jest Module Mocking
// Jest hoists this to the top of the file
jest.mock('./database', () => ({
query: jest.fn(),
connect: jest.fn().mockResolvedValue(true)
}));
import { query, connect } from './database';
import { getUsers } from './userRepository';
describe('userRepository', () => {
it('queries the database for users', async () => {
query.mockResolvedValue([{ id: 1, name: 'Alice' }]);
const users = await getUsers();
expect(query).toHaveBeenCalledWith('SELECT * FROM users');
expect(users).toHaveLength(1);
});
});
Vitest Module Mocking
Vitest requires explicit hoisting with vi.mock:
import { vi, describe, it, expect } from 'vitest';
vi.mock('./database', () => ({
query: vi.fn(),
connect: vi.fn().mockResolvedValue(true)
}));
import { query, connect } from './database';
import { getUsers } from './userRepository';
// Tests are identical to Jest
Partial Mocking
Sometimes you want to mock only specific exports while keeping others real:
// Jest
jest.mock('./utils', () => ({
...jest.requireActual('./utils'),
fetchRemoteConfig: jest.fn().mockResolvedValue({ feature: true })
}));
// Vitest
vi.mock('./utils', async () => ({
...(await vi.importActual('./utils')),
fetchRemoteConfig: vi.fn().mockResolvedValue({ feature: true })
}));
Common Patterns and Pitfalls
Clearing vs Resetting Mocks
Mocks accumulate state between tests. Clean them up:
describe('with proper cleanup', () => {
const mockService = jest.fn();
beforeEach(() => {
// mockClear: resets call history, keeps implementation
mockService.mockClear();
// mockReset: clears history AND removes implementation
// mockService.mockReset();
// mockRestore: for spies, restores original implementation
// mockService.mockRestore();
});
it('test one', () => {
mockService();
expect(mockService).toHaveBeenCalledTimes(1);
});
it('test two', () => {
mockService();
// Without cleanup, this would be 2
expect(mockService).toHaveBeenCalledTimes(1);
});
});
Avoiding Over-Mocking
Mock only what you must. If you’re mocking everything, you’re testing nothing. A good rule: mock I/O boundaries (network, filesystem, databases) but let pure business logic run unmocked.
TypeScript Considerations
Type your mocks properly to catch errors at compile time:
import { vi, type Mock } from 'vitest';
interface ApiClient {
fetchUser(id: string): Promise<User>;
}
const mockApiClient: ApiClient = {
fetchUser: vi.fn<[string], Promise<User>>()
};
// Or use jest.Mocked utility type
const mockClient = {
fetchUser: jest.fn()
} as jest.Mocked<ApiClient>;
Migration and Interoperability
Switching between Jest and Vitest is mostly find-and-replace:
| Jest | Vitest |
|---|---|
jest.fn() |
vi.fn() |
jest.mock() |
vi.mock() |
jest.spyOn() |
vi.spyOn() |
jest.requireActual() |
vi.importActual() |
jest.useFakeTimers() |
vi.useFakeTimers() |
Choose Jest if you’re in an established ecosystem with extensive Jest tooling, or if you need mature snapshot testing. Choose Vitest for new projects, especially those using Vite, or when you need faster test execution and better ESM support.
Both tools solve the same fundamental problem: letting you test code in isolation by controlling its dependencies. Master the mock function API, and you’ll write better, more reliable tests regardless of which runner you choose.