Advanced Jest Mocking: Timers, ESM & Complex Dependencies

Advanced Jest Mocking: Timers, ESM & Complex Dependencies

You've covered jest.fn() and jest.mock(). Now you're staring at a test that needs to control setTimeout, mock an ES module, or stub a class constructor. These are the patterns where Jest's documentation runs thin and Stack Overflow answers contradict each other. Here's what actually works.

jest.useFakeTimers() — Controlling Time

Real timers are a test antipattern. A test that waits for a real 3-second debounce takes 3 seconds — and that's the optimistic case where it doesn't flake due to timing variance. Jest's fake timers replace setTimeout, setInterval, clearTimeout, clearInterval, Date, and (optionally) queueMicrotask with synchronous, controllable implementations.

beforeEach(() => {
  jest.useFakeTimers();
});

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

test('debounce delays execution', () => {
  const fn = jest.fn();
  const debounced = debounce(fn, 500);

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

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

  jest.advanceTimersByTime(499);
  expect(fn).not.toHaveBeenCalled(); // still hasn't fired

  jest.advanceTimersByTime(1);
  expect(fn).toHaveBeenCalledTimes(1); // fired exactly once
});

Timer Control Methods

jest.advanceTimersByTime(ms)    // move fake clock forward by ms
jest.runAllTimers()             // run all pending timers to completion
jest.runOnlyPendingTimers()     // run only currently queued timers (not those created during their execution)
jest.runAllTicks()              // flush microtask queue (process.nextTick, Promise callbacks)
jest.clearAllTimers()           // clear all pending timers without running them
jest.getTimerCount()            // number of pending timers

runAllTimers() is convenient but dangerous with recursive timers — setInterval that creates more intervals will loop forever. Use runOnlyPendingTimers() for those cases.

Controlling Date

jest.useFakeTimers({
  now: new Date('2024-01-15T10:00:00.000Z'),
});

test('timestamp is correct', () => {
  const event = createEvent('click');
  expect(event.timestamp).toBe('2024-01-15T10:00:00.000Z');

  jest.advanceTimersByTime(5000);
  const event2 = createEvent('submit');
  expect(event2.timestamp).toBe('2024-01-15T10:00:05.000Z');
});

You can also set Date.now directly:

jest.setSystemTime(new Date('2024-06-01'));
expect(new Date().getFullYear()).toBe(2024);

Async Code with Fake Timers

Fake timers and async/await interact in non-obvious ways. If your code uses setTimeout wrapped in a promise, you need to advance time AND flush promises:

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function doSomethingAfterDelay() {
  await delay(1000);
  return 'done';
}

test('resolves after delay', async () => {
  const promise = doSomethingAfterDelay();

  jest.advanceTimersByTime(1000);
  await Promise.resolve(); // flush microtask queue

  const result = await promise;
  expect(result).toBe('done');
});

Or use jest.runAllTimersAsync() (Jest 29.5+), which automatically flushes promises between timer runs:

test('resolves after delay', async () => {
  const promise = doSomethingAfterDelay();
  await jest.runAllTimersAsync();
  await expect(promise).resolves.toBe('done');
});

Mocking ES Modules

ES modules (using import/export syntax) are the main source of confusion in Jest mocking. The behavior depends on your transform setup.

With Babel or TypeScript (most common)

If you're using babel-jest or ts-jest, ESM is transpiled to CommonJS before Jest runs it. In this case, jest.mock() works exactly as it does with CommonJS:

import { formatDate, parseDate } from './dateUtils';

jest.mock('./dateUtils', () => ({
  formatDate: jest.fn().mockReturnValue('2024-01-15'),
  parseDate: jest.fn().mockReturnValue(new Date('2024-01-15')),
}));

test('displays formatted date', () => {
  const result = renderDate(new Date());
  expect(formatDate).toHaveBeenCalled();
  expect(result).toBe('2024-01-15');
});

The __esModule: true flag is needed when you're mocking a module that has a default export and your code uses the default import:

jest.mock('./logger', () => ({
  __esModule: true,
  default: jest.fn(), // the default export
  log: jest.fn(),     // named export
}));

With Native ESM (experimental)

If you're running Jest with native ESM ("type": "module" in package.json and NODE_OPTIONS='--experimental-vm-modules'), the rules change. jest.mock() still works but requires the factory to be passed explicitly, and dynamic import semantics apply.

// jest.config.js
export default {
  extensionsToTreatAsEsm: ['.ts'],
  transform: {},
};
// test.mjs
import { jest } from '@jest/globals';

jest.mock('./config.js', () => ({
  getConfig: jest.fn().mockReturnValue({ debug: true }),
}));

const { getConfig } = await import('./config.js');

Native ESM support in Jest is still marked experimental. For production test suites, Babel/TypeScript transforms remain more reliable.

Partial ESM Mocking

jest.mock('./analytics', () => ({
  ...jest.requireActual('./analytics'),  // keep real implementations
  trackEvent: jest.fn(),                  // replace only this one
}));

jest.requireActual() bypasses Jest's module registry and loads the real module, letting you spread its exports and override specific ones.

Mocking Constructor Functions and Classes

When your code news a class, you need to mock the constructor and the methods the instance will have.

// EmailClient.js
export class EmailClient {
  constructor(apiKey) {
    this.apiKey = apiKey;
  }

  async send(options) {
    // real HTTP call
  }
}
jest.mock('./EmailClient', () => ({
  EmailClient: jest.fn().mockImplementation(() => ({
    send: jest.fn().mockResolvedValue({ messageId: 'mock-id' }),
  })),
}));

import { EmailClient } from './EmailClient';

test('sends email via client', async () => {
  const client = new EmailClient('api-key');

  await sendWelcomeEmail('user@example.com', client);

  expect(client.send).toHaveBeenCalledWith(
    expect.objectContaining({ to: 'user@example.com' })
  );
});

If the class is instantiated inside the module under test (not passed in), inspect the mock's instances:

test('creates client with correct api key', async () => {
  await initializeEmailService('my-api-key');

  expect(EmailClient).toHaveBeenCalledWith('my-api-key');

  const mockInstance = EmailClient.mock.instances[0];
  expect(mockInstance.send).toHaveBeenCalled();
});

Mocking Static Methods

jest.mock('./UserService', () => ({
  UserService: {
    findById: jest.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
    create: jest.fn(),
  },
}));

Or spy on the static method if you want to preserve the real implementation:

import { UserService } from './UserService';

jest.spyOn(UserService, 'findById').mockResolvedValue({ id: 1, name: 'Alice' });

Mocking axios and fetch

axios

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

test('GET request', async () => {
  axios.get.mockResolvedValue({ data: { users: [] } });

  const result = await getUserList();
  expect(result).toEqual([]);
  expect(axios.get).toHaveBeenCalledWith('/api/users');
});

test('handles error', async () => {
  axios.get.mockRejectedValue({
    response: { status: 500, data: { error: 'Server error' } },
  });

  await expect(getUserList()).rejects.toThrow();
});

For axios with interceptors or base instances:

jest.mock('axios', () => {
  const instance = {
    get: jest.fn(),
    post: jest.fn(),
    interceptors: {
      request: { use: jest.fn() },
      response: { use: jest.fn() },
    },
  };
  return {
    default: { create: jest.fn(() => instance), ...instance },
    create: jest.fn(() => instance),
  };
});

fetch

Jest doesn't include a fetch mock. Options:

jest-fetch-mock (npm package):

// jest.setup.js
require('jest-fetch-mock').enableMocks();

// in test
fetch.mockResolvedValueOnce({
  ok: true,
  json: async () => ({ users: [] }),
});

Manual mock:

global.fetch = jest.fn().mockResolvedValue({
  ok: true,
  status: 200,
  json: async () => ({ id: 1, name: 'Alice' }),
  text: async () => 'response text',
});

MSW (recommended for most cases — see the MSW comparison post for details).

Partial Module Mocking with jest.requireActual()

The most underused pattern in Jest is combining real and mocked exports from the same module:

jest.mock('lodash', () => ({
  ...jest.requireActual('lodash'),
  debounce: jest.fn(fn => fn), // replace debounce with passthrough for tests
}));

This is invaluable for utility libraries where you want most functions real but need to control specific ones (debounce, throttle, random number generators).

Another common case — mocking fs partially:

jest.mock('fs', () => ({
  ...jest.requireActual('fs'),
  readFileSync: jest.fn().mockReturnValue('mock file content'),
  writeFileSync: jest.fn(),
}));

Real fs.existsSync, fs.mkdirSync, etc. still work; only the file I/O methods are mocked.

Mocking Environment Variables

const originalEnv = process.env;

beforeEach(() => {
  jest.resetModules(); // clear module registry so re-imports pick up new env
  process.env = { ...originalEnv };
});

afterEach(() => {
  process.env = originalEnv;
});

test('uses production API when NODE_ENV is production', () => {
  process.env.NODE_ENV = 'production';
  process.env.API_URL = 'https://api.prod.example.com';

  const { API_URL } = require('./config'); // re-require after env change
  expect(API_URL).toBe('https://api.prod.example.com');
});

The jest.resetModules() call is critical — without it, the module is cached from the first import and won't re-evaluate with the new environment variables.

Circular Dependencies in Mocks

Circular dependencies are a common source of undefined errors in Jest mocks. Module A imports B, B imports A — when Jest's mock factory for A runs, B hasn't been initialized yet.

The safe pattern is lazy loading inside the factory:

jest.mock('./moduleA', () => {
  // Don't reference moduleB at factory definition time
  return {
    doSomething: jest.fn(() => {
      const { helperFromB } = require('./moduleB'); // lazy require
      return helperFromB();
    }),
  };
});

Or restructure to break the circular dependency — which is usually the right long-term answer. Circular deps in production code are a design smell; in test code they're a symptom of the same problem.

Resetting Module Registry

Sometimes you need a clean slate between tests — no cached modules, fresh requires:

beforeEach(() => {
  jest.resetModules();
});

test('config with feature flag A', () => {
  process.env.FEATURE_FLAG = 'a';
  const { config } = require('./config');
  expect(config.feature).toBe('a');
});

test('config with feature flag B', () => {
  process.env.FEATURE_FLAG = 'b';
  const { config } = require('./config');
  expect(config.feature).toBe('b');
});

Without resetModules, the second test gets the cached module from the first test's require.

Debugging Mock Issues

When a mock isn't working, run through this checklist:

  1. Is the mock being hoisted? jest.mock() calls are hoisted before imports. If you reference an outer variable in the factory, it may not be initialized yet. Variables starting with mock are hoisted along with jest.mock().
  2. Is the module path correct? Relative paths in jest.mock() must match exactly how they're imported in the module under test. A mismatch means two different modules in the registry.
  3. Is the module cached? If you've done require() before calling jest.mock(), you got the real module. Call jest.mock() before any imports (it's hoisted, so this is enforced), or use jest.resetModules().
  4. Is the function reference stale? If the module under test does const fn = require('./dep').fn at the top level, it holds a reference to the original function. Mocking the module later doesn't update that reference.
  5. Are you restoring between tests? jest.restoreAllMocks() in afterEach prevents spies from accumulating.

These patterns cover the scenarios that trip up developers who've mastered the basics. Fake timers eliminate timing flakiness, jest.requireActual() enables surgical mocking, and understanding the module registry explains most "why isn't my mock working" questions.

Read more