Programming Languages Bug Report

jest.mock silently ignored after switching a Node.js project to native ESM

After we set "type": "module" in package.json, jest.mock stopped replacing our mailer and tests started calling the real SMTP client. Static ESM imports are linked before any mock runs. Here is the mechanism and the fix with unstable_mockModule.

Daniel Tran Daniel Tran @fun_dev · September 18, 2025 · 9 min read · 11 Views
Banner: jest.mock silently ignored after switching a Node.js project to native ESM

Our notification service is a small Node.js worker that renders templates and hands them to a mailer module. We moved it to native ES modules so we could upgrade an ESM-only templating dependency. The code change was mostly mechanical: "type": "module" in package.json, .js extensions on relative imports, import instead of require. The app booted fine. The test suite did not.

The first run failed in every file that used mocks:

ReferenceError: jest is not defined

      3 | import { sendDigest } from '../src/digest.js';
      4 |
    > 5 | jest.mock('../src/mailer.js');
        | ^

One developer added import { jest } from '@jest/globals'; to the top of each test file, the ReferenceError disappeared, and the PR went green locally on his machine because the SMTP sandbox credentials in his shell were valid. In CI, with no credentials, the same tests failed differently:

expect(received).toHaveBeenCalledTimes(expected)

Matcher error: received value must be a mock or spy function

Received has type:  function
Received has value: [Function sendMail]

So sendMail was the real function. jest.mock ran, did not throw, and changed nothing. Worse, on the laptop with credentials the suite had been sending real digest emails to the sandbox inbox for two days.

Reproducing it

Three files are enough:

  1. src/mailer.js exports sendMail(), which opens an SMTP connection.
  2. src/digest.js imports sendMail and calls it once per user.
  3. test/digest.test.js statically imports sendDigest, calls jest.mock('../src/mailer.js') and asserts on sendMail.

Run Jest with ESM enabled and no transform:

NODE_OPTIONS="--experimental-vm-modules" npx jest test/digest.test.js

The assertion fails every time with the matcher error above.

What we ruled out

  • Wrong module path in jest.mock. We tried the absolute path, the path without the .js extension and a moduleNameMapper entry. None changed the result. The mock was registered, it simply never got a chance to be used.
  • resetMocks or restoreMocks in the config. Both were off. A spy created with jest.spyOn on an object export still worked, so the jest object itself was functional.
  • A cache problem. npx jest --clearCache and a fresh CI runner gave the same failure.

Root cause

In the CommonJS setup, babel-jest was transforming every test file and its hoisting plugin moved jest.mock(...) calls above the require calls generated from our import statements. That is why the order in the source file never mattered. When we switched to native ESM we also set transform: {} (Jest's documented requirement for running untransformed ESM), so nothing hoisted the call any more. In native ESM, every static import is resolved, linked and evaluated before the first line of the test body runs. By the time jest.mock executed, digest.js already held a live binding to the real sendMail. Jest's documentation states this directly: jest.mock calls are not hoisted above static ESM imports, and jest.unstable_mockModule is the ESM API.

What is happening under the hood

An ES module graph is processed in phases. Node (through the vm module APIs Jest uses when --experimental-vm-modules is set) first parses every file reachable from the test, resolves all specifiers, links exported bindings to importers, and only then evaluates modules in dependency order. A test file's top-level statements run during its own evaluation, which happens after all its static imports are evaluated.

Step CommonJS + babel-jest Native ESM, transform: {}
Test file rewritten before run Yes, jest.mock hoisted to the top No
When dependencies load At each require, after hoisted mocks During linking, before any test code
Effect of jest.mock in test body Registers a mock used by later require calls Too late for static imports, no error raised
Supported ESM mock API n/a jest.unstable_mockModule + dynamic import()

The jest global itself is also different. In ESM test files Jest does not inject it as a free variable, which is where the original ReferenceError came from. It must be imported from @jest/globals or read from import.meta.jest.

Environment

Component Version / value
OS Ubuntu 24.04 LTS (CI), macOS 15 (dev)
Runtime Node.js 22.19.0
Test runner Jest 30.1.3
Module system "type": "module" in package.json
Jest config transform: {}, testEnvironment: 'node'
Run command NODE_OPTIONS=--experimental-vm-modules jest

Minimal reproduction

// test/digest.test.js (broken)
import { jest } from '@jest/globals';
import { sendDigest } from '../src/digest.js';
import { sendMail } from '../src/mailer.js';

jest.mock('../src/mailer.js'); // runs after both imports are already evaluated

test('sends one email per subscriber', async () => {
  await sendDigest([{ email: 'a@example.com' }, { email: 'b@example.com' }]);
  expect(sendMail).toHaveBeenCalledTimes(2); // sendMail is the real function
});

Who is affected

Any Jest project running real ESM test files, either through "type": "module", .mjs files or extensionsToTreatAsEsm, with no transform that converts them to CommonJS. Projects still using babel-jest or ts-jest in CommonJS output mode are not affected, because hoisting still happens. TypeScript projects that emit ESM via ts-jest with useESM: true hit the same wall.

The fix

We rewrote the mocked tests to register the module mock first and only then load the code under test with a dynamic import(). Dynamic imports are evaluated at the point they run, so the registry already contains the mock when digest.js asks for mailer.js.

// test/digest.test.js (fixed)
import { jest } from '@jest/globals';

jest.unstable_mockModule('../src/mailer.js', () => ({
  sendMail: jest.fn(async () => ({ accepted: ['ok'] })),
}));

const { sendMail } = await import('../src/mailer.js');
const { sendDigest } = await import('../src/digest.js');

beforeEach(() => {
  sendMail.mockClear();
});

test('sends one email per subscriber', async () => {
  await sendDigest([{ email: 'a@example.com' }, { email: 'b@example.com' }]);
  expect(sendMail).toHaveBeenCalledTimes(2);
  expect(sendMail).toHaveBeenCalledWith(expect.objectContaining({ to: 'a@example.com' }));
});

Three details mattered:

  1. The factory is required. Unlike jest.mock, there is no automock form; every export the code under test touches must be returned by the factory, or the import fails with a missing export error at link time.
  2. Everything that depends on the mock must be imported dynamically. A single leftover static import { sendDigest } from '../src/digest.js' at the top pulls in the real mailer again.
  3. Specifiers must resolve to the same file. The mock is keyed by the resolved URL, so '../src/mailer.js' in the test and './mailer.js' inside digest.js both work, but a path alias that resolves differently in Jest than in Node will not match.

The diff on each test file was small:

-import { sendDigest } from '../src/digest.js';
-import { sendMail } from '../src/mailer.js';
-jest.mock('../src/mailer.js');
+jest.unstable_mockModule('../src/mailer.js', () => ({ sendMail: jest.fn() }));
+const { sendMail } = await import('../src/mailer.js');
+const { sendDigest } = await import('../src/digest.js');

Alternatives and trade-offs

  1. Move to Vitest. vi.mock is hoisted by Vitest's transform pipeline and works with ESM source out of the box. For a small service the migration is mostly renaming jest.fn to vi.fn. We did this for a second repository with Vitest 3.2.4 and kept static imports:

    import { vi, test, expect } from 'vitest';
    import { sendDigest } from '../src/digest.js';
    import { sendMail } from '../src/mailer.js';
    
    vi.mock('../src/mailer.js', () => ({ sendMail: vi.fn() }));
    
  2. Keep a transform to CommonJS for tests. Leaving babel-jest in place restores hoisting, but you are no longer testing the module system you ship, and ESM-only dependencies may need transformIgnorePatterns exceptions.

  3. Inject the dependency. Passing the mailer into sendDigest (or exporting a factory) removes the need for module mocking entirely. This is what we did for new code; module mocks stayed for legacy paths.

unstable_mockModule carries the "unstable" prefix because Jest's ESM support is still marked experimental. We accepted that risk because the API has been stable in practice across Jest 29 and 30, and we isolated its use to a few test files.

How to test the fix

First prove the mock is applied, then prove nothing can leak to the network:

NODE_OPTIONS="--experimental-vm-modules" npx jest --runInBand test/digest.test.js

We also added a guard test that fails if the real mailer is ever loaded during the suite:

// test/setup/no-network.js, referenced from setupFiles
import net from 'node:net';

net.Socket.prototype.connect = function guarded(...args) {
  throw new Error(`Unexpected network connection in tests: ${JSON.stringify(args[0])}`);
};

Confirming in production

There is no production for a test suite, so we checked the places where the bug had leaked:

  • The SMTP sandbox inbox stopped receiving digest emails from developer machines. Over the two days before the fix it had collected 412 messages; in the week after, zero.
  • CI duration for the service dropped from about 3 minutes 40 seconds to 1 minute 10 seconds, because tests no longer waited on SMTP connection timeouts.
  • jest --coverage showed mailer.js lines as uncovered in the digest suite, which is the expected outcome when the module is fully mocked.

Regression guard

  • The no-network setup file above runs for every test. A test that accidentally loads a real client fails loudly instead of timing out quietly.
  • An ESLint rule bans jest.mock( in ESM test files, where it can only ever be a no-op:
// eslint.config.js (excerpt)
{
  files: ['test/**/*.test.js'],
  rules: {
    'no-restricted-syntax': ['error', {
      selector: "CallExpression[callee.object.name='jest'][callee.property.name='mock']",
      message: 'jest.mock does not apply to static ESM imports. Use jest.unstable_mockModule + await import().',
    }],
  },
}

Security implications

The bug sent real emails from test runs to a sandbox account, using credentials that lived in a developer shell. Nothing reached real users, but it showed that test suites could exercise live integrations whenever credentials were present. We moved SMTP credentials out of the default shell profile and into an explicit .env.local that the test runner never loads. The fix itself does not change the attack surface.

Prevention checklist

  • When switching a project to native ESM, grep every test for jest.mock before merging.
  • Import the jest object from @jest/globals in ESM tests instead of relying on a global.
  • Register module mocks first and load the code under test with await import().
  • Block network access in unit tests so a missing mock fails fast.
  • Prefer dependency injection for new code so tests do not depend on module mocking at all.

References

Add a label

#nodejs #vitest #jest #esm #mocking
Written by
Daniel Tran @fun_dev

I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.