news 2026/9/24 14:06:25

sinon.assert.alwaysCalledWithMatch 详解:验证 fake/spy/stub 每次调用参数全部匹配

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
sinon.assert.alwaysCalledWithMatch 详解:验证 fake/spy/stub 每次调用参数全部匹配

sinon.assert.alwaysCalledWithMatch 详解:验证 fake/spy/stub 每次调用参数全部匹配

【免费下载链接】sinonTest spies, stubs and mocks for JavaScript.项目地址: https://gitcode.com/gh_mirrors/si/sinon

sinon.assert.alwaysCalledWithMatch(spy, arg1, arg2, ...)是 Sinon.JS 内置断言之一,用于验证fakespystub每一次调用参数都能与给定的期望值(支持部分匹配与sinon.match匹配器)相匹配。本文以 docs/concepts/assertions/api/always-called-with-match.md 为主线,结合源码、测试与相关断言 API,帮助你掌握该断言的语义、用法、底层实现以及它与alwaysCalledWith等兄弟断言的区别,并在测试框架中正确落地使用。

一、断言签名与语义

1. 函数签名

sinon.assert.alwaysCalledWithMatch(spy, arg1, arg2, ...)
  • spy:被验证的fakespystub对象;
  • arg1, arg2, ...:期望参数列表。每个参数既可以是普通值(做深度部分匹配),也可以是sinon.match匹配器(做条件匹配)。

2. 核心语义

该断言通过(不抛错)当且仅当目标 fake/spy/stub 的每一次调用都满足以下两个条件:

  1. 该次调用的实际参数数量不少于期望参数数量(允许实际调用多传参数,多出的部分被忽略);
  2. 期望参数列表中的每一个参数都能与对应的实际参数匹配

只要有一次调用的参数不满足匹配要求,断言即失败并抛出AssertError

从文档的等价描述可以更精确地理解它的行为:

This behaves the same way assinon.assert.alwaysCalledWith(spy, sinon.match(arg1), sinon.match(arg2), ...)

也就是说,alwaysCalledWithMatch本质上是把每个期望参数先包装成sinon.match(...)匹配器,再调用alwaysCalledWith逐次验证。这也是它与alwaysCalledWith(严格深度相等)的核心差异所在。

3. 与calledWithMatch的区别

alwaysCalledWithMatchcalledWithMatch的差别仅在"是否要求所有调用都匹配":

  • calledWithMatch只要存在一次调用匹配即通过(matchAny);
  • alwaysCalledWithMatch所有调用都必须匹配。

类似的配对关系也存在于calledWith/alwaysCalledWithcalledWithExactly/alwaysCalledWithExactly等断言中,详见 Assertions API 索引。

二、文档示例:用 object 期望做部分匹配

官方文档给出了一个非常典型的实战场景:用一个"部分对象"作为期望值,验证每次调用传入的对象都包含指定字段。

import * as sinon from "sinon"; const fake = sinon.fake(); const applePieExpectation = { name: "apple pie" }; fake({ name: "apple pie", price: 123 }); // Matches, generates no error sinon.assert.alwaysCalledWithMatch(fake, applePieExpectation); fake({ name: "cherry pie", price: 123 }); sinon.assert.alwaysCalledWithMatch(fake, applePieExpectation); //=> Uncaught Error [AssertError]: expected fake to always be called with match //=> Call 1: //=> { name: 'apple pie', price: 123 } { name: 'apple pie' } //=> Call 2: //=> { name: 'cherry pie', price: 123 } { name: 'apple pie' }

解读:

  • 第一次调用传入{ name: "apple pie", price: 123 },期望对象{ name: "apple pie" }是其子集,因此匹配成功
  • 第二次调用传入{ name: "cherry pie", price: 123 }name字段值不同,匹配失败
  • 由于"所有调用都必须匹配",断言整体失败,抛出AssertError,错误信息中逐行列出每次调用的实际参数与期望参数,便于快速定位是第几次调用出的问题。

这个错误信息格式由 src/sinon/assert.js 中注册的断言消息模板决定:

mirrorPropAsAssertion( "alwaysCalledWithMatch", "expected %n to always be called with match %D", );

其中%n会被替换为 fake 的名称,%D会展开为参数详情列表。

三、结合sinon.match匹配器使用

alwaysCalledWithMatch最有价值的用法是与sinon.match提供的类型/条件匹配器组合,对"只关心关键字段、不关心其余细节"的场景做精确断言。官方配套测试 docs/tests/docs/assertions/api/always-called-with-match.test.js 展示了这一用法:

import tap from "tap"; import * as sinon from "sinon"; tap.test("assert.alwaysCalledWithMatch - passes when all calls match", (t) => { const fake = sinon.fake(); fake({ name: "Alice", age: 30 }); fake({ name: "Bob", age: 40 }); t.doesNotThrow(() => { sinon.assert.alwaysCalledWithMatch(fake, { age: sinon.match.number }); }, "assertion should pass when all calls match"); t.end(); }); tap.test( "assert.alwaysCalledWithMatch - fails when one call doesn't match", (t) => { const fake = sinon.fake(); fake({ name: "Alice" }); fake({ name: "Bob", age: 40 }); t.throws( () => sinon.assert.alwaysCalledWithMatch(fake, { age: sinon.match.number }), /expected fake to always be called with match/, "assertion should fail when not all calls match" ); t.end(); } );

这个测试用例揭示了两个实用点:

  1. 部分对象 + 匹配器嵌套:期望值{ age: sinon.match.number }表示"实际参数必须是对象,且age字段是数字"。只要每次调用都满足该条件,断言就通过,完全不用关心name等其它字段。
  2. 失败判据:一旦某次调用缺少age字段(如{ name: "Alice" }),断言失败,错误信息匹配/expected fake to always be called with match/

sinon.match内置了大量匹配器,例如sinon.match.numbersinon.match.stringsinon.match.objectsinon.match.anysinon.match.has("key", value)等,完整列表见 Matchers API。这些匹配器都可直接作为alwaysCalledWithMatch的期望参数使用。

四、底层实现原理

1. 断言入口:mirrorPropAsAssertion模板

sinon.assert.alwaysCalledWithMatch并非手写逻辑,而是通过mirrorPropAsAssertion工厂函数从 fake 的alwaysCalledWithMatch属性自动生成的。见 src/sinon/assert.js:

function mirrorPropAsAssertion(name, method, message) { assert[name] = function (fake) { verifyIsStub(fake); const args = arraySlice(arguments, 1); let failed = false; ... failed = typeof fake[meth] === "function" ? !fake[meth].apply(fake, args) : !fake[meth]; if (failed) { failAssertion( this, (fake.printf || fake.proxy.printf).apply( fake, concat([msg], args), ), ); } else { assert.pass(name); } }; }

调用链为:

  1. verifyIsStub(fake)先校验传入对象确实是一个 fake/spy/stub(否则直接assert.fail);
  2. 调用fake.alwaysCalledWithMatch(...),若返回false则通过failAssertion抛出AssertError,否则走assert.pass

2. proxy 层:delegateToCalls委派

在 src/sinon/proxy.js 中,alwaysCalledWithMatch通过delegateToCalls委派到每个调用的calledWithMatch

delegateToCalls(proxyApi, "calledWithMatch", true); delegateToCalls(proxyApi, "alwaysCalledWith", false, "calledWith"); delegateToCalls(proxyApi, "alwaysCalledWithMatch", false, "calledWithMatch");

其中第二个参数matchAny是关键:

  • calledWithMatchtrue(任一调用匹配即通过);
  • alwaysCalledWithMatchfalse(必须全部调用匹配)。

delegateToCalls的实现见 src/sinon/proxy-call-util.js:

proxy[method] = function () { if (!this.called) { ... return false; } ... for (let i = 0, l = this.callCount; i < l; i += 1) { currentCall = this.getCall(i); const returnValue = currentCall[actual || method].apply( currentCall, arguments, ); ... if (returnValue) { matches += 1; if (matchAny) { return true; } } } ... return matches === this.callCount; };

可以看到,对于alwaysCalledWithMatchmatchAny === false),实现会遍历 fake 的全部历史调用,逐次用calledWithMatch验证,只有当匹配次数等于总调用次数时才返回true。从源码结构看,这一逐调用聚合逻辑正是 "always" 语义的来源。

3. 单次调用匹配:proxy-call.calledWithMatch

真正执行"单次调用是否匹配"的是 src/sinon/proxy-call.js 中的calledWithMatch

calledWithMatch: function calledWithMatch() { const self = this; const calledWithMatchArgs = slice(arguments); if (calledWithMatchArgs.length > self.args.length) { return false; } return reduce( calledWithMatchArgs, function (prev, expectation, i) { const actual = self.args[i]; return prev && match(expectation).test(actual); }, true, ); },

关键点:

  • 参数数量下限:期望参数数量不能超过实际参数数量,否则直接返回false。也就是说alwaysCalledWithMatch允许实际调用多传参数(这与alwaysCalledWithExactly要求严格等长的语义不同);
  • 逐位匹配:对每个期望参数,调用match(expectation).test(actual)——即sinon.match匹配器对实际参数进行测试。普通值会被包装成深度部分匹配器,sinon.match对象则直接使用其test逻辑。

match()函数来自@sinonjs/samsamcreateMatcher(见 src/sinon/assert.js),其"部分对象匹配"能力正是文档示例中{ name: "apple pie" }能够匹配{ name: "apple pie", price: 123 }的根本原因。

五、在测试框架中的实战用法

1. 原生 Node.js 断言 / tap

如前文测试所示,直接使用即可:

const fake = sinon.fake(); fake({ status: 200, body: "ok" }); fake({ status: 200, body: "ok" }); sinon.assert.alwaysCalledWithMatch(fake, { status: 200 }); // 通过

2. 与assert.expose配合简化写法

如果不想每次写sinon.assert.前缀,可以用assert.expose将断言方法挂载到全局或某个对象上:

sinon.assert.expose(globalThis, { prefix: "" }); alwaysCalledWithMatch(fake, { status: 200 });

expose的完整参数(prefixincludeFail)见 expose 文档 与 src/sinon/assert.js 的实现。

3. 与 jest、mocha 等框架集成

Sinon 官方的断言体系天然适用于各类测试框架。当断言失败时抛出的是Error,且error.name = "AssertError"(见 src/sinon/assert.js),因此可以:

  • 在 Mocha/Jest 中直接用expect(() => sinon.assert.alwaysCalledWithMatch(...)).toThrow()捕获失败;
  • 配合 sinon-chai 等集成库使用更符合 Chai 风格的断言链。

关于断言与外部框架的集成策略(自定义assert.failassert.pass),参见 Assertions 概念页。

六、常见误区与最佳实践

  1. 不要与alwaysCalledWithExactly混淆alwaysCalledWithMatch允许实际调用多传参数(只校验前缀位置的期望参数),而alwaysCalledWithExactly要求参数个数与值都严格相等;
  2. "always" 意味着所有调用:即使 fake 只被调用过一次且匹配,只要后续有一次不匹配,断言整体失败。若只想验证"至少某次调用匹配",应改用calledWithMatch
  3. 优先使用部分对象期望:验证对象参数时,只写关键字段即可,避免过度耦合不相关的字段,让测试更聚焦于行为契约;
  4. 错误信息是调试利器:失败时AssertError会列出每次调用的实际/期望参数,配合sinon.assert.expose集成到测试报告,可快速定位是哪一次调用偏离了预期。

七、相关 API 速查

断言方法语义对应文档
calledWithMatch存在一次调用参数匹配即通过called-with-match
alwaysCalledWithMatch所有调用参数都匹配才通过本文
alwaysCalledWith所有调用与期望参数深度相等always-called-with
alwaysCalledWithExactly所有调用参数个数与值严格相等always-called-with-exactly
neverCalledWithMatch没有任何调用的参数匹配never-called-with-match

八、源码与测试参考

  • 断言注册与错误消息模板:src/sinon/assert.js
  • 断言通用实现mirrorPropAsAssertion:src/sinon/assert.js
  • proxy 层委派alwaysCalledWithMatch:src/sinon/proxy.js
  • 逐调用聚合逻辑delegateToCalls:src/sinon/proxy-call-util.js
  • 单次调用匹配calledWithMatch:src/sinon/proxy-call.js
  • 配套测试(含sinon.match用法):docs/tests/docs/assertions/api/always-called-with-match.test.js

九、小结

sinon.assert.alwaysCalledWithMatch是验证"多次调用的参数始终满足某类约束"的首选断言:它结合了 Sinon 的两大能力——assert系列断言提供的详细失败信息,以及sinon.match匹配器提供的灵活部分匹配。理解其"逐调用聚合 + 单调用匹配"的双层实现(delegateToCallsproxy-call.calledWithMatch),能帮助你判断它与其他alwaysCalledWith*断言的边界,写出更稳健、可维护的测试代码。

【免费下载链接】sinonTest spies, stubs and mocks for JavaScript.项目地址: https://gitcode.com/gh_mirrors/si/sinon

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

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

用 Zig 集成 PRQL 编译器:prqlc-c FFI 最小示例全解析

后端 【免费下载链接】prql PRQL is a modern language for transforming data — a simple, powerful, pipelined SQL replacement 项目地址&#xff1a; https://gitcode.com/gh_mirrors/pr/prql 点击查看 免费下载 PRQL&#xff08;Pipelined Relational Query Language&am…

作者头像 李华
网站建设 2026/9/24 14:05:20

Logistics | “Stock Days ” vs.“Inventory Coverage”

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

作者头像 李华
网站建设 2026/9/24 14:04:48

Python | 地址解析经纬度

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

作者头像 李华
网站建设 2026/9/24 14:04:12

【Dv3Admin】系统视图菜单按钮管理API文件解析

后台权限系统的发展趋势是细化到接口及操作按钮级别,以满足复杂业务下的安全与控制需求。菜单按钮权限管理模块基于 Django 与 DRF 实现,为后台平台提供了标准、细粒度的权限配置能力。 围绕 dvadmin/system/views/menu_button.py 源码,解析菜单按钮增删改查的实现方式,说…

作者头像 李华
网站建设 2026/9/24 14:04:10

Unity 引擎源码剖析:ICall 的 ABI 契约,为什么写错一个类型就会崩

开篇:一个查了三天的 bug QA 提的单子只有一行: 子弹偶尔穿过掩体,复现率约 5%,只在城区地图出现查射线检测的逻辑——没问题。 查碰撞体的配置——没问题。 查物理层级——也没问题。 最后在自己写的原生插件里,找到了这一行: extern "C" int IsBlocked(Vecto…

作者头像 李华