news 2026/9/25 3:27:55

用 Link Seams 在 CommonJS 中 Stub 依赖:Sinon + Proxyquire 实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用 Link Seams 在 CommonJS 中 Stub 依赖:Sinon + Proxyquire 实战指南
  • 测试
  • 开发工具

【免费下载链接】sinon

Test spies, stubs and mocks for JavaScript.

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

Sinon 是 JavaScript 测试中最常用的测试替身(test double)库,但它本身只负责创建和注入 spies、stubs、mocks 与 fakes,并不拦截模块加载。本指南讲解如何在 CommonJS 环境下利用link seams(链接缝)技术,通过proxyquire这类工具 hook 进 Node.js 的require机制,把被测模块的依赖整体替换成由你完全控制的 Sinon stub,从而真正隔离被测系统(SUT)。读完本文,你将掌握"目标模块 + 假依赖"的完整测试套路,理解其底层原理,并清楚它与直接属性替换、ESM 可变命名空间等方案的边界。

若想更透彻地理解本文示例与 "seams" 概念的来龙去脉,推荐阅读经典著作Working Effectively with Legacy Code中关于 seams 的章节,但这并非阅读本文的必要前提。

为什么选择 Link Seams 而非直接替换属性

在讲实现之前,先厘清一个关键区别:Sinon 是 stubbing 库,不是模块拦截库。它可以把一个对象上的方法替换为 stub,但它无法改变"被测模块在加载时通过require拿到的依赖引用"。

docs/guides/how-to/stub-dependency.md介绍的"直接属性替换"方案,要求被测模块与测试共享同一个依赖对象实例:

const dependencyModule = require("./dependencyModule"); sinon.stub(dependencyModule, "getSecretNumber").returns(3);

这只有在被测模块内部没有对依赖做解构(destructuring)时才能生效,而且依赖必须是可写的普通对象。而 link seams 的思路完全不同:它不去碰任何已加载的对象,而是在require解析的那一刻拦截它,把整个依赖模块换成测试提供的假实现。这相当于在"调用点"而不是"属性"上制造替换点,适用范围更广,也无需修改被测代码。

背景:为什么 CommonJS 依然值得掌握

本指南针对的是由 Node.js 推广开来的 CommonJS(CJS)模块系统。虽然 ECMAScript 2015 引入了 ES Modules(ESM)标准,但在 2023 年之前 CJS 一直是事实上的主流模块格式,而且时至今日,许多转译器与打包器仍会把代码输出为 CJS 模块。

典型例子就是 TypeScript:截至 2023 年,它的默认编译输出仍是 CJS。也就是说,你写的import foo from './foo'最终可能被转译成const foo = require('./foo')。只要目标运行时里实际执行的还是require,link seams 方案就依然适用。

如果你面对的是真正的 ES Modules(例如.mjs文件,或package.json中"type": "module"且未经过转译),请参阅 如何 Stub ES Module imports 一文——ESM 的命名空间对象在规范上是不可变的,需要借助esm包的mutableNamespace选项才能让 Sinon 的 stub 生效,原理与本指南完全不同。

核心思路:Hook 进require

要让require的底层调用被替换,就需要一个能 hook 进模块加载流程的工具。生态中这类工具不少,常见的有:

  • proxyquire:本指南示例选用,API 直观,传入"要替换的模块路径 → 假实现"的映射即可;
  • rewire:偏重于访问模块私有变量,也可替换依赖;
  • Quibble:语法更简洁,并且支持作为 ESM loader 使用,在 真实世界依赖 Stub 案例 中有配套用法。

尽管工具各异,底层机制大同小异:它们都包装(或替换)了 Node 的require函数,在模块解析阶段按你给出的映射返回假模块。掌握了 proxyquire 的用法,切换到其他工具的成本很低。

实战示例:Stub 掉fs

下面是一个完整的、可运行的示例,演示如何隔离一个依赖fs的模块。完整的源码与可运行 demo 由 Sinon 团队维护在sinonjs/demo-proxyquire仓库中。

示例的项目结构如下:

. ├── lib │ └── does-file-exist.js └── test └── does-file-exist.test.js

被测源码:lib/does-file-exist.js

这是doesFileExist模块的源码,它只有一个依赖:fs。

var fs = require("fs"); function doesFileExist(path) { return fs.existsSync(path); } module.exports = doesFileExist;

注意这里的关键点:模块顶层require("fs")拿到的fs对象,将在测试中被 proxyquire 整体替换成假对象。这正是 link seam 发挥作用的位置——require语句本身就是那个"链接缝"。

测试文件:test/does-file-exist.test.js

为了隔离被测模块,我们把fsstub 掉,提供一个我们完全掌控其行为的fs.existsSync假实现:

var proxyquire = require("proxyquire"); var sinon = require("sinon"); var assert = require("referee").assert; var doesFileExist; // the module to test var existsSyncStub; // the fake method on the dependency describe("example", function () { beforeEach(function () { existsSyncStub = sinon.stub(); // create a stub for every test // import the module to test, using a fake dependency doesFileExist = proxyquire("../lib/does-file-exist", { fs: { existsSync: existsSyncStub, }, }); }); describe("when a path exists", function () { beforeEach(function () { existsSyncStub.returns(true); // set the return value that we want }); it("should return `true`", function () { var actual = doesFileExist("9d7af804-4719-4578-ba1d-5dd8a4dae89f"); assert.isTrue(actual); }); }); });

拆解测试的关键步骤

  1. 每次测试前新建 stub:existsSyncStub = sinon.stub()生成一个全新的、无预设行为的 stub 函数。把它放在beforeEach中,确保不同测试之间互不污染。
  2. 用 proxyquire 注入假依赖:proxyquire("../lib/does-file-exist", { fs: { existsSync: existsSyncStub } })的意思是:加载../lib/does-file-exist时,但凡它require("fs"),就返回{ existsSync: existsSyncStub }这个假对象。于是被测模块里的fs.existsSync(path)实际调用的是我们的 stub。
  3. 预设行为:existsSyncStub.returns(true)让 stub 无论收到什么路径都返回true,从而把测试引导到"路径存在"这一分支。
  4. 断言:assert.isTrue(actual)验证被测函数确实把 stub 的返回值透传了出来。

当你想测试"路径不存在"的分支时,只需在另一个describe块里调用existsSyncStub.returns(false),被测模块的代码一行都不用改——这正是隔离被测系统的意义所在。此外,stub 继承了 Sinon 完整的 spy API,你还可以用existsSyncStub.calledWith(...)、existsSyncStub.callCount等断言验证依赖的调用情况。

源码级原理:Sinon stub 到底做了什么

从实现层面看,sinon.stub()的行为可以在仓库源码中得到印证。src/sinon/stub.js 中的stubImpl是 stub 的核心实现:它会先检查目标对象是否是 ES Module(若是则抛出TypeError: ES Modules cannot be stubbed),再通过getPropertyDescriptor读取属性的属性描述符(property descriptor),校验其可写/可配置性。真正执行"替换"动作的是 src/sinon/util/core/wrap-method.js 中的wrapMethod——它把对象上的原方法包装成 stub 代理(proxy),原函数不再被调用。

这解释了 link seams 方案与直接属性替换的一个本质差异:

  • 直接属性替换(sinon.stub(fs, "existsSync"))要求fs上确实存在该属性、且属性描述符允许被修改。如果依赖是转译产物(如 SWC 输出的非可配置 getter),会直接抛错——docs/guides/how-to/typescript-swc.md中的案例就演示了这一点。
  • proxyquire 方案完全不触碰原fs对象,我们提供的假对象本身就是 stub,因此不受属性描述符约束,适用于任何形式的依赖。

此外,stub 的returns()行为在 src/sinon/behavior.js 与 src/sinon/default-behaviors.js 中定义:调用returns(value)会替换 stub 的默认行为,而每次调用都经过createStub中生成的代理函数(见 src/sinon/stub.js),最终由当前行为(behavior)决定返回值。

与其他依赖替换方案的对比

方案适用场景关键限制
Link seams(proxyquire / Quibble / rewire)CommonJS 模块,希望整体替换依赖、不修改被测代码需要额外工具;不适用于真正的 ESM
直接属性替换(sinon.stub(dep, "meth"))依赖是共享实例、属性可写可配置依赖被解构后失效;转译产物可能不可配置
显式依赖注入(DI)任意模块系统,被测代码可改需要改造被测代码的签名或入口
ESM 可变命名空间(esm包)真正的 ESM 环境偏离 ESM 规范,仅作测试便利

其中显式依赖注入与 Quibble 在 link seams 场景的变体,在 真实世界依赖 Stub 案例(TypeScript + SWC) 一文中有完整的分步实战演示——那里用quibble("./other", { toBeMocked: mocked })配合sandbox.stub().returns("mocked"),本质与本文的 proxyquire 示例同源。

局限与注意事项

  • 仅适用于 CommonJS 执行路径:如果模块最终以真实 ESM 形式运行(未转译),require拦截不会生效,请改用 Stub ES Module imports 中的方案。
  • 替换粒度是"模块"而非"属性":你给出的假对象需要包含被测模块实际会访问的全部成员,否则运行时会得到undefined而不是TypeError,错误会更隐蔽。
  • 与打包器/转译器输出有关:如果 TS/Babel 已把 ESM 编译为 CJS,本文方案依然有效;但若转译产物把导出变成不可配置的 getter,直接属性替换会失败,link seams 仍可绕过该问题。
  • 在测试中保持 stub 隔离:务必在beforeEach中新建 stub,并考虑使用sinon.createSandbox()统一管理restore(),避免测试间相互泄漏。

延伸阅读

  • 如何 Stub 模块的某个依赖(CommonJS 直接属性替换)
  • 如何 Stub ES Module imports(ESM 可变命名空间)
  • 真实世界依赖 Stub 案例(TypeScript + SWC + Quibble)
  • Stubs 概念总览(含returns、onCall等行为 API)
  • Sinon 官方 How-to 索引
  • 测试
  • 开发工具

【免费下载链接】sinon

Test spies, stubs and mocks for JavaScript.

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

相关推荐

上一篇:Teku源码编译与调试:Java开发者深入理解共识客户端的捷径
下一篇:终极指南:如何用N_m3u8DL-RE高效下载加密流媒体内容

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

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

水泥砂浆质保多久?从污染控制到服务部闭环的工程质量管理指南

做工程的人应该都有过这种体验:水泥砂浆交付之后,业主问的第一句话往往不是“做得怎么样”,而是“这个质保多久”。这问题听着简单,背后牵扯的却是一条完整的质量链条——材料本身有没有问题、施工有没有碰红线、交付之后谁在负责…

作者头像 李华
网站建设 2026/9/25 3:24:31

源码级拆解EastDraw:从编译到二次开发的矢量绘图实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华