news 2026/9/25 5:41:24

使用 Sinon 对 ES Module 导入进行 Stub:esm 包与 mutableNamespace 完整实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
使用 Sinon 对 ES Module 导入进行 Stub:esm 包与 mutableNamespace 完整实战指南
  • 测试
  • 开发工具

【免费下载链接】sinon

Test spies, stubs and mocks for JavaScript.

项目地址:https://gitcode.com/gh_mirrors/si/sinon
点击查看免费下载

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 esm

Step 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.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); }); });

第二个用例印证了 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.

项目地址:https://gitcode.com/gh_mirrors/si/sinon
点击查看免费下载
上一篇:如何用Open3D实现点云降维?PCA与t-SNE可视化的完整指南
下一篇:HoRNDIS终极指南:5分钟实现Mac与Android的USB网络共享

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/25 5:40:45

Spring AI + Java实战:企业级RAG知识库问答全链路构建与调优

很多 Java 后端同学的第一反应是:知识库已经建好了,文档也都传上去了,那 RAG 是不是就该自动跑起来了?结果一接 Spring AI 才发现,事情没那么简单。RAG 不是“把文档塞进去”就完事,而是一条完整的链路&…

作者头像 李华
网站建设 2026/9/25 5:39:49

openGauss分区表:大数据量管理实战指南

openGauss分区表:大数据量管理实战指南 【免费下载链接】openGauss-server openGauss kernel ~ openGauss is an open source relational database management system 项目地址: https://gitcode.com/opengauss/openGauss-server openGauss 是华为开源的关系…

作者头像 李华
网站建设 2026/9/25 5:38:40

高频 Linux 命令 100 条

目录 00. dd01. 根据文件内容查找 grep02. 根据文件名查找 find03. 链接文件 ln04. 查看文件夹容量 du05. 磁盘/分区挂载 mount06. 查看CPU温度07. 查看CPU频率08. 查看/修改实时任务运行时间占比09. 查看已知进程名的进程信息10. 查看是否开启了Ftrace 00. dd 名称&#xf…

作者头像 李华