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

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

  1. 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');
    });
    EOF
    
    npx 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)

    buildPath calls slugify by 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.

  2. 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);
    });
    EOF
    
    npx 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 equality

    report.js and sidebar.js look the same. One writes slugify(title) against a live import binding, resolved on every call. The other ran const { slugify } = format once at module load and holds that value. The toHaveBeenCalledTimes(1) line turns a wrong number into a diagnosis.

  3. 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');
    });
    EOF
    

    The three modules under src2/ are the ESM ones rewritten with module.exports, const format = require('./format.js') in the first caller and const { slugify } = require('./format.js') in the second.

    FORCE_COLOR=0 npx jest test/spy.test.js
    
    FAIL 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 total

    Identical verdict from a different mechanism. In CommonJS jest.spyOn(format, 'slugify') writes to the module.exports object, so format.slugify(x) elsewhere picks it up and const { slugify } = require('./format.js') does not. The rule is about the call site, not the module system.

  4. 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);
    }
    EOF
    
    node plainnode.mjs
    
    defineProperty threw: TypeError: Cannot redefine property: slugify

    A 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.

  5. 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');
    });
    EOF
    
    npx 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 equality

    vi.mock with importOriginal is 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. buildPath came from the original file and still calls the original slugify in its own scope.

  6. 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);
      },
    };
    EOF
    
    cat > 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);
    });
    EOF
    
    npx 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 confirms restoreAllMocks gave 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

Sign: The spy is installed, the test passes, and the real function ran.Cause: A passing test that was meant to exercise a stub is worse than a failing one. Add expect(format.slugify).toHaveBeenCalledTimes(1) next to every spy. In step 2 that assertion is the only thing separating a working spy from an inert one.
Sign: A function a module calls on itself cannot be stubbed.Cause: Neither vi.spyOn nor vi.mock with importOriginal reaches it, which step 5 measures. The internal call resolves the local binding, and no external patch touches it. Make the call go through an object property, or pass the collaborator in.
Sign: The same spy line throws TypeError in another project.Cause: Spying on a module namespace works only because the runner rewrote the module. Under plain Node the namespace is sealed, and Object.defineProperty on it throws 'Cannot redefine property', as step 4 shows.
Sign: Two call sites look identical and only one is intercepted.Cause: const { slugify } = format at module scope reads the value once. slugify(x) written against an import is resolved on each call. The source differs by one line that has nothing to do with the test.

What to check next

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.

intermediate11 minpublished updated Maks Verny