Vitest 自定义断言 Matcher 完全指南:使用 expect.extend 扩展你的断言能力
2026/9/14 6:01:14 网站建设 项目流程

Vitest 自定义断言 Matcher 完全指南:使用 expect.extend 扩展你的断言能力

【免费下载链接】vitestNext generation testing framework powered by Vite.项目地址: https://gitcode.com/GitHub_Trending/vi/vitest

本篇技术指南围绕 Vitest 的expect.extendAPI,系统讲解如何在测试中自定义断言 Matcher:从最基础的实现与注册、TypeScript 类型声明与增强、同步/异步 Matcher 的返回值协议,到this上下文中的全部状态属性与源码级实现原理。读完本文,你将能够为团队沉淀一套类型安全、可复用、支持.resolves/.rejects/expect.poll/expect.soft的自定义断言库,并理解其与 Chai 插件体系的关系。

由于 Vitest 同时兼容 Chai 与 Jest 两套断言生态,你可以自由选择chai.use插件 API 或expect.extend来扩展断言——两者底层共用同一套 Chai 插件机制。本文聚焦expect.extend这条路径,它对从 Jest 迁移而来的团队尤其友好。

快速上手:第一个自定义 Matcher

调用expect.extend并传入一个包含自定义 Matcher 的对象即可扩展默认断言。每个 Matcher 是一个普通函数,接收的第一个参数是expect(...)中的接收值(received),其余参数是调用时传入的参数:

expect.extend({ toBeFoo(received) { const { isNot } = this return { // 不要根据 isNot 手动翻转 pass,Vitest 会自动处理 pass: received === 'foo', message: () => `${received} is${isNot ? ' not' : ''} foo` } } }) // 使用 expect('foo').toBeFoo() expect('bar').not.toBeFoo()

关键约定:

  • passtrue表示断言通过;当与.not组合时,Vitest 会自动反转最终结果,Matcher 内部无需感知isNot
  • message惰性求值的箭头函数,只在断言失败时被调用,用于生成报错信息;
  • 若断言失败,message()的返回值会作为错误信息抛出,并可携带actual/expected让 Vitest 渲染 diff。

底层原理:expect.extend 如何生效

expect.extend并不是独立于 Chai 的旁路实现,而是直接包装为 Chai 插件。在 packages/expect/src/jest-extend.ts 中可以看到,JestExtend插件通过utils.addMethod(chai.expect, 'extend', ...)注册extend方法,内部再调用use(JestExtendPlugin(chai, expect, expects))把自定义 Matcher 注入 Chai 的Assertion.prototype

JestExtendPlugin对每个 Matcher 做了三件事:

  1. 包装为__VITEST_EXTEND_ASSERTION__,调用时先经getMatcherState组装出this上下文,再以expectAssertion.call(state, obj, ...args)调用你的 Matcher;
  2. 通过utils.addMethod同时挂载到JEST_MATCHERS_OBJECT.matchersc.Assertion.prototype,保证expect(received).toBeFoo()可用;
  3. 额外把 Matcher 注册为**非对称匹配器(asymmetric matcher)**挂到expect.toBeFoo/expect.not.toBeFoo上(见 jest-extend.ts),所以expect.extend一次声明,expect.extendexpect().*expect.*三处同时生效——这也正是官方文档"Extending theMatchersinterface will add a type toexpect.extend,expect().*, andexpect.*methods at the same time"这句话的实现来源。

此外,源码还通过wrapAssertion(见 packages/expect/src/utils.ts)让自定义 Matcher 自动支持expect.soft模式,失败时不再中断当前测试。

TypeScript 类型声明:让自定义 Matcher 类型安全

使用 TypeScript 时,需要在一个**环境声明文件(ambient declaration file)**中扩展vitest模块的Matchers接口,例如vitest.d.ts

import 'vitest' declare module 'vitest' { interface Matchers<R, T> { toBeFoo: () => R } }

说明:

  • R是断言的返回类型,T是接收值的类型;
  • 同步Matcher 返回R:普通断言下R解析为void,当断言与.resolves.rejectsexpect.pollexpect.element组合使用时自动变为Promise<void>
  • 当期望参数应与接收值同类型时,使用T,例如toEqualTyped: (expected: T) => R

::: tip 必须import 'vitest',否则 TypeScript 不会把该文件当作模块处理,declare module增强将不生效。 :::

::: warning 别忘记把环境声明文件加入tsconfig.jsoninclude列表,否则类型提示不会加载。 :::

从源码看,Matchers接口定义于 packages/expect/src/types.ts:interface Matchers<R extends void | Promise<void> = void | Promise<void>, T = unknown> {}。它是一个开放的空接口,正是为了让你通过declare module合并声明;其类型参数命名必须与扩展时保持一致。ExpectStaticJestAssertion都继承了Matchers,因此扩展一处即全局生效。而这些类型也通过 packages/vitest/src/public/index.ts 从vitest入口重新导出,供使用方导入。

Matcher 返回值协议:SyncMatcherResult 与 MatcherResult

Matcher 的返回值必须兼容以下结构:

interface SyncMatcherResult { pass: boolean message: () => string // 如果传入以下字段,失败时会自动出现在 diff 中, // 无需你在 message 里手动打印 diff actual?: unknown expected?: unknown meta?: object } type MatcherResult = SyncMatcherResult | Promise<SyncMatcherResult>

实战要点:

  • actual/expected强烈建议返回:当断言失败时,Vitest 会把二者渲染成漂亮的 diff 输出,而不是一长串JSON.stringify。在 jest-extend.ts 中,失败时构造的JestExtendError会携带actualexpected以及assertionNamemeta,供报告器与 IDE 展示;
  • meta是 Vitest 4.1 起支持的附加元数据对象,可携带任意结构化信息;
  • 类型层面的等价定义是SyncExpectationResultExpectationResult(见 types.ts),RawMatcherFn规定 Matcher 函数签名为(this: T, received: any, ...expected: E): ExpectationResult

异步 Matcher:返回 Promise 并正确 await

如果 Matcher 实现是异步的(例如需要查询数据库、读取文件),返回值需要是Promise<SyncMatcherResult>,类型声明为Promise<void>而非R,并且在测试中显式await

expect.extend({ async toBeAsyncAssertion(received) { return { pass: received === 'foo', message: () => `expected ${received} to be foo`, } } }) declare module 'vitest' { interface Matchers<R, T> { toBeAsyncAssertion: () => Promise<void> } } await expect('foo').toBeAsyncAssertion()

从源码看,JestExtendPlugin会检测返回结果是否为 thenable(typeof (result as any).then === 'function'),若是则走thenable.then(...)的异步分支处理失败抛错(见 jest-extend.ts)。因此异步 Matcher 与同步 Matcher 的失败处理路径完全一致,只是多了 Promise 的等待。

4.1+ 官方导出的 Matcher 类型

自 Vitest 4.1 起,官方从vitest直接导出了编写自定义 Matcher 所需的类型,无需再从chai@jest/expect-utils寻找:

import type { // 函数类型 Matcher, // 返回值 MatcherResult, // 以 this 暴露的状态 MatcherState, } from 'vitest' import { expect } from 'vitest' // 简单 Matcher,用 function 声明以便访问 this const customMatcher: Matcher = function (received) { // ... } // 带参数的 Matcher const customMatcher: Matcher<MatcherState, [arg1: unknown, arg2: unknown]> = function (received, arg1, arg2) { // ... } // 带自定义注解、显式 this 的 Matcher function customMatcher(this: MatcherState, received: unknown, arg1: unknown, arg2: unknown): MatcherResult { // ... return { pass: false, message: () => 'something went wrong!', } } expect.extend({ customMatcher })

需要说明:Matcher是 RawMatcherFn 的别名,MatcherResultExpectationResult的别名,SyncMatcherResultSyncExpectationResult的别名——导出路径见 packages/vitest/src/public/index.ts,三者与上述返回值协议一一对应。

::: tip 如果要构建自定义快照 Matcher(对toMatchSnapshot()/toMatchInlineSnapshot()/toMatchFileSnapshot()的包装),请使用vitest导出的Snapshots,详见 Custom Snapshot Matchers。仓库中 test/e2e/snapshots/custom-matcher.test.ts 提供了完整的自定义快照 Matcher 示例。 :::

this 上下文:MatcherState 全部属性详解

Matcher 函数体内可以通过this访问当前断言状态。下表为官方文档列出的核心属性,其余状态值由 Vitest 内部使用。对应实现见 getMatcherState 与 MatcherState 接口。

isNot

当以.not调用(expect(received).not.toBeFoo())时为true无需在 Matcher 内自行处理,Vitest 会自动反转pass的最终结果。

promise

当 Matcher 被resolved/rejected修饰符调用时,值为对应修饰符名称(如'resolved'),否则为空字符串''

equals

内部用于几乎所有内置 Matcher 的深度比较工具函数。返回true/false表示两个值是否相等,默认支持嵌套的非对称匹配器(如expect.any(...)expect.objectContaining(...))。其类型签名为(a, b, customTesters?, strictCheck?) => boolean

utils

一组用于格式化与输出断言消息的工具函数,例如打印颜色、缩进、构造expect(...).toBe...风格的提示文本等。源码中该集合由getMatcherUtils()展开并追加了diffstringifyiterableEqualitysubsetEquality(见 jest-extend.ts),便于你在message()中复刻 Vitest 原生的 diff 输出。

currentTestName

当前测试的完整名称(包含 describe 块的嵌套名称)。在源码中来自task?.fullTestName,从 packages/expect/src/state.ts 的getState机制注入。

task(4.1.0+)

当可用时,包含对 Test runner task 的引用,可据此访问当前测试任务的元信息。

::: warning 在并发测试中若使用全局expectthis.taskundefined。此时应改用测试上下文中的context.expect,确保自定义 Matcher 里能拿到task。 :::

这一限制在仓库测试中有直接体现:test/e2e/test/expect-task.test.ts 覆盖了全局expectcontext.expect以及并发场景下task的可用性差异。

testPath

当前测试文件路径。

environment

当前environment的名称,例如'jsdom''node''happy-dom'等。

soft

断言是否以soft形式调用。同样无需在 Matcher 中自行处理——Vitest 总会捕获软断言错误,不会中断后续用例。这由 wrapAssertion 在注册阶段统一包装实现。

assertion(5.0.0+)

底层 Chai assertion),在getMatcherState中通过assertion: assertion as any注入。

运行时代码示例:一个完整的自定义 Matcher

将本文内容组合成一个可直接运行在仓库测试环境中的完整示例(参考 test/e2e/test/expect-extend.test.ts 的组织方式):

import { expect, test, type MatcherState } from 'vitest' expect.extend({ // 自定义:校验 received 是偶数 toBeEven(this: MatcherState, received: number) { return { pass: received % 2 === 0, message: () => `expected ${received} to be an even number`, actual: received, expected: 'an even number', } }, }) test('even matcher works', () => { expect(42).toBeEven() expect(41).not.toBeEven() })

配套的vitest.d.ts环境声明:

import 'vitest' declare module 'vitest' { interface Matchers<R> { toBeEven: () => R } }

小结

  • expect.extendchai.use底层同源,通过 JestExtend 插件 注册,一次声明同时支持expect.extendexpect().*expect.*(非对称匹配器)三种用法;
  • Matcher 返回值遵循SyncMatcherResult/MatcherResult协议,actualexpectedmeta字段会在失败时自动渲染 diff;异步 Matcher 记得返回 Promise 并在测试中await
  • 类型扩展通过declare module 'vitest'合并开放的 Matchers 接口 完成,RT分别对应断言返回类型与接收值类型;
  • this上下文(MatcherState)提供了isNotpromiseequalsutilscurrentTestNametasktestPathenvironmentsoftassertion等状态,其中task在并发测试的全局expect下不可用,应改用context.expect
  • 构建快照类自定义 Matcher 时使用vitest导出的Snapshots,参考 Custom Snapshot Matchers。

至此,你已经掌握了从"写一个简单 Matcher"到"类型安全地构建异步、软断言、非对称匹配的自定义断言库"的完整链路,可以开始为你的项目沉淀专属断言方言了。

【免费下载链接】vitestNext generation testing framework powered by Vite.项目地址: https://gitcode.com/GitHub_Trending/vi/vitest

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

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

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

立即咨询