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.
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:
src/mailer.jsexportssendMail(), which opens an SMTP connection.src/digest.jsimportssendMailand calls it once per user.test/digest.test.jsstatically importssendDigest, callsjest.mock('../src/mailer.js')and asserts onsendMail.
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.jsextension and amoduleNameMapperentry. None changed the result. The mock was registered, it simply never got a chance to be used. resetMocksorrestoreMocksin the config. Both were off. A spy created withjest.spyOnon an object export still worked, so the jest object itself was functional.- A cache problem.
npx jest --clearCacheand 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:
- 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. - 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. - 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'insidedigest.jsboth 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
-
Move to Vitest.
vi.mockis hoisted by Vitest's transform pipeline and works with ESM source out of the box. For a small service the migration is mostly renamingjest.fntovi.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() })); -
Keep a transform to CommonJS for tests. Leaving
babel-jestin place restores hoisting, but you are no longer testing the module system you ship, and ESM-only dependencies may needtransformIgnorePatternsexceptions. -
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 --coverageshowedmailer.jslines as uncovered in the digest suite, which is the expected outcome when the module is fully mocked.
Regression guard
- The
no-networksetup 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.mockbefore merging. - Import the
jestobject from@jest/globalsin 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
- Jest documentation: ECMAScript Modules — https://jestjs.io/docs/ecmascript-modules
- Jest documentation: The Jest object — https://jestjs.io/docs/jest-object
- Vitest documentation: Mocking — https://vitest.dev/guide/mocking.html
- Node.js documentation: ECMAScript modules — https://nodejs.org/api/esm.html
- Node.js documentation: VM modules — https://nodejs.org/api/vm.html
Add a label
Embed this post
<iframe src="https://hibug.me/jest-mock-not-applied-esm-imports" width="700" height="420" frameborder="0" loading="lazy"></iframe>
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.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.