- 测试
- 开发工具
【免费下载链接】sinon
Test spies, stubs and mocks for JavaScript.
ES Modules(ESM)的绑定是**静态解析、实时(live)且不可变(immutable)**的,因此直接对 ES 模块的命名空间对象执行sinon.stub()会抛出TypeError: ES Modules cannot be stubbed。本文以 Sinon 开源仓库的官方指南 docs/guides/how-to/stub-esm.md 为主体,结合 stub.js 与 is-es-module.js 等源码实现,讲解如何借助 Node.js 生态中的esm包及其mutableNamespace选项,让模块命名空间变为可写,从而在 ESM 语境下正常使用 Sinon stub。读完本文,你将掌握一套可直接落地的 ESM 单测替身(test double)方案,并理解其底层原理与边界限制。
问题本质:ESM 命名空间为什么无法被 Stub
ECMAScript 规范规定,模块命名空间对象(Module Namespace Object)的属性是non-writable(不可写)、non-configurable(不可配置)、non-deletable(不可删除)的。也就是说,一旦模块加载完成,其导出绑定就是只读的,任何试图在运行时改写导出的行为都会被 JavaScript 引擎拒绝。
Sinon 的stub()在实现上会主动检测这种场景。查看核心实现 stub.js:
function stubImpl(object, property, context) { if (isEsModule(object)) { throw new TypeError("ES Modules cannot be stubbed"); } // ... }而判定"是否 ES 模块"的逻辑在 is-es-module.js 中:
export default function isEsModule(object) { return ( object && typeof Symbol !== "undefined" && object[Symbol.toStringTag] === "Module" && Object.isSealed(object) ); }即:如果一个对象的Symbol.toStringTag为"Module"且对象处于密封(sealed)状态,Sinon 就认定其为 ES 模块命名空间,并拒绝创建 stub。这一行为也被仓库的单元测试明确锁定,见 stub-test.js:
it("throws when trying to stub an ES module namespace object", function () { const object = {}; Object.defineProperty(object, Symbol.toStringTag, { value: "Module", }); Object.seal(object); assert.exception( function () { createStub(object); }, { name: "TypeError", message: "ES Modules cannot be stubbed", }, ); });换句话说,这是 Sinon 主动抛出的、可读性良好的错误提示,目的是避免用户在不可变命名空间上做无效操作后产生困惑。
一个典型的失败示例
假设有如下源码文件与测试:
源文件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"; describe("calculator", () => { it("should use the add function", () => { // This will throw: TypeError: ES Modules cannot be stubbed sinon.stub(mathModule, "add").returns(99); }); });运行测试时会得到TypeError: ES Modules cannot be stubbed。原因正如上文所述:mathModule是原生 ESM 命名空间对象,其add属性是只读的,sinon.stub()无法完成属性替换。
解决方案:esm包 +mutableNamespace
esm包是一个为 Node.js 提供的高性能、生产可用的 ES 模块加载器。它提供了mutableNamespace选项,能够将模块命名空间对象包装为可写,这正是 Sinon 安装 stub 所需的先决条件。
Step 1:安装esm包
npm install --save-dev esmStep 2:创建加载器 / 启动文件
在项目根目录创建esm-loader.cjs,开启mutableNamespace选项:
// esm-loader.cjs require = require("esm")(module, { cjs: true, mutableNamespace: true, });注意:文件必须使用
.cjs扩展名(或确保package.json中没有"type": "module"),从而保证该文件被当作 CommonJS 处理,否则无法调用require("esm")。
Step 3:在运行测试时注册加载器
在package.json的test脚本中使用--require参数,在测试运行器启动前加载上述启动文件:
{ "scripts": { "test": "mocha --require ./esm-loader.cjs 'test/**/*.test.mjs'" } }Step 4:编写测试
现在可以像操作普通对象一样,对 ES 模块的导出进行sinon.stub():
// 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); }); });注意两点关键约定:
- 测试中必须使用
import * as mathModule这种命名空间导入方式,而不是import { add }解构导入(原因见下文"局限与注意事项"); - 使用
afterEach(() => sinon.restore())保证每个用例结束后恢复所有被替换的属性,避免测试间相互污染,这也是 error-handling.md 中反复强调的最佳实践。
完整示例:项目布局与全部代码
. ├── src │ ├── math.mjs │ └── calculator.mjs ├── test │ └── calculator.test.mjs ├── esm-loader.cjs └── package.jsonpackage.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); }); });第二个用例印证了 stubbing 的"可恢复性":在afterEach中调用sinon.restore()之后,calculate会重新调用真实的add,返回真实结果7。
为什么这套方案能生效?
esm包会挂钩 Node.js 的模块加载系统。当设置了mutableNamespace: true时,它用Proxy包装 ES 模块命名空间对象,使属性赋值得以通过。于是,sinon.stub()在命名空间对象上替换属性的操作,不再是"向不可变对象写入",而是"向代理对象写入",因此不再抛出异常。
从 Sinon 源码角度看,stubImpl在创建 stub 前会调用 get-property-descriptor.js 获取属性的描述符,并对属性描述符做合法性校验(见 stub.js 与 is-property-configurable.js)。当命名空间经 Proxy 变得可写可配置后,属性描述符校验自然通过,stub 安装成功。整个链路可以概括为:
esm加载器 →mutableNamespace启用 Proxy 包装 →sinon.stub(namespace, prop)的属性替换合法化 → stub 生效、调用记录被 spy 收集。
局限与注意事项
- 只对
esm包有效。原生的--experimental-vm-modules或其他 loader 默认不支持mutableNamespace语义,此方案无法迁移到这些环境。 - 转译场景无需此方案。如果使用 TypeScript 或 Babel 且已经将 ESM 编译为 CommonJS,那么模块导出变成可变的普通对象,直接按 CommonJS 依赖替换的方式处理即可,无需
esm包。此时请参考 如何 Stub CommonJS 依赖。 - 解构导入无法被 Stub。如果被测模块内部使用
import { add } from "./math.mjs"并把add作为局部绑定直接调用,那么对命名空间对象的 stub不会影响这个早已捕获的局部绑定。要让 stub 生效,被测代码必须通过命名空间对象访问导出(例如import * as math from "./math.mjs"后调用math.add(...))。同理,测试文件中也应使用import * as mathModule而非解构导入。 mutableNamespace是非标准的。它偏离了 ESM 规范,本质上是"为测试便利而打破只读约束"的手段,不应被当作生产环境的常规技巧。生产代码请严格遵守 ESM 只读语义。- stub 是库级行为而非模块拦截。Sinon 是一个 stubbing 库,而不是模块拦截库;依赖替换高度依赖运行环境与实现方式。Node 环境下更通用的推荐做法是link seams或显式依赖注入,详见 link-seams-commonjs.md 与 stub-dependency.md。
补充替代思路
当你不便引入esm包时,error-handling.md 还提供了两种轻量替代方案:
- 包装对象法:把单个导入的函数包进一个普通对象再 stub:
import { someMethod } from "./my-module.js"; const wrapper = { someMethod }; sinon.stub(wrapper, "someMethod");sinon.replace()法:使用sinon.replace()替换命名空间上的方法:
import * as myModule from "./my-module.js"; import * as sinon from "sinon"; const fake = sinon.fake.returns("mocked value"); sinon.replace(myModule, "someMethod", fake);这两种方式都可以避免对不可变命名空间直接写入,适合不想引入额外加载器的简单场景。
相关文章
- 如何 Stub 模块的依赖(CommonJS)
- 使用 link seams 替换 CommonJS 模块
- 真实世界依赖替换(TypeScript + SWC)
- Stub 错误处理与最佳实践
小结
ESM 的只读绑定决定了sinon.stub()无法直接作用于原生模块命名空间,但通过esm包的mutableNamespace选项配合--require启动加载器,可以在一套完整的 Node.js 测试链路中恢复"可写命名空间",让 Sinon 的 spy/stub/restore 机制在 ESM 项目里正常工作。使用时务必牢记三条红线:只对esm包有效、转译场景不需要、解构导入无法生效,并结合sinon.restore()保持测试隔离。对于更复杂的依赖替换诉求,优先考虑 link seams 或依赖注入等环境无关的方案。
- 测试
- 开发工具
【免费下载链接】sinon
Test spies, stubs and mocks for JavaScript.
相关推荐
Flow 严格 ES Module 导入/导出 Lint 规则完整指南:`experimental.strict_es6_import_export` 详解
Flow 严格 ES Module 导入/导出 Lint 规则完整指南: experimental.strict_es6_import_export 详解 本文
开发工具静态分析代码质量Sinon fake timers 进阶:`clock.runToLast()` / `runToLastAsync()` 完整实战指南
Sinon fake timers 进阶: clock.runToLast / runToLastAsync 完整实战指南 本篇技术指南聚焦 Sinon.JS
测试开发工具使用PuLP进行模型导入导出的完整指南
使用PuLP进行模型导入导出的完整指南 前言 PuLP作为Python中流行的线性规划建模工具,提供了强大的模型构建和求解能力。在实际应用中,我们经常需要将构建
科学计算
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考