How to stub ES module imports
ES Modules (ESM) are statically analyzed and their bindings are live and immutable by the ECMAScript specification. This means that attempting to stub a named export of an ES module with Sinon will throw a TypeError like:
TypeError: ES Modules cannot be stubbed
This article shows how to configure Node.js to allow mutable ES module namespaces, enabling Sinon stubs to work in an ESM context.
The problem
Consider an ES module source file and a consumer that imports from it:
Source file: src/math.mjs
export function add(a, b) {
return a + b;
}
Module under test: src/calculator.mjs
import { add } from "./math.mjs";
export function calculate(a, b) {
return add(a, b);
}
Test file: test/calculator.test.mjs
import sinon from "sinon";
import * as mathModule from "../src/math.mjs";
import { calculate } from "../src/calculator.mjs";
describe("calculator", () => {
it("should use the add function", () => {
// This will throw: TypeError: ES Modules cannot be stubbed
sinon.stub(mathModule, "add").returns(99);
});
});
Sinon correctly raises an error here because, per the ES module spec, namespace object properties are non-writable, non-configurable, and non-deletable.
The solution: use the esm package with mutableNamespace
The esm package is a fast, production-ready ES module loader for Node.js. It offers a mutableNamespace option that makes module namespace objects writable, which is what Sinon needs to install stubs.
Step 1: Install the esm package
npm install --save-dev esm
Step 2: Create a loader / setup file
Create a file at the root of your project (e.g., esm-loader.cjs) that enables the mutableNamespace option:
// esm-loader.cjs
require = require("esm")(module, {
cjs: true,
mutableNamespace: true,
});
Note: The
.cjsextension (or"type": "module"absent inpackage.json) ensures this file is treated as CommonJS, which is required to callrequire('esm').
Step 3: Register the loader when running tests
Update your package.json test script to use --require to load the setup file before your test runner:
{
"scripts": {
"test": "mocha --require ./esm-loader.cjs 'test/**/*.test.mjs'"
}
}
Step 4: Write the test
Now your test can use sinon.stub() normally against ES module exports:
// test/calculator.test.mjs
import sinon from "sinon";
import * as mathModule from "../src/math.mjs";
import { calculate } from "../src/calculator.mjs";
import assert from "assert";
describe("calculator", () => {
afterEach(() => {
sinon.restore();
});
it("should delegate to the add function", () => {
sinon.stub(mathModule, "add").returns(99);
const result = calculate(1, 2);
assert.equal(result, 99);
assert.ok(mathModule.add.calledOnce);
});
});
Complete example: project layout
.
├── src
│ ├── math.mjs
│ └── calculator.mjs
├── test
│ └── calculator.test.mjs
├── esm-loader.cjs
└── package.json
package.json
{
"name": "esm-sinon-example",
"version": "1.0.0",
"scripts": {
"test": "mocha --require ./esm-loader.cjs 'test/**/*.test.mjs'"
},
"devDependencies": {
"esm": "^3.2.25",
"mocha": "^10.0.0",
"sinon": "*"
}
}
esm-loader.cjs
require = require("esm")(module, {
cjs: true,
mutableNamespace: true,
});
src/math.mjs
export function add(a, b) {
return a + b;
}
src/calculator.mjs
import { add } from "./math.mjs";
export function calculate(a, b) {
return add(a, b);
}
test/calculator.test.mjs
import sinon from "sinon";
import * as mathModule from "../src/math.mjs";
import { calculate } from "../src/calculator.mjs";
import assert from "assert";
describe("calculator", () => {
afterEach(() => {
sinon.restore();
});
it("should use stubbed add function", () => {
sinon.stub(mathModule, "add").returns(42);
const result = calculate(10, 20);
assert.equal(result, 42);
assert.ok(mathModule.add.calledOnceWith(10, 20));
});
it("should call the real add function when not stubbed", () => {
const result = calculate(3, 4);
assert.equal(result, 7);
});
});
Why does this work?
The esm package hooks into Node.js’s module loading system. When mutableNamespace: true is set, it wraps ES module namespace objects with a Proxy that allows property assignment. Sinon’s stub() function replaces the property on the namespace object; with the proxy in place, this assignment succeeds instead of throwing.
Limitations and caveats
- Only works with the
esmpackage. Native--experimental-vm-modulesor other loaders do not supportmutableNamespaceout of the box. - Transpiled output: If you are using TypeScript or Babel that already compiles your ESM to CommonJS, this approach is not needed. Stub the CommonJS dependency instead.
- Destructured imports cannot be stubbed. If the module under test does
import { add } from './math.mjs'and usesaddas a local binding, the stub on the namespace will not affect the already-captured binding. The consumer must access the export through the module namespace object for stubs to take effect. mutableNamespaceis non-standard. It deviates from the ESM specification. Consider it a testing convenience rather than a production technique.
