How to spy on a function in jest
vi.spyOn(module, 'name') replaces a property on an object. It reaches a caller that writes format.slugify(x), and it does not reach a caller that copied the function into a local name, including the module that exports it. Assert the spy was called, or the test proves nothing.
Why check this
Run this when a unit test needs one function out of a module replaced and the rest left real: a clock, an id generator, a pricing table lookup. Run it again the moment a spy stops having any effect after a refactor.
The failure it prevents is a test that asserts the wrong thing quietly. A spy that never intercepts leaves the real function running, so the test exercises production behaviour while its name says otherwise. Steps 1 to 3 show the same spy reaching one caller and missing two, on both runners.
Prerequisites
- Node 22.23.2, Vitest 5.0.0, Jest 30.5.0, on one Windows 11 machine, on 2026-09-12.
- Two scratch projects outside your repository, one per runner. The Jest copy uses CommonJS. See vi.spyOn and vi.mock.
- Three modules: the one that owns the function, a caller that goes through the import, and a caller that copies it first.
cat > src/format.js <<'EOF'
export function slugify(title) {
return title.toLowerCase().replace(/\s+/g, '-');
}
export function buildPath(title) {
return '/p/' + slugify(title);
}
EOF
cat > src/report.js <<'EOF'
import { slugify } from './format.js';
export function link(title) {
return '/p/' + slugify(title);
}
EOF
cat > src/sidebar.js <<'EOF'
import * as format from './format.js';
const { slugify } = format;
export function link(title) {
return '/p/' + slugify(title);
}
EOF
Steps
- Step 1.
Spy on the export and try it against three callers.
cat > test/spy.test.js <<'EOF' import { it, expect, vi, afterEach } from 'vitest'; import * as format from '../src/format.js'; import { link } from '../src/report.js'; afterEach(() => vi.restoreAllMocks()); it('spy replaces the namespace export', () => { vi.spyOn(format, 'slugify').mockReturnValue('stubbed'); expect(format.slugify('Hello World')).toBe('stubbed'); }); it('spy reaches a call from another module', () => { vi.spyOn(format, 'slugify').mockReturnValue('stubbed'); expect(link('Hello World')).toBe('/p/stubbed'); }); it('spy reaches a call the module makes to itself', () => { vi.spyOn(format, 'slugify').mockReturnValue('stubbed'); expect(format.buildPath('Hello World')).toBe('/p/stubbed'); }); EOFnpx vitest run test/spy.test.js --reporter=verbose✓ test/spy.test.js > spy replaces the namespace export 2ms ✓ test/spy.test.js > spy reaches a call from another module 0ms × test/spy.test.js > spy reaches a call the module makes to itself 6ms → expected '/p/hello-world' to be '/p/stubbed' // Object.is equality Test Files 1 failed (1) Tests 1 failed | 2 passed (3)buildPathcallsslugifyby its own local name, which is bound at parse time and points at the original function. Nothing a test does from outside changes that binding. The received value,/p/hello-world, is the real implementation answering. - Step 2.
Add the caller that destructured the import.
cat > test/destructured.test.js <<'EOF' import { it, expect, vi, afterEach } from 'vitest'; import * as format from '../src/format.js'; import { link as reportLink } from '../src/report.js'; import { link as sidebarLink } from '../src/sidebar.js'; afterEach(() => vi.restoreAllMocks()); it('reaches the module that calls through the import', () => { vi.spyOn(format, 'slugify').mockReturnValue('stubbed'); expect(reportLink('Hello World')).toBe('/p/stubbed'); expect(format.slugify).toHaveBeenCalledTimes(1); }); it('reaches the module that destructured the import', () => { vi.spyOn(format, 'slugify').mockReturnValue('stubbed'); expect(sidebarLink('Hello World')).toBe('/p/stubbed'); expect(format.slugify).toHaveBeenCalledTimes(1); }); EOFnpx vitest run test/destructured.test.js --reporter=verbose✓ test/destructured.test.js > reaches the module that calls through the import 3ms × test/destructured.test.js > reaches the module that destructured the import 6ms → expected '/p/hello-world' to be '/p/stubbed' // Object.is equalityreport.jsandsidebar.jslook the same. One writesslugify(title)against a live import binding, resolved on every call. The other ranconst { slugify } = formatonce at module load and holds that value. ThetoHaveBeenCalledTimes(1)line turns a wrong number into a diagnosis. - Step 3.
Run the same three cases under Jest, in CommonJS.
cat > test/spy.test.js <<'EOF' const format = require('../src2/format.js'); const report = require('../src2/report.js'); const sidebar = require('../src2/sidebar.js'); afterEach(() => jest.restoreAllMocks()); test('module property call', () => { jest.spyOn(format, 'slugify').mockReturnValue('stubbed'); expect(report.link('Hello World')).toBe('/p/stubbed'); }); test('destructured import', () => { jest.spyOn(format, 'slugify').mockReturnValue('stubbed'); expect(sidebar.link('Hello World')).toBe('/p/stubbed'); }); test('self-call inside the module', () => { jest.spyOn(format, 'slugify').mockReturnValue('stubbed'); expect(format.buildPath('Hello World')).toBe('/p/stubbed'); }); EOFThe three modules under
src2/are the ESM ones rewritten withmodule.exports,const format = require('./format.js')in the first caller andconst { slugify } = require('./format.js')in the second.FORCE_COLOR=0 npx jest test/spy.test.jsFAIL test/spy.test.js ● destructured import expect(received).toBe(expected) // Object.is equality Expected: "/p/stubbed" Received: "/p/hello-world" ● self-call inside the module expect(received).toBe(expected) // Object.is equality Expected: "/p/stubbed" Received: "/p/hello-world" Test Suites: 1 failed, 1 total Tests: 2 failed, 1 passed, 3 totalIdentical verdict from a different mechanism. In CommonJS
jest.spyOn(format, 'slugify')writes to themodule.exportsobject, soformat.slugify(x)elsewhere picks it up andconst { slugify } = require('./format.js')does not. The rule is about the call site, not the module system. - Step 4.
Check why the namespace spy works at all.
cat > plainnode.mjs <<'EOF' import * as format from './src/format.js'; try { Object.defineProperty(format, 'slugify', { value: () => 'stubbed' }); console.log('defineProperty succeeded:', format.slugify('Hello World')); } catch (e) { console.log('defineProperty threw:', e.constructor.name + ': ' + e.message); } EOFnode plainnode.mjsdefineProperty threw: TypeError: Cannot redefine property: slugifyA real ES module namespace is sealed. Step 1 works because Vitest transforms modules before running them and leaves the exports patchable. The same line under plain Node, or a runner on native ESM, throws instead of spying.
- Step 5.
Replace one export and keep the rest with
importOriginal.cat > test/partial.test.js <<'EOF' import { it, expect, vi } from 'vitest'; import * as format from '../src/format.js'; import { link } from '../src/report.js'; vi.mock('../src/format.js', async (importOriginal) => { const original = await importOriginal(); return { ...original, slugify: vi.fn(() => 'mocked') }; }); it('replaces one export and keeps the rest', () => { expect(link('Hello World')).toBe('/p/mocked'); expect(format.slugify).toHaveBeenCalledWith('Hello World'); }); it('leaves the self-call inside the module alone', () => { expect(format.buildPath('Hello World')).toBe('/p/mocked'); }); EOFnpx vitest run test/partial.test.js --reporter=verbose✓ test/partial.test.js > replaces one export and keeps the rest 2ms × test/partial.test.js > leaves the self-call inside the module alone 5ms → expected '/p/hello-world' to be '/p/mocked' // Object.is equalityvi.mockwithimportOriginalis the answer to replacing one function and keeping the others, and it has the same blind spot. The factory builds a new module object for importers.buildPathcame from the original file and still calls the originalslugifyin its own scope. - Step 6.
Make the function reachable, then spy on it.
cat > src/paths.js <<'EOF' export const paths = { slugify(title) { return title.toLowerCase().replace(/\s+/g, '-'); }, buildPath(title) { return '/p/' + paths.slugify(title); }, }; EOFcat > test/spyable.test.js <<'EOF' import { it, expect, vi, afterEach } from 'vitest'; import { paths } from '../src/paths.js'; afterEach(() => vi.restoreAllMocks()); it('reaches the self-call once it goes through the object', () => { vi.spyOn(paths, 'slugify').mockReturnValue('stubbed'); expect(paths.buildPath('Hello World')).toBe('/p/stubbed'); expect(paths.slugify).toHaveBeenCalledWith('Hello World'); }); it('gives the real implementation back after restore', () => { expect(paths.buildPath('Hello World')).toBe('/p/hello-world'); expect(vi.isMockFunction(paths.slugify)).toBe(false); }); EOFnpx vitest run test/spyable.test.js --reporter=verbose✓ test/spyable.test.js > reaches the self-call once it goes through the object 4ms ✓ test/spyable.test.js > gives the real implementation back after restore 0ms Test Files 1 passed (1) Tests 2 passed (2)The self-call now goes through a property lookup on
paths, which a spy can replace. The second test confirmsrestoreAllMocksgave the real function back. Passing the collaborator in as an argument works the same way and needs no object.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| The real value came back | The spy is on a binding the caller does not use | Assert toHaveBeenCalled and follow the call site |
| The spy reports zero calls | Same cause, stated by the mock instead of the value | Check whether the caller destructured the import |
| One caller intercepted, another not | One uses the live binding, one copied it | Change the call site or inject the function |
| Cannot redefine property | A sealed ES module namespace, no Vite transform | Use vi.mock, or restructure the module |
| vi.mock works and the self-call does not | The original module still runs its own function | Move the function behind an object or a parameter |
| The spy leaks into the next test | Nothing restored it | vi.restoreAllMocks() or restoreMocks: true |
Common mistakes
What to check next
- How to verify a mock was called: the assertion that turns an inert spy into a red test.
- How to reset mocks between tests: a spy left on a shared object outlives its test.
- How to mock fetch in vitest: the same import-time capture, on a global.
- How to detect flaky tests: a spy that leaks makes failures depend on file order.
FAQ
How do I spy on an imported function?
Import the module as a namespace, import * as format from './format.js', then vi.spyOn(format, 'slugify'). It works for callers that write format.slugify(x) or that call the live import binding, and not for callers that copied the function into a local variable.
How do I mock only one function from a module?
vi.mock('./format.js', async (importOriginal) => ({ ...(await importOriginal()), slugify: vi.fn() })). Everything not named in the factory stays real. Jest's equivalent is jest.requireActual inside a jest.mock factory.
Why is my spy not called?
The call site does not go through the property you patched. The three shapes that escape it are a self-call inside the same module, a destructured import, and a reference copied at import time. Step 1 and step 2 have one of each.
How do I mock an ES6 module import?
vi.mock with a path, hoisted above the imports by the runner. For a default export, return { default: vi.fn() }. Under Jest with native ESM you need jest.unstable_mockModule and a dynamic import of the module under test.
Should I spy or restructure the code?
Spy when the function is already reached through an object. When the test needs a spy on something a module calls on itself, the code is telling you the collaborator is not injectable, and step 6 is the smaller change of the two.
Verified
Verified by Maks Vernynode 22.23.2vitest 5.0.0jest 30.5.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate11 minpublished updated Maks Verny