测试开发工具【免费下载链接】sinonTest 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 秒后 resolvemodule.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 推进模拟时间多少 msconfig.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 seamsSeam接缝概念源自经典著作Working Effectively with Legacy Code它是代码中允许你不动被测代码、从外部替换其依赖的位置。本指南的目标是定位 link seams 并用自己的桩替换依赖docs/guides/how-to/link-seams-commonjs.md。3.2 为什么 CommonJS 仍然值得关注指南明确指出尽管 ES Module 标准 2015 年就已出现但转译器与打包器至今仍可把代码输出为 CJS 模块——例如 TypeScript 到 2023 年默认输出仍是 CJSimport 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 接管返回了我们注入的、带桩方法的对象。四、直接桩掉模块依赖CommonJS4.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: someemailuser${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 esmStep 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.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、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 驱动测试 SWCRust 实现的高性能转译器转译 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-writable6.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是可写的简单 valueSWC 下它是不可配置的 getter。Getter 本身对 Sinon 不是问题有很多替换手段但configurable: false会让 Sinon 束手无策。结论SWC 把import * as Other from ./other转译成导出经由不可变访问器暴露的对象。由此得到三条解决路线重配 SWC让测试产物变成可写 value 或可配置 getter使用纯依赖注入从内部打开./other.ts干预模块加载方式注入额外的requirehook。6.3 方案一修改转译器输出安装 SWC 插件swc_mut_cjs_exports在.swcrc的jsc键下加入experimental: { plugins: [[ swc_mut_cjs_exports, {} ]] },此后属性描述符变为可配置。由于 getter 与 value 不同测试代码需改用replaceGetter配合 fake 替换 getterconst 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 seamsCommonJS指南 的主题这里把 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 中与本文示例一一对应的可运行测试。赞分享测试开发工具【免费下载链接】sinonTest spies, stubs and mocks for JavaScript.项目地址https://gitcode.com/gh_mirrors/si/sinon点击查看免费下载相关推荐Sinon.JS 实战指南使用 Fake Timers 高效测试异步函数Sinon.JS 实战指南使用 Fake Timers 高效测试异步函数 测试依赖定时器的代码往往耗时且不稳定一个 setTimeout fn, 1000测试开发工具yq tag 运算符实战读取与设置节点类型标签!!str、!!int、!!boolyq tag 运算符实战读取与设置节点类型标签!!str、!!int、!!bool tag 是 yq 中用于读取或设置节点类型标签YAML Tag如测试开发工具Sinon.js 假时间器Fake Timers使用指南Sinon.js 假时间器Fake Timers使用指南 项目介绍 Sinon.js 的 Fake Timers 是一个强大的JavaScript库它模拟开发工具上一篇智慧职教刷课脚本技术解析多平台自动化学习解决方案设计与实现下一篇如何在Windows资源管理器中快速识别APK文件终极图标显示解决方案创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考