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 or vi.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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.