☰
Sinon.JS 实战指南:How-to 场景全解——Fake Timers 异步加速、CommonJS/ESM 依赖桩与 TypeScript+SWC 真实案例
2026/9/25 10:47:16 网站建设 项目流程
  • 测试
  • 开发工具

【免费下载链接】sinon

Test spies, stubs and mocks for JavaScript.

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

本文是 Sinon.JS 官方 "How-to" 系列实践指南的完整导读与深度讲解。围绕docs/guides/how-to/index.md索引下收录的五类高频测试场景——用 fake timers 加速依赖定时器的异步测试、通过 link seams 隔离 CommonJS 被测系统、直接桩掉模块依赖、让 ES Module 命名空间可被 stub、以及 TypeScript + SWC 真实世界依赖替换案例——逐一给出可复制、可运行的代码与配置,并结合当前仓库源码与测试印证底层原理。读完本文,你将掌握在 Node.js 与各类构建工具链中隔离被测系统、注入测试替身(test double)的完整方法论与排错思路。

一、How-to 指南总览

Sinon.JS 的核心定位是"创建并注入测试替身(spies、fakes、stubs)"的库,而不是模块拦截库。因此在面对"如何替换被测模块的依赖"这类问题时,答案高度依赖运行环境与实现方式。官方在 docs/guides/how-to/index.md 中收录了五篇独立成文的实战指南:

指南相对路径核心主题
Async functions with fake timersdocs/guides/how-to/fake-timers-async.md加速依赖定时器的测试,同步触发计划中的回调
Link seams (CommonJS)docs/guides/how-to/link-seams-commonjs.md用 proxyquire 这类工具 hook 进require,隔离被测系统
Stub a dependencydocs/guides/how-to/stub-dependency.md直接在测试中桩掉 CommonJS 模块的导出方法
Stub ES module importsdocs/guides/how-to/stub-esm.md让 ES Module 命名空间可变,从而支持 Sinon 桩
TypeScript and SWCdocs/guides/how-to/typescript-swc.md真实世界依赖替换的详细排错案例研究

五篇指南遵循同一条主线:先弄清楚构建产物到底是什么样,再选择对应的隔离手段。下面逐篇深入。

二、用 Fake Timers 加速异步函数测试

2.1 基本思路:跳过等待,同步推进时钟

依赖setTimeout、setInterval等定时器 API 的代码在测试中通常需要真实等待,拖慢整个测试套件。Sinon 的 fake timers 把全局定时器替换为可控的假实现,让测试可以调用clock.tick(ms)直接"快进"时间,同步触发计划中的回调。

以官方指南中的 maker 模块为例(docs/guides/how-to/fake-timers-async.md):

// maker.js module.exports.callAfterOneSecond = (callback) => { setTimeout(callback, 1000); };

对应的测试使用 Mocha 风格的生命周期钩子在测试前后安装与恢复时钟:

// test.js before(function () { this.clock = sinon.useFakeTimers(); }); after(function () { this.clock.restore(); }); it("should call after one second", function () { const spy = sinon.spy(); maker.callAfterOneSecond(spy); // callback 不会立即被调用 assert.ok(!spy.called); // 时钟快进 1000ms 后,回调被同步触发 this.clock.tick(1000); assert.ok(spy.called); // PASS });

关键点在于:tick(1000)之后断言立刻执行,测试几乎瞬时完成,而不是真的等待 1 秒。

2.2 返回 Promise 的函数

指南进一步演示了"测试返回 Promise 的函数,同时推进假时钟"的模式。被测函数通过setTimeout在 1 秒后 resolve:

module.exports.fulfillAfterOneSecond = () => { return new Promise((resolve) => { setTimeout(() => resolve(42), 1000); }); };

测试返回 Promise 本身(利用测试运行器对 Promise 的原生支持),在tick之后通过.then断言:

it("should be fulfilled after one second", function () { const promise = maker.fulfillAfterOneSecond(); this.clock.tick(1000); return promise.then((result) => assert.equal(result, 42)); // PASS });

由于async函数与显式返回 Promise 的函数行为一致,同一个模式可以直接套用在async/await代码上:

// maker.js module.exports.asyncReturnAfterOneSecond = async () => { const setTimeoutPromise = (timeout) => { return new Promise((resolve) => setTimeout(resolve, timeout)); }; await setTimeoutPromise(1000); return 42; };
it("should return 42 after 1000ms", async function () { const promise = maker.asyncReturnAfterOneSecond(); this.clock.tick(1000); const result = await promise; assert.equal(result, 42); // PASS });

2.3 底层原理与注意事项

从源码看,sinon.useFakeTimers的实现在 src/sinon/util/fake-timers.js:它委托给@sinonjs/fake-timers包,支持三种入参形态——无参(now: 0)、数字或Date(作为起始纪元)、以及配置对象(可携带global指定安装目标上下文),并返回一个带restore(内部即uninstall)方法的 clock 实例。

该 API 的配置选项在 docs/concepts/fake-timers/use-fake-timers.md 中有完整表格,常用项包括:

选项类型默认值说明
config.nowNumber/Date0安装时钟时指定的起始时间戳
config.toFakeString[]除nextTick外全部显式指定要替换的函数名,不能与toNotFake并用
config.toNotFakeString[][]显式指定保持原生的函数名
config.loopLimitNumber1000调用runAll()时最多执行的定时器数量
config.shouldAdvanceTimeBooleanfalse根据真实系统时间流逝自动推进模拟时间
config.advanceTimeDeltaNumber20仅配合shouldAdvanceTime: true使用,真实时间每过 1ms 推进模拟时间多少 ms
config.shouldClearNativeTimersBooleanfalse安装前清除原生定时器
config.ignoreMissingTimersBooleanfalse忽略环境中不存在的定时器方法
config.targetObjectglobal指定安装目标对象(如 JSDOM 窗口)

例如限制runAll()循环上限并指定起始时间:

sinon.useFakeTimers({ now: 1483228800000, loopLimit: 10 });

仓库测试 docs/tests/docs/fake-timers/config-example.test.js 正是该组合的验证用例:安装时钟后断言Date.now()与配置一致,调度多个 timeout 后调用clock.runAll()全部执行,最后clock.restore()收尾。

需要注意:尽管这些测试几乎瞬时通过,它们本质上仍是异步的——与第一个同步回调示例不同,它们返回 Promise 而不是在clock.tick(1000)之后立刻运行断言,因为Promise 的then()永远异步执行。但假时钟依然把等待时间压缩到了接近零。

三、通过 Link Seams 隔离 CommonJS 被测系统

3.1 什么是 link seams

"Seam"(接缝)概念源自经典著作Working Effectively with Legacy Code:它是代码中允许你不动被测代码、从外部替换其依赖的位置。本指南的目标是定位 link seams 并用自己的桩替换依赖(docs/guides/how-to/link-seams-commonjs.md)。

3.2 为什么 CommonJS 仍然值得关注

指南明确指出:尽管 ES Module 标准 2015 年就已出现,但转译器与打包器至今仍可把代码输出为 CJS 模块——例如 TypeScript 到 2023 年默认输出仍是 CJS,import foo from './foo'最终可能被转译成const foo = require('./foo')。因此理解 CJS 场景依然必要。ESM 场景请转至 Stub ES module imports 指南。

3.3 Hooking intorequire

要替换require底层完成的加载行为,需要一个能 hook 进模块加载过程的工具:rewire、proxyquire、Quibble 等皆可。指南以proxyquire为例,其机制对其他工具同样适用。

3.4 完整示例

示例目录结构:

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

源文件只依赖一个模块fs:

// lib/does-file-exist.js var fs = require("fs"); function doesFileExist(path) { return fs.existsSync(path); } module.exports = doesFileExist;

测试文件用proxyquire以假实现替换fs,其中existsSync是我们在每个测试前新创建的 Sinon 桩,完全控制其行为:

// test/does-file-exist.test.js var proxyquire = require("proxyquire"); var sinon = require("sinon"); var assert = require("referee").assert; var doesFileExist; // 被测模块 var existsSyncStub; // 依赖上的假方法 describe("example", function () { beforeEach(function () { existsSyncStub = sinon.stub(); // 每个测试都创建新桩 // 用假依赖导入被测模块 doesFileExist = proxyquire("../lib/does-file-exist", { fs: { existsSync: existsSyncStub, }, }); }); describe("when a path exists", function () { beforeEach(function () { existsSyncStub.returns(true); // 设置想要的返回值 }); it("should return `true`", function () { var actual = doesFileExist("9d7af804-4719-4578-ba1d-5dd8a4dae89f"); assert.isTrue(actual); }); }); });

这个模式的价值在于:被测模块doesFileExist完全不知道自己被测试——它依旧require("fs"),只是require的解析被 proxyquire 接管,返回了我们注入的、带桩方法的对象。

四、直接桩掉模块依赖(CommonJS)

4.1 适用边界与方法前提

Sinon 是桩库而非模块拦截库,依赖桩的可行性高度依赖环境与实现。官方对 Node 环境的推荐通常是link seams 方案(见上一节)或显式依赖注入;但在一些更基础的场景下,仅用 Sinon 就能通过修改依赖模块的导出属性达到目的(docs/guides/how-to/stub-dependency.md)。

要桩掉被测模块的依赖(被 import 的模块),做法是:在测试中显式 import 该依赖,然后 stub 其目标方法。前提是:该方法不能被解构(destructured)——无论在被测模块里还是测试里都不行,因为解构会捕获方法引用,桩替换的是模块导出对象上的属性,已捕获的绑定不受影响。

4.2 基础示例

依赖模块与被测模块:

// dependencyModule.js function getSecretNumber() { return 44; } module.exports = { getSecretNumber, };
// moduleUnderTest.js const dependencyModule = require("./dependencyModule"); function getTheSecret() { return `The secret was: ${dependencyModule.getSecretNumber()}`; } module.exports = { getTheSecret, };

注意被测模块是通过dependencyModule.getSecretNumber()访问的,而非解构。测试如下:

// test.js const assert = require("assert"); const sinon = require("sinon"); const dependencyModule = require("./dependencyModule"); const { getTheSecret } = require("./moduleUnderTest"); describe("moduleUnderTest", function () { describe("when the secret is 3", function () { it("should be returned with a string prefix", function () { sinon.stub(dependencyModule, "getSecretNumber").returns(3); const result = getTheSecret(); assert.equal(result, "The secret was: 3"); }); }); });

4.3 异步依赖的复杂示例

当依赖返回 Promise 时,在测试方法上加async关键字、用await调用被测方法即可。被测链路由一个分页拉取用户列表的 API 模块和聚合逻辑模块组成:

// userApi.js const axios = require("axios"); async function getPageOfUsers(page) { const result = await axios({ method: "GET", url: `https://reqres.in/api/users?page=${page}`, }); return result.data; } module.exports = { getPageOfUsers };
// userUtils.js const userApi = require("./userApi"); async function getAllUsers() { const users = []; let page = 0, usersPage = null; do { page += 1; usersPage = await userApi.getPageOfUsers(page); users.push(...usersPage.data); } while (usersPage.total_pages > page); return users; } module.exports = { getAllUsers };

测试用sinon.stub(userApi, "getPageOfUsers")替换整个方法,并在afterEach中restore():

// UserUtils-test.js const assert = require("assert"); const sinon = require("sinon"); const userUtils = require("./userUtils"); const userApi = require("./userApi"); function aUser(id) { return { id, email: `someemail@user${id}.com`, first_name: `firstName${id}`, last_name: `lastName${id}`, avatar: `https://www.somepage${id}.com`, }; } describe("userUtils", function () { let getPageOfUsersStub; beforeEach(function () { getPageOfUsersStub = sinon.stub(userApi, "getPageOfUsers"); }); afterEach(function () { getPageOfUsersStub.restore(); }); describe("when a single page of users exists", function () { it("should return users from that page", async function () { const pageOfUsers = { page: 1, total_pages: 1, data: [aUser(1), aUser(2), aUser(3)], }; getPageOfUsersStub.returns(Promise.resolve(pageOfUsers)); const result = await userUtils.getAllUsers(); assert.equal(result.length, 3); assert.equal(getPageOfUsersStub.calledOnce, true); }); }); describe("when multiple pages of users exists", function () { it("should return a combined list of all users", async function () { const pageOfUsers1 = { page: 1, total_pages: 2, data: [aUser(1), aUser(2), aUser(3)], }; const pageOfUsers2 = { page: 2, total_pages: 2, data: [aUser(4), aUser(5)], }; getPageOfUsersStub.withArgs(1).returns(Promise.resolve(pageOfUsers1)); getPageOfUsersStub.withArgs(2).returns(Promise.resolve(pageOfUsers2)); const result = await userUtils.getAllUsers(); assert.equal(result.length, 5); assert.equal(getPageOfUsersStub.callCount, 2); }); }); });

此处展示了 Sinon 桩的两项实用能力:withArgs(...)按参数分别配置返回值,calledOnce/callCount验证调用次数。单页场景下getAllUsers只请求一次;两页场景下根据total_pages循环两次,聚合出 5 个用户。

五、桩掉 ES Module 导入:让命名空间可变

5.1 问题根源:ESM 绑定是 live 且不可变的

ES Modules 被静态分析,其绑定按 ECMAScript 规范是live 且 immutable的——命名空间对象的属性不可写、不可配置、不可删除(docs/guides/how-to/stub-esm.md)。因此直接对 ES 模块的具名导出执行sinon.stub会抛出:

TypeError: ES Modules cannot be stubbed

复现路径如下。源模块与消费者:

// 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", () => { // 这里会抛错:TypeError: ES Modules cannot be stubbed sinon.stub(mathModule, "add").returns(99); }); });

Sinon 正确地在此报错——这正是 ESM 规范所要求的属性不可变性。

5.2 解决方案:esm包的mutableNamespace选项

esm包是面向 Node.js 的 ES 模块加载器,其mutableNamespace选项能让模块命名空间对象变得可写,这正是 Sinon 安装桩所需的条件。操作步骤:

Step 1:安装依赖

npm install --save-dev esm

Step 2:创建加载器/初始化文件(放在项目根目录,如esm-loader.cjs)

// 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:正常编写测试

// 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); }); });

5.3 完整项目布局

. ├── 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、src/math.mjs、src/calculator.mjs以及上面的测试文件,即构成完整可运行示例。测试还可以验证"未桩时调用真实实现"的行为,形成对照:

it("should call the real add function when not stubbed", () => { const result = calculate(3, 4); assert.equal(result, 7); });

5.4 为什么能生效,以及注意事项

esm包 hook 进 Node.js 的模块加载系统。当mutableNamespace: true开启时,它用Proxy包装 ES 模块命名空间对象,允许属性赋值;Sinon 的stub()正是替换命名空间对象上的属性,有了 Proxy 后赋值成功而不再抛错。

局限性需牢记:

  • 仅对esm包有效:原生--experimental-vm-modules或其他 loader 默认不支持mutableNamespace;
  • 转译产物无需此方案:如果 TypeScript/Babel 已把 ESM 编译成 CommonJS,直接走 Stub a dependency 方案;
  • 解构导入无法被桩:若被测模块import { add } from './math.mjs'且以局部绑定使用add,命名空间上的桩不会影响已捕获的绑定——消费者必须通过模块命名空间对象访问导出,桩才会生效;
  • mutableNamespace是非标准行为:它偏离 ESM 规范,应视为测试便利手段而非生产技巧。

相关指南相互衔接:CommonJS link seams 方案 与 TypeScript + SWC 真实案例。

六、案例研究:TypeScript + SWC 下的真实世界依赖桩

6.1 场景与问题

Sinon 只做简单几件事并力求做好:创建并注入测试替身。但在构建管线、转译器与多模块系统并存的今天,"简单的事"会迅速变难。本篇案例(docs/guides/how-to/typescript-swc.md)的选型是:Mocha 驱动测试 + SWC(Rust 实现的高性能转译器)转译 TypeScript,目标模块系统为 CommonJS,目标是能在测试中用 Sinon 替身替换./other模块的导出。

初始代码:

// main.ts import { toBeMocked } from "./other"; export function main() { const out = toBeMocked(); console.log(out); }
// other.ts export function toBeMocked() { return "I am the original function"; }
// main.spec.ts import sinon from "sinon"; import "./init"; import * as Other from "./other"; import { main } from "./main"; import { expect } from "chai"; const sandbox = sinon.createSandbox(); describe("main", () => { let mocked; it("should mock", () => { mocked = sandbox.stub(Other, "toBeMocked").returns("mocked"); main(); expect(mocked.called).to.be.true; }); });

同样的代码用ts-node跑没问题,换成 SWC 后 Mocha 报错:

1) main should mock: TypeError: Descriptor for property toBeMocked is non-configurable and non-writable

6.2 定位问题:属性描述符对比

错误信息说明 Sinon 无法处理一个属性描述符近乎不可变的对象。指南建议用console.log打印Object.getOwnPropertyDescriptors(Other)来诊断:

console.log("Other", Other); console.log( "Other property descriptors", Object.getOwnPropertyDescriptors(Other), );

SWC 配置运行下的输出:

Other { toBeMocked: [Getter] } Other property descriptors { __esModule: { value: true, writable: false, enumerable: false, configurable: false }, toBeMocked: { get: [Function: get], set: undefined, enumerable: true, configurable: false } }

ts-node配置运行下的输出:

Other { toBeMocked: [Function: toBeMocked] } Other property descriptors { __esModule: { value: true, writable: false, enumerable: false, configurable: false }, toBeMocked: { value: [Function: toBeMocked], writable: true, enumerable: true, configurable: true } }

关键差异:ts-node下toBeMocked是可写的简单 value;SWC 下它是不可配置的 getter。Getter 本身对 Sinon 不是问题(有很多替换手段),但configurable: false会让 Sinon 束手无策。结论:SWC 把import * as Other from './other'转译成"导出经由不可变访问器暴露"的对象。由此得到三条解决路线:

  1. 重配 SWC,让测试产物变成可写 value 或可配置 getter;
  2. 使用纯依赖注入,从内部打开./other.ts;
  3. 干预模块加载方式,注入额外的requirehook。

6.3 方案一:修改转译器输出

安装 SWC 插件swc_mut_cjs_exports,在.swcrc的jsc键下加入:

"experimental": { "plugins": [[ "swc_mut_cjs_exports", {} ]] },

此后属性描述符变为可配置。由于 getter 与 value 不同,测试代码需改用replaceGetter配合 fake 替换 getter:

const stub = sandbox.fake.returns("mocked"); sandbox.replaceGetter(Other, "toBeMocked", () => stub);

6.4 方案二:纯依赖注入

版本 1:全手动模式。此技术不依赖语言、模块系统、打包器与工具链,但需要轻微改动被测系统,且 Sinon 不会自动恢复状态:

// other.ts function _toBeMocked() { return "I am the original function"; } export let toBeMocked = _toBeMocked; export function _setToBeMocked(mockImplementation) { toBeMocked = mockImplementation; }
// main.spec.ts describe("main", () => { let mocked; let original = Other.toBeMocked; after(() => Other._setToBeMocked(original)); it("should mock", () => { mocked = sandbox.stub().returns("mocked"); Other._setToBeMocked(mocked); main(); expect(mocked.called).to.be.true; }); });

版本 2:借助 Sinon 的自动清理。Sinon 16.1 起获得了"赋值并恢复由访问器(accessor)定义的属性"的能力:只要对外暴露带 setter/getter 的对象,Sinon 就能替你善后:

// other.ts function _toBeMocked() { return "I am the original function"; } export let toBeMocked = _toBeMocked; export const myMocks = { set toBeMocked(mockImplementation) { toBeMocked = mockImplementation; }, get toBeMocked() { return _toBeMocked; }, };
// main.spec.ts describe("main", () => { after(() => sandbox.restore()); it("should mock", () => { mocked = sandbox.fake.returns("mocked"); sandbox.replace.usingAccessor(Other.myMocks, 'toBeMocked', mocked); main(); expect(mocked.called).to.be.true; }); });

从当前仓库源码可以印证这些 API 的存在与分工:在 src/sinon/sandbox.js 中,sandbox.replace处理普通属性替换,sandbox.replace.usingAccessor(第 394 行附近)面向访问器定义的值,sandbox.replaceGetter/sandbox.replaceSetter则分别针对 getter/setter 属性(第 446、494 行附近),并且对非 getter 属性调用replaceGetter会明确抛错(第 42 行)。这套 API 正是本方案"自动恢复原值"能力的基础。

6.5 方案三:Hook 进 Node 模块加载

这正是 Link seams(CommonJS)指南 的主题,这里把 proxyquire 换成 Quibble——它更简洁,还支持作为 ESM loader 使用。效果如下:

describe("main module", () => { let mocked, main; before(() => { mocked = sandbox.stub().returns("mocked"); quibble("./other", { toBeMocked: mocked }); ({ main } = require("./main")); }); it("should mock", () => { main(); expect(mocked.called).to.be.true; }); });

6.6 案例启示

同一目标存在多条路径:改转译输出、纯依赖注入、hook 模块加载,各有取舍。关键在于先理解转译产物与属性描述符的实际情况,再挑选适合自己的组合——这套方法论对其他工具链组合同样适用。

七、总结:如何选择正确的隔离策略

回顾五篇指南,可以提炼出一张决策简表:

场景推荐方案关键前提
被测代码依赖定时器 / Promise 延时fake timers +tick()记得restore();断言注意 Promise 的then()异步语义
Node 环境,模块走 CommonJSproxyquire/Quibble 等 link seams 工具在beforeEach中注入桩,按测试需要配置返回值
简单依赖替换,无复杂工具链sinon.stub(dep, "method")直接改导出方法不能被解构,需在测试中显式 import 依赖
被测代码为原生 ESMesm包 +mutableNamespace消费者需经命名空间对象访问导出;非标准手段
TypeScript + SWC 等转译链改转译输出 / 依赖注入 / require hook先用getOwnPropertyDescriptors摸清产物属性描述符

Sinon 始终聚焦"创建并注入测试替身"这一件事;当模块系统与构建管线带来复杂性时,官方给出的统一心法是:弄清最终产物的真实形态,再从 link seams、依赖注入与模块加载 hook 三条路线中选择可行者。更多 API 细节可继续查阅 docs/concepts 下的分类文档,以及 docs/tests/docs 中与本文示例一一对应的可运行测试。

  • 测试
  • 开发工具

【免费下载链接】sinon

Test spies, stubs and mocks for JavaScript.

项目地址:https://gitcode.com/gh_mirrors/si/sinon
点击查看免费下载
上一篇:智慧职教刷课脚本技术解析:多平台自动化学习解决方案设计与实现
下一篇:如何在Windows资源管理器中快速识别APK文件:终极图标显示解决方案

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询