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?
- Handles Module Caching: Jest’s module caching can be tricky. When you
jest.mock('module-path'), it affects all subsequent imports ofmodule-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. - Clearer Intent: It explicitly states that you are treating
utilsas a dependency that needs mocking for this test suite. You’re not just "peeking" atformatDate; you’re controlling its behavior and observing its calls as a distinct unit. - Easier Mock Implementation: Once the module is mocked, you can easily replace the implementation of
formatDateusingutils.formatDate.mockImplementation(...)orutils.formatDate.mockReturnValue(...)without needingjest.spyOnfor that specific purpose.
Common Pitfalls and How to Avoid Them:
-
Mocking Too Late: If you import
reporter.jsbefore callingjest.mock('./utils'),reporter.jswill have already imported the realformatDate. To fix this, you need to ensure yourjest.mockcall happens before anyimportorrequireof the modules that depend on it. If you can’t control the import order easily (e.g., in a large existing codebase), you can usejest.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 tryjest.spyOn(utils, 'formatDate')whereutilsisrequire('./utils'), you might be spying on the realformatDateif 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. Whenjest.mock('./utils')is active,require('./utils')returns the mock object. -
Forgetting
jest.resetAllMocks()orjest.restoreAllMocks(): Spies and mocks persist between tests by default.beforeEachorafterEachhooks withjest.resetAllMocks()orjest.restoreAllMocks()are crucial for test isolation.resetAllMocksclears mock call data and resets implementations, whilerestoreAllMocksspecifically restores original implementations for spies. For this pattern,resetAllMocksis 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.