jsguides

Mocking Modules and Dependencies

When testing JavaScript code, you often need to isolate the unit under test from its dependencies. Database calls, API requests, file system operations, and third-party libraries all make tests slow, flaky, or impossible to run in isolation. Mocking modules and dependencies solves this by replacing them with controlled test doubles.

You will learn how to use Jest and Vitest to mock functions, entire modules, and timers — everything you need to write fast, reliable, isolated tests.

Why mocking matters

Consider this function that fetches user data from an API:

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

async function getUserById(id) {
  const response = await axios.get(`/api/users/${id}`);
  return response.data;
}

module.exports = { getUserById };

To test this function without mocking, you’d need a running API server. This makes your tests:

  • Slow: network requests take time
  • Flaky: network issues cause random failures
  • Hard to maintain: tests depend on external state

Mocking solves all three problems by replacing real dependencies with controlled test doubles.

Mocking Functions

The most basic form of mocking is replacing a single function with a mock implementation.

Creating mock functions

Both Jest and Vitest provide a way to create mock functions:

// Jest
const mockFn = jest.fn();

// Vitest
const mockFn = vi.fn();

A bare jest.fn() returns undefined on every call. That is fine when all you need to verify is whether the function was called and with what arguments. When you need the mock to produce a return value that downstream code can use, pass a default implementation during creation:

const mockFn = jest.fn(() => 'default value');
mockFn(); // 'default value'

Setting a single default value works for one-off scenarios. Tests that need a sequence of different return values, or a value that only applies once, benefit from mockReturnValue and mockReturnValueOnce. These methods keep the mock setup readable without nesting logic inside the factory function:

const mockFn = jest.fn()
  .mockReturnValue('first')
  .mockReturnValue('second')
  .mockReturnValue('default');

mockFn(); // 'first'
mockFn(); // 'second'
mockFn(); // 'default'
mockFn(); // 'default'

Mocking implementation

When a mock needs to compute its return value from inputs, like applying a discount rate to a price, pass a real function instead of a static value. The mock still records every call, so you can assert on arguments and invocation count while preserving the calculation logic you are testing:

const calculateDiscount = jest.fn((price, rate) => {
  return price * (rate / 100);
});

calculateDiscount(100, 20); // 20
calculateDiscount; // Mock function

Mocking Modules

When your code imports a module, you can mock the entire module. This is powerful for replacing third-party libraries like axios, lodash, or any npm package.

Using jest.mock()

The module under test imports axios and calls axios.get() to return user data. In tests, you replace axios so no real HTTP request fires:

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

module.exports = {
  getUser: async (id) => {
    const response = await axios.get(`/users/${id}`);
    return response.data;
  }
};

Calling jest.mock('axios') replaces every method on the imported module with a no-op mock. You then configure the specific method your code calls, here axios.get, to return a resolved promise with test data. The assertion at the end confirms the right URL was requested, which catches regressions in the route construction:

// api.test.js
const api = require('./api');
const axios = require('axios');

// Mock the entire axios module
jest.mock('axios');
const mockedAxios = axios;

test('fetches user successfully', async () => {
  // Configure the mock
  mockedAxios.get.mockResolvedValue({
    data: { id: 1, name: 'Alice', email: 'alice@example.com' }
  });

  const user = await api.getUser(1);

  expect(user).toEqual({
    id: 1,
    name: 'Alice',
    email: 'alice@example.com'
  });
  expect(mockedAxios.get).toHaveBeenCalledWith('/users/1');
});

The jest.mock() call is hoisted to the top of the file, so it runs before any imports. This means you can mock modules that are imported elsewhere in your test file.

Mocking with doMock (ES Modules)

If you’re using ES modules with import, use jest.doMock() instead. Unlike jest.mock(), jest.doMock() is not hoisted, which gives you more control over when the mock is applied:

// ES module version
jest.doMock('./api', () => ({
  getUser: vi.fn().mockResolvedValue({ id: 1, name: 'Alice' })
}));

import { getUser } from './api';

A common pitfall with module mocking is replacing everything when you only need to override one piece. If your test depends on lodash.cloneDeep behaving correctly but only needs to stub lodash.debounce, mocking the entire library can mask bugs. Partial mocking keeps the real implementation for everything except the function you specify:

Partial module mocking

Sometimes you only want to mock part of a module while keeping the rest real:

jest.mock('lodash', () => ({
  ...jest.requireActual('lodash'),
  debounce: vi.fn((fn) => fn) // Replace only debounce
}));

Using Spies

Spies let you wrap existing functions while still allowing the original implementation to run. Unlike a full mock that replaces behavior, a spy records calls and arguments without interfering with what the function returns. This makes spies the right choice when you need to confirm an interaction happened, for example verifying a logging function fired, but the actual output of that function matters for the rest of the test.

jest.spyOn() / vi.spyOn()

const math = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b
};

test('spies on object methods', () => {
  const spy = jest.spyOn(math, 'add');

  math.add(2, 3);

  expect(spy).toHaveBeenCalled();
  expect(spy).toHaveBeenCalledWith(2, 3);
  expect(math.add(10, 5)).toBe(15); // Original still works

  spy.mockRestore(); // Restore original function
});

Spying on module methods

Spies also work on methods of mocked modules. When you mock axios with jest.mock() and then call jest.spyOn(axios, 'get'), you get both the mock’s configurable return value and the spy’s call-tracking. This combination lets you control what the dependency returns while also asserting on the exact URL and arguments passed to it:

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

jest.mock('axios');

test('spies on axios get', () => {
  const spy = jest.spyOn(axios, 'get');
  axios.get.mockResolvedValue({ data: { name: 'Bob' } });

  api.getUser(1);

  expect(spy).toHaveBeenCalledWith('/users/1');
});

Mocking Timers

Code that uses setTimeout, setInterval, or setImmediate can be difficult to test because real timers introduce non-deterministic delays and slow down the suite. Jest and Vitest provide fake timers that let you fast-forward through time in a deterministic way, so a 5-second timeout completes instantly in test without compromising the callback logic.

useFakeTimers() / useFakeTimers()

The module under test wraps setTimeout to delay a notification callback by 5 seconds:

// delayed.js
function notifyUser(callback) {
  setTimeout(() => {
    callback('User notified');
  }, 5000);
}

To test this, activate fake timers with jest.useFakeTimers(), call the function, and then advance time by exactly 5 seconds with jest.advanceTimersByTime(). Before advancing, the callback should not have fired, which proves the timer is actually controlling the delay. After advancing, the callback fires immediately with the expected argument:

// delayed.test.js
function notifyUser(callback) {
  setTimeout(() => {
    callback('User notified');
  }, 5000);
}

test('notifies user after delay', () => {
  jest.useFakeTimers();
  const callback = jest.fn();

  notifyUser(callback);

  // Timer hasn't fired yet
  expect(callback).not.toHaveBeenCalled();

  // Fast-forward time
  jest.advanceTimersByTime(5000);

  // Now callback fired
  expect(callback).toHaveBeenCalledWith('User notified');

  jest.useRealTimers();
});

The test proves two things: the callback does not fire before time advances (confirming the timer is in control), and it fires with the correct argument after the 5-second mark. Jest provides several methods for controlling timer execution depending on how precise you need the time skipping to be.

Timer Methods

MethodDescription
jest.runAllTimers()Run all pending timers
jest.advanceTimersByTime(ms)Move time forward by ms
jest.runOnlyPendingTimers()Run only pending timers

Vitest provides a more modern, promise-based timer API that integrates with async/await, reducing the ceremony around fake timer management.

Modern timer API (Vitest)

Vitest offers a cleaner timer API:

import { vi, fn } from 'vitest';

test('timers in Vitest', async () => {
  vi.useFakeTimers();

  const callback = vi.fn();
  notifyUser(callback);

  await vi.runTimersToTime(5000);

  expect(callback).toHaveBeenCalled();

  vi.useRealTimers();
});

Vitest’s runTimersToTime returns a promise, so the test can await the full timer resolution without juggling jest.advanceTimersByTime and a separate assertion. This cleaner syntax makes timer-related tests easier to follow, especially when multiple timers fire in sequence.

Mocking Node.js modules

Node.js has built-in modules like fs, path, and crypto that your code may import directly. Mocking these built-ins follows the same pattern as any other module: call jest.mock('fs') and configure the methods your code calls.

Mocking fs

jest.mock('fs');

const fs = require('fs');

test('reads file content', () => {
  fs.readFileSync.mockReturnValue('file contents');

  const content = fs.readFileSync('test.txt', 'utf8');

  expect(content).toBe('file contents');
  expect(fs.readFileSync).toHaveBeenCalledWith('test.txt', 'utf8');
});

Mocking fs is straightforward because its methods are synchronous or callback-based. Environment variables, on the other hand, require care: process.env is a global that persists across tests. The simplest approach saves the original, sets the test value, and restores afterward:

Mocking environment variables

test('uses environment variable', () => {
  const originalEnv = process.env;
  process.env.API_KEY = 'test-key';

  // Your code here that uses process.env.API_KEY

  process.env = originalEnv; // Restore
});

The direct assignment approach risks leaking state if a test throws before reaching the restore line. Jest’s spy API avoids this with automatic cleanup. jest.spyOn on a property getter gives you the same control but restores the original when you call mockRestore(), even if your test fails partway through:

Or use jest.spyOn for cleaner restoration:

test('spies on process.env', () => {
  const spy = jest.spyOn(process.env, 'API_KEY', 'get');
  spy.mockReturnValue('test-key');

  // Your test code

  spy.mockRestore();
});

Best Practices

  1. Mock at the appropriate level: mock the closest dependency to your code, not everything.

  2. Keep mocks simple: complex mocks are hard to maintain and can hide real problems.

  3. Verify mock interactions: use matchers like toHaveBeenCalledWith to ensure your code calls dependencies correctly.

  4. Clean up after tests: call mockRestore() or use jest.resetModules() when needed.

  5. Don’t mock everything: sometimes it’s better to use the real implementation if it’s fast and deterministic.

  6. Use descriptive mock data: mock return values should resemble real data structure.

Keeping mocks honest

Mocks are most useful when they reflect the real behavior closely enough to protect the contract of the code under test. A mock that returns a string when the real dependency returns an object can make a test pass for the wrong reason. The aim is not to mirror every detail, but to keep the shape, timing, and success or failure behavior close enough that the test still says something meaningful about the production code.

It is also wise to mock the smallest useful surface. If your function only needs one method from a module, replacing the whole module can hide too much. A focused mock keeps the test easier to understand and makes it more obvious which dependency matters. That habit also reduces maintenance because fewer unrelated methods need to be kept in sync as the codebase changes.

Spies are a good middle ground when you want to observe behavior without changing it. They can confirm that a call happened with the right input while still letting the original function do its job. That pattern is especially useful for logging, formatting, and helper methods where the output matters but the call itself is also worth checking. Used carefully, spies give you insight without forcing a fake implementation into places where the real code is already simple.

Timer mocks deserve extra care because they change how time moves in the test. A fake timer can make a slow test fast, but it can also make the code path feel different from production if the test is not written carefully. Keep the setup explicit, advance time in clear steps, and restore the real timers when the test is done. That keeps the test honest and avoids leaks into later cases.

Good mocks do one more thing well: they make the test easier to read. If the setup is so complex that the reader needs to understand a fake subsystem before they can see the assertion, the mock may be doing too much. The best mocks are the ones you notice only because the test is easier to follow.

Summary

You’ve learned the essential techniques for mocking in Jest and Vitest:

  • Creating mock functions with jest.fn() / vi.fn()
  • Mocking entire modules with jest.mock()
  • Using spies to wrap existing functions with jest.spyOn() / vi.spyOn()
  • Controlling time with fake timers
  • Mocking Node.js built-in modules like fs
  • Best practices for effective mocking

These techniques will help you write tests that are fast, reliable, and isolated from external dependencies. In the next tutorial, we’ll explore Testing DOM Code with jsdom to learn how to test browser-specific code.

See Also