拓冰建站拓冰建站
首页 / 资讯中心 / 正文

深入解析 Jest expect 断言库:内部架构、全局状态与自定义 Matcher 编写指南

深入解析 Jest expect 断言库内部架构、全局状态与自定义 Matcher 编写指南【免费下载链接】jestDelightful JavaScript Testing.项目地址: https://gitcode.com/gh_mirrors/je/jestexpect是 Jest 生态的断言库核心一个可调用的expect(actual)函数加上一套小型静态 APIexpect.extend、expect.assertions、非对称匹配器工厂、expect.getState/setState。本文以 packages/expect/CLAUDE.md 为主体结合packages/expect包源码梳理其内部架构、全局注册表设计、六条硬性规则hard rules背后的实现依据并给出编写自定义 matcher 的完整规范。读完本文你将能理解expect的状态模型、异步分发机制与堆栈重写策略并具备编写高质量自定义匹配器的能力。expect 包是什么一个可调用的断言库从源码结构看expect包packages/expect不是一个框架而是一个纯断言库。它对外暴露一个可调用函数expect(actual)同时携带一组静态扩展点expect.extend(matchers)— 注册自定义 matcherexpect.assertions(n)/expect.hasAssertions()— 断言计数约束非对称匹配器工厂expect.any、expect.anything、expect.stringMatching等expect.getState()/expect.setState()— 读写 per-test 全局状态expect.addEqualityTesters(testers)— 扩展相等性比较。这些静态 API 的定义集中在 packages/expect/src/index.ts 中expect.extend (matchers: MatchersObject) setMatchers(matchers, false, expect); expect.addEqualityTesters customTesters addCustomEqualityTesters(customTesters); expect.assertions assertions; expect.hasAssertions hasAssertions; expect.getState getState; expect.setState setState; expect.extractExpectedAssertionsErrors extractExpectedAssertionsErrors;骨架文件一览文档明确列出了该包的承重文件load-bearing files每一份都在仓库中有对应实现文件职责packages/expect/src/index.tsexpect()入口、.resolves/.rejects管道、.not接线、JestAssertionError、expect.extend胶水packages/expect/src/jestMatchersObject.ts全局注册表在globalThis[Symbol.for($$jest-matchers-object)]上保存 matchers 状态 自定义等值 testerpackages/expect/src/asymmetricMatchers.tsAsymmetricMatcher抽象基类及全部内置工厂packages/expect/src/matchers.ts / spyMatchers.ts / toThrowMatchers.ts内置 matcher。体量较大的文件每个 matcher 都需精心格式化调用/返回值/diff 输出值得注意的架构边界大部分等值逻辑equals、iterableEquality、subsetEquality并不在expect包内而是位于兄弟包expect-utilspackages/expect-utils/src/utils.ts、packages/expect-utils/src/jasmineUtils.ts。面向用户的扩展点expect.addEqualityTesters(...)的作用是扩展equals认识的 tester 集合——这是受支持的扩展方式而不是直接修改equals本身。全局注册表与 per-test 状态expect最核心的设计是全局注册表。所有 matcher、per-test 状态和自定义等值 tester 都挂在globalThis上一个以 Symbol 为键的对象上// packages/expect/src/jestMatchersObject.ts const JEST_MATCHERS_OBJECT Symbol.for($$jest-matchers-object); const INTERNAL_MATCHER_FLAG Symbol.for($$jest-internal-matcher); if (!Object.prototype.hasOwnProperty.call(globalThis, JEST_MATCHERS_OBJECT)) { const defaultState: MatcherState { assertionCalls: 0, expectedAssertionsNumber: null, isExpectingAssertions: false, numPassingAsserts: 0, suppressedErrors: [], // 不立即抛出的错误 }; Object.defineProperty(globalThis, JEST_MATCHERS_OBJECT, { value: { customEqualityTesters: [], matchers: Object.create(null), state: defaultState, }, }); }对应实现见 packages/expect/src/jestMatchersObject.ts。这个对象包含三部分matchers— matcher 名字到 matcher 函数的映射state— 当前测试的可变状态customEqualityTesters— 用户通过expect.addEqualityTesters注册的自定义等值比较器。对外的读写接口是一组纯函数getState、setState、getMatchers、setMatchers、getCustomEqualityTesters、addCustomEqualityTesters。其中setState通过Object.assign做浅合并jestMatchersObject.ts#L47-L51addCustomEqualityTesters会先做Array.isArray类型校验再 push 进数组jestMatchersObject.ts#L132-L143。expect.getState()/expect.setState()的状态字段文档明确要求getState()返回的是同一个活的可变对象——读者能实时看到更新而不是快照。因此状态的重置职责属于 runner如jest-circus不属于expect本身。值得注意的状态字段如下字段含义assertionCalls每次 matcher 分发时递增expectedAssertionsNumber是expect.assertions(n)设置的预算isExpectingAssertionsexpect.hasAssertions()设置的标志suppressedErrorssnapshot matcher 在dontThrow模式下产生的软失败错误集合currentTestName、testPath、snapshotState由 runner 在每次测试开始时注入numPassingAsserts通过断言计数在processResult成功分支中递增见 index.ts#L349状态的消费端是 packages/expect/src/extractExpectedAssertionsErrors.ts它读取assertionCalls、expectedAssertionsNumber、isExpectingAssertions等字段比较实际断言数与预期生成格式化的错误消息Expected 2 assertions to be called but received 1 assertion call.并在返回前调用resetAssertionsLocalState重置本地状态extractExpectedAssertionsErrors.ts#L18-L25。硬性规则之一jasmineUtils.ts 是 vendored 的packages/expect-utils/src/jasmineUtils.ts 是从 Jasmine 2Pivotal Labs 2008–2016MIT 许可原样复制过来的文件。它实现了核心的equals(a, b, customTesters)算法整个expect的相等性语义都建立在这份算法之上。因此文档制定了第一条硬性规则文件顶部的 license header 必须原样保留不要重写该算法——与 Jasmineequals语义的逐 bug 兼容bug-for-bug compatibility本身就是expect公共契约的一部分。如果需要新的相等性行为正确做法是添加一个Tester自定义等值比较器而不是改动equals本身iterableEquality、subsetEquality就位于 packages/expect-utils/src/utils.ts用户侧通过expect.addEqualityTesters(...)扩展。在makeThrowingMatcher中equals会与用户自定义 tester 绑定后注入每个 matcher 的MatcherContext见下文MatcherContext 字段一节。硬性规则之二expect 与 jest/expect 必须解析到同一 realmmatcher 注册表以Symbol.for($$jest-matchers-object)为键挂在globalThis上。只要使用同一个globalThis两处require(expect)就能看到同一组 matcher。但存在一种危险场景如果jest-runtime的模块注册表中有两条路径各自重新加载了expect就会产生两个JestAssertionError构造函数导致expect.extend注册的 matcher 只扩充其中一套另一套看不到。这个问题的修复正是 PR #16130jest-runtime现在从内部模块注册表internal module registry解析expect和jest/expect从而让测试文件的 import 与框架自身的 import 共享同一状态。仓库中 packages/jest-runtime/src/index.ts#L65 的FRAMEWORK_SINGLETON_MODULES集合new Set([jest/expect, expect])正是这一机制的直接体现。这条规则给维护者的启示触碰 matcher 对象注册逻辑时不要引入模块级可变状态——它可能在跨 realm 场景下分叉。所有状态都应保存在globalThis[JEST_MATCHERS_OBJECT]上。硬性规则之三JestAssertionError 携带 matcherResult当 matcher 失败时抛出的错误是一个JestAssertionError其.matcherResult属性携带结构化结果{pass, message, actual, expected}。报告器reporter和 IDE 集成都会读取这个字段。// packages/expect/src/index.ts export class JestAssertionError extends Error { matcherResult?: OmitSyncExpectationResult, message {message: string}; }在processResult的失败分支中可以看到写入逻辑index.ts#L338-L346// 把 matcher 结果一并挂到 error 上便于自定义 reporter // 访问 actual / expected 对象例如渲染自定义视觉 diff error.matcherResult {...result, message}; if (throws) { throw error; } else { getState().suppressedErrors.push(error); }文档明确警告不要用普通Error替换抛出的值。matcherResult在下游被jest-circus/jest-jasmine2用于生成失败摘要也被jest-message-utilpackages/jest-message-util用于 diff 渲染。丢失这个字段会导致失败输出劣化。硬性规则之四非对称匹配器必须实现 asymmetricMatchAsymmetricMatcher是抽象基类packages/expect/src/asymmetricMatchers.ts#L59-L85其$$typeof标记为Symbol.for(jest.asymmetricMatcher)export abstract class AsymmetricMatcherT implements AsymmetricMatcherInterface { $$typeof Symbol.for(jest.asymmetricMatcher); constructor( protected sample: T, protected inverse false, ) {} protected getMatcherContext(): MatcherContext { return { customTesters: getCustomEqualityTesters(), dontThrow: () {}, ...getStateMatcherState(), equals, isNot: this.inverse, utils, }; } abstract asymmetricMatch(other: unknown): boolean; abstract toString(): string; getExpectedType?(): string; toAsymmetricMatcher?(): string; }子类必须实现asymmetricMatch(other)若要支持 diff 渲染还应实现toAsymmetricMatcher()/getExpectedType()。jasmineUtils中的equals算法通过检查a.$$typeof Symbol.for(jest.asymmetricMatcher)来分发到非对称匹配。内置工厂全部注册在expect命名空间上index.ts#L403-L420expect.anything anything; expect.any any; expect.not { arrayContaining: arrayNotContaining, arrayOf: notArrayOf, closeTo: notCloseTo, objectContaining: objectNotContaining, stringContaining: stringNotContaining, stringMatching: stringNotMatching, }; expect.arrayContaining arrayContaining; expect.arrayOf arrayOf; expect.closeTo closeTo; expect.objectContaining objectContaining; expect.stringContaining stringContaining; expect.stringMatching stringMatching;从 asymmetricMatchers.ts 的源码可以看到每个内置非对称匹配器的具体行为例如Any.asymmetricMatch对String/Number/Function/Boolean/BigInt/Symbol/Object/Array做了特判其余情况走other instanceof this.sampleL87-L138Anything只要求other ! nullCloseToMath.abs(sample - other) Math.pow(10, -precision) / 2且对正负无穷做了防NaN特判L346-L400ObjectContaining要求other是对象且非数组逐键用equals递归比较。expect.extend自动生成非对称变体一个容易被忽略但重要的机制expect.extend({foo})注册自定义 matcher 时会自动为它生成非对称版本expect.foo(...)。这发生在jestMatchersObject.ts的setMatchers中——对每个非内部 matcher动态创建一个继承AsymmetricMatcher的子类jestMatchersObject.ts#L76-L123class CustomMatcher extends AsymmetricMatcher[unknown, ...Arrayunknown] { constructor(inverse false, ...sample: [unknown, ...Arrayunknown]) { super(sample, inverse); } asymmetricMatch(other: unknown) { const {pass} matcher.call( this.getMatcherContext(), other, ...this.sample, ) as SyncExpectationResult; return this.inverse ? !pass : pass; } toString() { return ${this.inverse ? not. : }${key}; } override getExpectedType() { return any; } }随后分别挂到expect[key]和expect.not[key]上。这就是为什么自定义 matcher 既可以用在expect(x).foo(...)上也可以作为expect.any(...)那样的参数出现在其他 matcher 的期望值中。硬性规则之五同步与异步 matcher 分发expect(actual)返回的对象有三分支index.ts#L105-L157const expectation: any { not: {}, rejects: {not: {}}, resolves: {not: {}}, }; for (const name of Object.keys(allMatchers)) { const matcher allMatchers[name]; const promiseMatcher getPromiseMatcher(name, matcher) || matcher; expectation[name] makeThrowingMatcher(matcher, false, , actual); expectation.not[name] makeThrowingMatcher(matcher, true, , actual); expectation.resolves[name] makeResolveMatcher(name, promiseMatcher, false, actual, err); expectation.resolves.not[name] makeResolveMatcher(name, promiseMatcher, true, actual, err); expectation.rejects[name] makeRejectMatcher(name, promiseMatcher, false, actual, err); expectation.rejects.not[name] makeRejectMatcher(name, promiseMatcher, true, actual, err); }三种分支语义direct同步直接执行 matcher失败时抛错.resolves/.rejects先 await再把结果/原因喂给 matcher.not反转pass。每个 matcher 名会生成 6 个变体含resolves.not、rejects.not。堆栈锚点await 之前捕获错误makeThrowingMatcher的一个关键细节文档强调必须在任何await之前同步捕获锚点错误err new JestAssertionError()这样出错时堆栈指向expect(...)的调用点而不是内部 Promise 机制。const makeThrowingMatcher ( matcher: RawMatcherFn, isNot: boolean, promise: string, actual: any, err?: JestAssertionError, ): ThrowingMatcherFn { function throwingMatcher(...args): any { let throws true; const utils: MatcherUtils[utils] {...matcherUtils, iterableEquality, subsetEquality}; // ... const matcherContext: MatcherContext { ...getStateMatcherState(), ...matcherUtilsThing, error: err, isNot, promise, }; // ... } };同步路径中err直接复用异步路径makeResolveMatcher/makeRejectMatcher在.then回调里创建innerErr并在promise 方向错误比如resolves却收到 reject时给outerErr写入 Received promise rejected instead of resolved 之类的消息后抛出index.ts#L163-L273。此外resolves/rejects会先校验 received 是否为 promise或返回 promise 的函数否则抛出结构化错误received value must be a promise or a function returning a promise。toThrow的异步特化getPromiseMatcherindex.ts#L92-L103对toThrow做了特殊处理异步路径下toThrow由toThrowMatchers.ts的createMatcher(name, true)生成其内部用isError(received)判断 received 是否已是捕获到的错误对象从而避免对 Error 调用函数的错误toThrowMatchers.ts#L78-L111。硬性规则之六INTERNAL_MATCHER_FLAG 控制堆栈重写内置 matcher 通过setMatchers(matchers, isInternal, expect)打上Symbol.for($$jest-internal-matcher)标记。三个内置集合都以isInternal: true注册index.ts#L459-L461setMatchers(matchers, true, expect); setMatchers(spyMatchers, true, expect); setMatchers(toThrowMatchers, true, expect);标记写入方式为Object.defineProperty(matcher, INTERNAL_MATCHER_FLAG, {value: isInternal})jestMatchersObject.ts#L72-L74。jest-circus/jest-jasmine2根据该标记决定是否在错误输出中重写堆栈帧。在makeThrowingMatcher的错误处理中可以看到对应逻辑index.ts#L353-L365const handleError (error: Error) { if ( matcher[INTERNAL_MATCHER_FLAG] true !(error instanceof JestAssertionError) error.name ! PrettyFormatPluginError Error.captureStackTrace ) { // 从堆栈中移除本函数及更深层的帧 Error.captureStackTrace(error, throwingMatcher); } throw error; };也就是说内置 matcher 的错误堆栈会被裁剪把throwingMatcher及其内部帧从堆栈中抹掉让用户看到的是干净的断言失败位置而expect.extend注册的用户 matcherisInternal: false保留完整堆栈。文档特别提醒复制 matcher 函数时不要去掉这个标记。另外外部用户matcher 的调用被包在一个名为__EXTERNAL_MATCHER_TRAP__的函数里index.ts#L370-L378——注释说明这是一个陷阱专为 inline snapshot 在堆栈中捕获自定义 matcher 调用名而设。编写自定义 matcher 的完整规范文档给出 matcher 的标准签名(this: MatcherContext, received, ...args) { pass: boolean; message: () string; };expect.extend会把对象里的每个函数注册为 matcher非函数值会抛出TypeError见 jestMatchersObject.ts#L64-L70。四个容易踩坑的点message必须是 thunk惰性函数。构建消息代价昂贵成功时会被跳过getMessage只在失败时调用message()见 index.ts#L159-L161。不要在 matcher 返回时急切地格式化消息。读取this.isNot调整措辞。分发器会为.not反转pass但消息措辞仍需匹配语义方向——expect(x).not.toBe(y)失败时的消息要表达不应相等的语气。头部信息要遵循this.promise。this.promise取值是resolves | rejects | 。expect(p).resolves.toThrow(...)的失败消息不能假装是普通.toThrow。jest-matcher-utils的matcherHint在传入 option 对象时会自动处理这一点——务必使用它不要手写头部。尽早做类型检查类型不符时抛出结构化的matcherErrorMessage。最典型的范例是toThrow拒绝非函数/非 promise 的 receivedreceived value must be a functiontoThrowMatchers.ts#L100-L109。MatcherContext 字段MatcherContext定义见 packages/expect/src/types.ts#L55-L88由MatcherUtils与只读的MatcherState合并而成matcher 的this上可用的关键字段字段说明equalsjasmineUtils.equals的绑定副本已烘焙用户自定义 testercustomTesters用户注册的自定义等值 tester 数组isNot是否处于.not分支promiseresolves/rejects/utilsjest-matcher-utils的全部工具另附iterableEquality、subsetEqualitydontThrow()供 snapshot matcher 使用把抛错降级为累积错误让一个测试里报告所有 snapshot 失败而非立即中断dontThrow的机制在makeThrowingMatcher中实现dontThrow: () (throws false)而processResult中throws为假时错误被 push 进getState().suppressedErrorsindex.ts#L296-L297 与 index.ts#L343-L347。返回值的契约校验_validateResultindex.ts#L422-L438会校验 matcher 返回值必须是对象、pass必须是布尔值、message若存在必须是字符串或函数否则抛出 Unexpected return from a matcher function. 的错误。const _validateResult (result: any) { if ( typeof result ! object || typeof result.pass ! boolean || (result.message typeof result.message ! string typeof result.message ! function) ) { throw new Error( Unexpected return from a matcher function.\n Matcher functions should return an object in the following format:\n {message?: string | function, pass: boolean}\n ${matcherUtils.stringify(result)} was returned, ); } };同时成功分支会递增numPassingAsserts失败分支会递增assertionCalls并挂载matcherResult——这二者共同支撑expect.assertions(n)/expect.hasAssertions()的统计。测试基建属性测试与类型测试expect包的测试策略同样写进了文档属性测试property-based tests位于 packages/expect/src/tests/包括matchers-toContain.property.test.ts、matchers-toContainEqual.property.test.ts、matchers-toEqual.property.test.ts、matchers-toStrictEqual.property.test.ts使用fast-check的 arbitraries生成器来自 packages/expect/src/tests/arbitraries/sharedSettings.ts。这类测试随机生成大量输入验证 matcher 在边界情况下的稳定性。常规单元测试asymmetricMatchers.test.ts、extend.test.ts、customEqualityTesters.test.ts、assertionCounts.test.ts、spyMatchers.test.ts、toThrowMatchers.test.ts、stacktrace.test.ts等对应快照位于 packages/expect/src/tests/snapshots/。类型测试目录 packages/expect/typetests/。文档给出的维护约定是修改公共 matcher 或expect.extend的类型时必须同步更新__typetests__下的断言防止类型签名回归。总结理解 expect 的三个关键心智模型一切状态都在globalThis上matchers、per-test 状态、自定义 tester 都收敛在Symbol.for($$jest-matchers-object)注册表里realm 一致性问题#16130通过jest-runtime的FRAMEWORK_SINGLETON_MODULES保证expect/jest/expect单例加载解决。分发是预构建 惰性校验expect(actual)调用时一次性构建 6 个分支的函数闭包真正执行时由makeThrowingMatcher统一处理isNot反转、断言计数、matcherResult挂载、dontThrow降级与堆栈裁剪JestAssertionError在await之前创建以保证堆栈锚点。相等性语义是移植的契约jasmineUtils.equals是 vendored 的 Jasmine 2 算法扩展相等性应通过expect.addEqualityTesters添加Tester而非修改算法本身。如果需要在 Jest 生态中做二次开发自定义 matcher 插件、自定义 reporter、或深入理解断言失败输出建议直接阅读 packages/expect/src/index.ts 的分发逻辑、packages/expect/src/jestMatchersObject.ts 的注册表实现以及 packages/expect/src/asymmetricMatchers.ts 的内置非对称匹配器再配合 packages/expect/src/tests/ 中的测试用例验证行为。【免费下载链接】jestDelightful JavaScript Testing.项目地址: https://gitcode.com/gh_mirrors/je/jest创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门