jest.spyOn isn’t the only way to intercept function calls in Jest, and often, it’s not even the best way.

Let’s say you have a module utils.js with a function formatDate:

// utils.js
export function formatDate(date) {
  return date.toISOString();
}

And another module reporter.js that uses it:

// reporter.js
import { formatDate } from './utils';

export function reportEvent(eventName, eventData) {
  const timestamp = new Date();
  const formattedTimestamp = formatDate(timestamp);
  console.log(`[${formattedTimestamp}] ${eventName}: ${JSON.stringify(eventData)}`);
}

You want to test reportEvent without actually letting formatDate run and without replacing formatDate entirely. You want to see if it was called and with what.

Here’s how you’d do it without jest.spyOn:

// reporter.test.js
import { reportEvent } from './reporter';
import * as utils from './utils'; // Import the whole module

describe('reportEvent', () => {
  let formatDateSpy;

  beforeEach(() => {
    // Mock the entire module *before* importing the module that uses it.
    // This is crucial.
    jest.mock('./utils');
    // Now, re-import the module that depends on the mocked module.
    // If you already imported reporter.js before jest.mock, you'd need to
    // clear the module cache: delete require.cache[require.resolve('./reporter')];
    // and then re-import.
    const reporter = require('./reporter'); // Use require for re-importing after mock
    reportEvent = reporter.reportEvent; // Re-assign the function

    // Now, we can access the mocked formatDate directly from the mocked utils module.
    formatDateSpy = jest.spyOn(utils, 'formatDate');
  });

  afterEach(() => {
    jest.resetAllMocks(); // Clean up spies
  });

  test('should call formatDate with a Date object', () => {
    const eventName = 'userLogin';
    const eventData = { userId: 123 };
    reportEvent(eventName, eventData);

    expect(formatDateSpy).toHaveBeenCalledTimes(1);
    // Check that it was called with an instance of Date
    expect(formatDateSpy).toHaveBeenCalledWith(expect.any(Date));
  });

  test('should log the formatted event', () => {
    const eventName = 'itemAdded';
    const eventData = { itemId: 'abc' };
    const mockTimestamp = new Date('2023-10-27T10:00:00Z');
    const mockFormattedTimestamp = '2023-10-27T10:00:00.000Z';

    // Jest mocks are automatically restored by resetMocks,
    // but we can also manually mock the implementation for this specific test.
    utils.formatDate.mockImplementation(() => mockFormattedTimestamp);

    // We also need to mock console.log to check its output
    const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});

    reportEvent(eventName, eventData);

    expect(consoleSpy).toHaveBeenCalledWith(`[${mockFormattedTimestamp}] ${eventName}: ${JSON.stringify(eventData)}`);

    consoleSpy.mockRestore(); // Clean up console spy
  });
});

The core idea here is to mock the entire module that contains the function you want to spy on, rather than trying to spy on a specific function after it’s been imported. When you jest.mock('./utils'), Jest replaces the actual utils.js module with a mock version. This mock version has all its exported functions as Jest mocks.

Then, when reporter.js is imported (or re-imported after the mock is set), it receives the mocked utils module. The formatDate it imports is now a mock function. You can then use jest.spyOn(utils, 'formatDate') to attach a Jest spy to this already-mocked function. This spy records calls made to utils.formatDate from within the mocked module.

Why is this better?

  1. Handles Module Caching: Jest’s module caching can be tricky. When you jest.mock('module-path'), it affects all subsequent imports of module-path. If you try to spy on a function after the module that uses it has already imported the real function, you’re often spying on the wrong thing or not spying at all. Mocking the module upfront ensures the dependent module imports the mock from the start.
  2. Clearer Intent: It explicitly states that you are treating utils as a dependency that needs mocking for this test suite. You’re not just "peeking" at formatDate; you’re controlling its behavior and observing its calls as a distinct unit.
  3. Easier Mock Implementation: Once the module is mocked, you can easily replace the implementation of formatDate using utils.formatDate.mockImplementation(...) or utils.formatDate.mockReturnValue(...) without needing jest.spyOn for that specific purpose.

Common Pitfalls and How to Avoid Them:

  • Mocking Too Late: If you import reporter.js before calling jest.mock('./utils'), reporter.js will have already imported the real formatDate. To fix this, you need to ensure your jest.mock call happens before any import or require of the modules that depend on it. If you can’t control the import order easily (e.g., in a large existing codebase), you can use jest.resetModules() or manually clear the module cache:

    beforeEach(() => {
      jest.resetModules(); // Resets the module registry - good for isolation
      jest.mock('./utils');
      const reporter = require('./reporter');
      reportEvent = reporter.reportEvent;
      formatDateSpy = jest.spyOn(require('./utils'), 'formatDate'); // Spy on the mocked version
    });
    

    Or, more granularly:

    beforeEach(() => {
      // Delete the module from Jest's cache
      delete require.cache[require.resolve('./reporter')];
      delete require.cache[require.resolve('./utils')];
    
      jest.mock('./utils'); // Now mock it
    
      const reporter = require('./reporter'); // Re-require reporter
      reportEvent = reporter.reportEvent;
    
      // Re-require utils to get the mocked version
      const mockedUtils = require('./utils');
      formatDateSpy = jest.spyOn(mockedUtils, 'formatDate');
    });
    

    The jest.resetModules() approach is generally cleaner.

  • Spying on the Wrong Object: If you have import { formatDate } from './utils'; directly in your test file, and then try jest.spyOn(utils, 'formatDate') where utils is require('./utils'), you might be spying on the real formatDate if the mock wasn’t set up correctly or early enough. Always ensure you’re spying on the function as it exists within the module that is using it. When jest.mock('./utils') is active, require('./utils') returns the mock object.

  • Forgetting jest.resetAllMocks() or jest.restoreAllMocks(): Spies and mocks persist between tests by default. beforeEach or afterEach hooks with jest.resetAllMocks() or jest.restoreAllMocks() are crucial for test isolation. resetAllMocks clears mock call data and resets implementations, while restoreAllMocks specifically restores original implementations for spies. For this pattern, resetAllMocks is usually sufficient.

The next hurdle you’ll likely face is when you need to mock a function that is not exported from a module, but is a method on an object that is itself imported.

Want structured learning?

Take the full Jest course →