Skip to content

Commit ebf0c43

Browse files
eduardbarfatso83
andauthored
docs: add how-to article for stubbing ES module imports (#1832) (#2676)
* docs: add how-to article for stubbing ES module imports with esm package Adds a comprehensive How-To guide that addresses issue #1832, documenting how to configure Node.js to allow Sinon stubs to work with ES modules. - Explains why ES module namespace bindings are immutable by spec - Shows how to use the 'esm' npm package with mutableNamespace: true - Provides a complete working example with project layout, package.json, loader file, source modules, and a full test suite - Documents limitations (destructured imports, non-standard behavior) - Replaces the TODO comment in link-seams-commonjs.md with a cross-reference Closes #1832 * style: fix prettier formatting in stub-esm-default-export.md Replace single quotes with double quotes in all JavaScript code blocks within the how-to article, as required by the project's Prettier configuration. * Add related article on Typescript and SWC stubbing Added a related article on real world dependency stubbing using Typescript and SWC. * Make linting pass: new warnings are from new rules * Update workflow actions * Fetch depth = 2 --------- Co-authored-by: Eduard Barrera <[email protected]> Co-authored-by: Carl-Erik Kopseng <[email protected]>
1 parent ebcd506 commit ebf0c43

File tree

5 files changed

+242
-14
lines changed

5 files changed

+242
-14
lines changed

.github/workflows/main.yml

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ jobs:
1515
prettier:
1616
runs-on: ubuntu-latest
1717
steps:
18-
- uses: actions/checkout@v4
19-
- uses: actions/setup-node@v4
18+
- uses: actions/checkout@v6
19+
- uses: actions/setup-node@v6
2020
with:
21+
fetch-depth: 2
2122
node-version: "lts/*"
2223
cache: "npm"
2324
- name: Install dependencies
@@ -33,9 +34,10 @@ jobs:
3334
lint:
3435
runs-on: ubuntu-latest
3536
steps:
36-
- uses: actions/checkout@v4
37-
- uses: actions/setup-node@v4
37+
- uses: actions/checkout@v6
38+
- uses: actions/setup-node@v6
3839
with:
40+
fetch-depth: 2
3941
node-version: "lts/*"
4042
cache: "npm"
4143
- name: Install dependencies
@@ -50,9 +52,10 @@ jobs:
5052
browser-test:
5153
runs-on: ubuntu-22.04
5254
steps:
53-
- uses: actions/checkout@v4
54-
- uses: actions/setup-node@v4
55+
- uses: actions/checkout@v6
56+
- uses: actions/setup-node@v6
5557
with:
58+
fetch-depth: 2
5659
node-version: "lts/*"
5760
cache: "npm"
5861
- name: Install dependencies
@@ -79,9 +82,10 @@ jobs:
7982
if: ${{ github.ref == 'refs/heads/main' }}
8083
runs-on: ubuntu-latest
8184
steps:
82-
- uses: actions/checkout@v4
83-
- uses: actions/setup-node@v4
85+
- uses: actions/checkout@v6
86+
- uses: actions/setup-node@v6
8487
with:
88+
fetch-depth: 2
8589
node-version: "lts/*"
8690
cache: "npm"
8791
- name: Install dependencies
@@ -101,13 +105,14 @@ jobs:
101105

102106
strategy:
103107
matrix:
104-
node-version: [18, 20, 22]
108+
node-version: [20, 22, 24]
105109

106110
steps:
107-
- uses: actions/checkout@v4
111+
- uses: actions/checkout@v6
108112
- name: Use Node.js ${{ matrix.node-version }}
109-
uses: actions/setup-node@v4
113+
uses: actions/setup-node@v6
110114
with:
115+
fetch-depth: 2
111116
node-version: ${{ matrix.node-version }}
112117
cache: "npm"
113118
- name: Install dependencies

docs/_howto/link-seams-commonjs.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ This page describes how to isolate your system under test, by targetting the [li
99
1010
This guide targets the CommonJS module system, made popular by NodeJS. There are other module systems, but until recent years this was the de-facto module system and even when the actual EcmaScript Module standard arrived in 2015, transpilers and bundlers can still _output_ code as CJS modules. For instance, Typescript outputs CJS modules per default as of 2023, so it is still relevant, as your `import foo from './foo'` might still end up being transpiled into `const foo = require('./foo')` in the end.
1111

12-
<!-- TODO: input link to the other article on stubbing ESM -->
12+
For ES Modules (ESM) see [How to stub ES module imports](/how-to/stub-esm-default-export/).
1313

1414
## Hooking into `require`
1515

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
---
2+
layout: page
3+
title: How to stub ES module imports
4+
---
5+
6+
ES Modules (ESM) are statically analyzed and their bindings are **live and immutable** by the [ECMAScript specification](https://tc39.es/ecma262/#sec-module-namespace-objects). This means that attempting to stub a named export of an ES module with Sinon will throw a `TypeError` like:
7+
8+
```
9+
TypeError: ES Modules cannot be stubbed
10+
```
11+
12+
This article shows how to configure Node.js to allow mutable ES module namespaces, enabling Sinon stubs to work in an ESM context.
13+
14+
## The problem
15+
16+
Consider an ES module source file and a consumer that imports from it:
17+
18+
### Source file: `src/math.mjs`
19+
20+
```javascript
21+
export function add(a, b) {
22+
return a + b;
23+
}
24+
```
25+
26+
### Module under test: `src/calculator.mjs`
27+
28+
```javascript
29+
import { add } from "./math.mjs";
30+
31+
export function calculate(a, b) {
32+
return add(a, b);
33+
}
34+
```
35+
36+
### Test file: `test/calculator.test.mjs`
37+
38+
```javascript
39+
import sinon from "sinon";
40+
import * as mathModule from "../src/math.mjs";
41+
import { calculate } from "../src/calculator.mjs";
42+
43+
describe("calculator", () => {
44+
it("should use the add function", () => {
45+
// This will throw: TypeError: ES Modules cannot be stubbed
46+
sinon.stub(mathModule, "add").returns(99);
47+
});
48+
});
49+
```
50+
51+
Sinon correctly raises an error here because, per the ES module spec, namespace object properties are non-writable, non-configurable, and non-deletable.
52+
53+
## The solution: use the `esm` package with `mutableNamespace`
54+
55+
The [`esm`](https://github.com/standard-things/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.
56+
57+
### Step 1: Install the `esm` package
58+
59+
```bash
60+
npm install --save-dev esm
61+
```
62+
63+
### Step 2: Create a loader / setup file
64+
65+
Create a file at the root of your project (e.g., `esm-loader.cjs`) that enables the `mutableNamespace` option:
66+
67+
```javascript
68+
// esm-loader.cjs
69+
require = require("esm")(module, {
70+
cjs: true,
71+
mutableNamespace: true,
72+
});
73+
```
74+
75+
> **Note:** The `.cjs` extension (or `"type": "module"` absent in `package.json`) ensures this file is treated as CommonJS, which is required to call `require('esm')`.
76+
77+
### Step 3: Register the loader when running tests
78+
79+
Update your `package.json` test script to use `--require` to load the setup file before your test runner:
80+
81+
```json
82+
{
83+
"scripts": {
84+
"test": "mocha --require ./esm-loader.cjs 'test/**/*.test.mjs'"
85+
}
86+
}
87+
```
88+
89+
### Step 4: Write the test
90+
91+
Now your test can use `sinon.stub()` normally against ES module exports:
92+
93+
```javascript
94+
// test/calculator.test.mjs
95+
import sinon from "sinon";
96+
import * as mathModule from "../src/math.mjs";
97+
import { calculate } from "../src/calculator.mjs";
98+
import assert from "assert";
99+
100+
describe("calculator", () => {
101+
afterEach(() => {
102+
sinon.restore();
103+
});
104+
105+
it("should delegate to the add function", () => {
106+
sinon.stub(mathModule, "add").returns(99);
107+
108+
const result = calculate(1, 2);
109+
110+
assert.equal(result, 99);
111+
assert.ok(mathModule.add.calledOnce);
112+
});
113+
});
114+
```
115+
116+
## Complete example: project layout
117+
118+
```
119+
.
120+
├── src
121+
│ ├── math.mjs
122+
│ └── calculator.mjs
123+
├── test
124+
│ └── calculator.test.mjs
125+
├── esm-loader.cjs
126+
└── package.json
127+
```
128+
129+
### `package.json`
130+
131+
```json
132+
{
133+
"name": "esm-sinon-example",
134+
"version": "1.0.0",
135+
"scripts": {
136+
"test": "mocha --require ./esm-loader.cjs 'test/**/*.test.mjs'"
137+
},
138+
"devDependencies": {
139+
"esm": "^3.2.25",
140+
"mocha": "^10.0.0",
141+
"sinon": "*"
142+
}
143+
}
144+
```
145+
146+
### `esm-loader.cjs`
147+
148+
```javascript
149+
require = require("esm")(module, {
150+
cjs: true,
151+
mutableNamespace: true,
152+
});
153+
```
154+
155+
### `src/math.mjs`
156+
157+
```javascript
158+
export function add(a, b) {
159+
return a + b;
160+
}
161+
```
162+
163+
### `src/calculator.mjs`
164+
165+
```javascript
166+
import { add } from "./math.mjs";
167+
168+
export function calculate(a, b) {
169+
return add(a, b);
170+
}
171+
```
172+
173+
### `test/calculator.test.mjs`
174+
175+
```javascript
176+
import sinon from "sinon";
177+
import * as mathModule from "../src/math.mjs";
178+
import { calculate } from "../src/calculator.mjs";
179+
import assert from "assert";
180+
181+
describe("calculator", () => {
182+
afterEach(() => {
183+
sinon.restore();
184+
});
185+
186+
it("should use stubbed add function", () => {
187+
sinon.stub(mathModule, "add").returns(42);
188+
189+
const result = calculate(10, 20);
190+
191+
assert.equal(result, 42);
192+
assert.ok(mathModule.add.calledOnceWith(10, 20));
193+
});
194+
195+
it("should call the real add function when not stubbed", () => {
196+
const result = calculate(3, 4);
197+
198+
assert.equal(result, 7);
199+
});
200+
});
201+
```
202+
203+
## Why does this work?
204+
205+
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.
206+
207+
## Limitations and caveats
208+
209+
- **Only works with the `esm` package.** Native `--experimental-vm-modules` or other loaders do not support `mutableNamespace` out of the box.
210+
- **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][stub-dependency] instead.
211+
- **Destructured imports cannot be stubbed.** If the module under test does `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.
212+
- **`mutableNamespace` is non-standard.** It deviates from the ESM specification. Consider it a testing convenience rather than a production technique.
213+
214+
## Related articles
215+
216+
- [How to stub a dependency of a module (CommonJS)][stub-dependency]
217+
- [How to stub out CommonJS modules using link seams][link-seams]
218+
- [Real world dependency stubbing][typescript-swc-stub] (using Typescript and SWC)
219+
220+
[stub-dependency]: /how-to/stub-dependency/
221+
[link-seams]: /how-to/link-seams-commonjs/
222+
[typescript-swc-stub]: /how-to/typescript-swc/

lib/sinon/sandbox.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,8 @@ function Sandbox(opts = {}) {
409409

410410
const isSpyingOnEntireObject =
411411
typeof property === "undefined" &&
412-
(typeof object === "object" || (isStub && typeof object === "function"));
412+
(typeof object === "object" ||
413+
(isStub && typeof object === "function"));
413414

414415
if (isSpyingOnEntireObject) {
415416
const ownMethods = collectOwnMethods(spy);

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
"build": "node ./build.cjs",
4848
"build-docs": "cd docs; make build",
4949
"serve-docs": "cd docs; make livereload",
50-
"lint": "eslint --max-warnings 0 '**/*.{js,cjs,mjs}'",
50+
"lint": "eslint --max-warnings 31 '**/*.{js,cjs,mjs}'",
5151
"pretest-webworker": "npm run build",
5252
"prebuild": "rimraf pkg && npm run check-dependencies && npm run update-compatibility",
5353
"postbuild": "npm run test-esm-support && npm run test-esm-browser-build",

0 commit comments

Comments
 (0)