|
| 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/ |
0 commit comments