docs/_howto/stub-esm-default-export.md
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.
Consider an ES module source file and a consumer that imports from it:
src/math.mjsexport function add(a, b) {
return a + b;
}
src/calculator.mjsimport { add } from "./math.mjs";
export function calculate(a, b) {
return add(a, b);
}
test/calculator.test.mjsimport 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.
esm package with mutableNamespaceThe 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.
esm packagenpm install --save-dev esm
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').
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'"
}
}
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);
});
});
.
├── 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.cjsrequire = require("esm")(module, {
cjs: true,
mutableNamespace: true,
});
src/math.mjsexport function add(a, b) {
return a + b;
}
src/calculator.mjsimport { add } from "./math.mjs";
export function calculate(a, b) {
return add(a, b);
}
test/calculator.test.mjsimport 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);
});
});
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.
esm package. Native --experimental-vm-modules or other loaders do not support mutableNamespace out of the box.import { add } from './math.mjs' and uses add as 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.mutableNamespace is non-standard. It deviates from the ESM specification. Consider it a testing convenience rather than a production technique.