news 2026/9/15 13:18:21

如何用 Vitest 的 vi.when 按不同参数让 mock 函数返回不同结果

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
如何用 Vitest 的 vi.when 按不同参数让 mock 函数返回不同结果

如何用 Vitest 的 vi.when 按不同参数让 mock 函数返回不同结果

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

当一个 spy 需要针对不同的调用参数返回不同结果时,mockReturnValue帮不上忙,因为它对所有调用返回同一个值。旧的做法是用mockImplementation手写参数判断:

db.findById.mockImplementation((id) => { if (id === 1) { return Promise.resolve({ id: 1, name: 'Ella' }) } if (id === 2) { return Promise.resolve({ id: 2, name: 'Gracie' }) } return Promise.resolve(undefined) })

参数一多,这类 if/else 链会迅速变得难读。Vitest 5.0.0 起提供的vi.when把参数匹配交给框架:你只需声明“匹配什么参数、匹配后做什么”,Vitest 在匹配时自动处理参数比较。使用前提有两个:

  • Vitest 版本为 5.0.0 或更高(vi.when、配套的toHaveBeenExhausted断言均标注<Version>5.0.0</Version>);
  • 操作对象是一个 spy,即通过vi.fn()vi.spyOn创建的 mock 函数。

基本用法:calledWith 声明参数,then* 声明动作

vi.when(spy)返回一个When链对象。链上先调用.calledWith(...args)声明要匹配的参数(这创建一个behavior),再调用一个then*方法挂上action,决定匹配后 spy 的行为。参数按深度相等(deep equality)比较,并支持非对称匹配器,如expect.any()

以下示例在 Vitest 的发布说明中完整出现,可直接放进测试文件运行:

import { expect, test, vi } from 'vitest' test('returns user data', async () => { const findById = vi.fn() vi.when(findById) .calledWith(1) .thenResolve({ id: 1, name: 'Ella' }) .calledWith(2) .thenResolve({ id: 2, name: 'Gracie' }) .calledWith(expect.any(Number)) .thenReject(new Error('not found')) await expect(findById(1)).resolves.toEqual({ id: 1, name: 'Ella' }) await expect(findById(3)).rejects.toThrow('not found') })

多个 behavior 可以串在同一条链上。behavior 之间按先注册先匹配(first-in-first-out)的顺序判断:第一个参数匹配的 behavior 获胜,类似一串 if/else 语句。所以上例中expect.any(Number)必须放在最后,否则它会先匹配所有数字。

可用的 then* 动作

then*覆盖了 mock 的全部结果类型,各自等价于一个mock*方法(对照表来自 Conditional Mocking 配方):

动作等价于等价代码
thenReturn(value)mockReturnValue(value)return value
thenThrow(error)mockThrow(error)throw error
thenResolve(value)mockResolvedValue(value)return Promise.resolve(value)
thenReject(error)mockRejectedValue(error)return Promise.reject(error)

Once的简写形式(thenReturnOncethenThrowOncethenResolveOncethenRejectOnce)等价于传{ times: 1 },即该 action 只处理一次调用。

同一 behavior 上叠加多个 action

一个 behavior 可以挂多个 action。匹配命中时,action 按后注册先执行(last-in-first-out)的顺序被_消耗_:最近注册的 action 先运行,消耗完后 Vitest 回落到上一个。times选项限制一个 action 能处理多少次调用,超过后落到下一个 action;不带times的 action 无限次生效。

由于 action 按注册逆序评估,无限 action 应该先注册,这样后面注册的一次性 action 才能在有限时间内临时覆盖它。配方文档中的重试示例:

import { test, vi } from 'vitest' import { readConfig } from './config.ts' test('retries after an initial failure', async () => { const fetchInstance = vi.fn<() => Promise<unknown>>() vi.when(fetchInstance) .calledWith('/data/config.json') .thenResolve(new Response('{ debug: true }')) // ↳ indefinite fallback .thenReject(new Error('network error'), { times: 1 }) // ↳ applied first and consumed after one call await expect(readConfig(fetchInstance)).resolves.toEqual({ debug: true }) expect(fetchInstance).toHaveBeenCalledTimes(2) })

效果是:第一次调用返回被拒绝的 Promise(times: 1,被消耗一次后退出),第二次调用回落到无限的thenResolveexpect(fetchInstance).toHaveBeenCalledTimes(2)验证了“先失败、重试后成功”这一行为链。

后文配方示例(如readConfigsendEmailgetUserByIdloadDashboard)引用的是各自项目里的被测函数与类型(如FindById),实际使用时替换为你项目中的对应实现;vi.when的链式写法本身保持不变。

用非对称匹配器按参数“形状”匹配

当你关心的是参数的类型或形状而不是精确值时,calledWith支持 非对称匹配器:

test('sends email to each recipient', () => { vi.when(sendEmail) .calledWith(expect.stringContaining('@')) .thenReturn({ ok: true, message: 'sent via external relay' }) })

结合前面说的“behavior 按先注册先匹配”,具体匹配器必须注册在宽泛匹配器之前,宽泛的才能充当兜底:

test('sends email to each recipient', () => { vi.when(sendEmail) .calledWith(expect.stringContaining('@internal.example.com')) .thenReturn({ ok: true, message: 'sent via internal relay' }) .calledWith(expect.stringContaining('@')) .thenReturn({ ok: true, message: 'sent via external relay' }) })

注册顺序陷阱:behavior 合并

这里有一条容易踩坑的规则:注册新 behavior 时,Vitest 按注册顺序检查已有 behavior,如果新参数已经能匹配某个已有 behavior,新的 action 会合并进那个已有 behavior,而不是新建一个。

vi.when(getRole) .calledWith(expect.any(String)) .thenReturn('user') .calledWith('admin@example.com') .thenReturnOnce('admin')

'admin@example.com'已经匹配expect.any(String),所以第二次注册被合并进去,实际效果等价于:

vi.when(getRole) .calledWith(expect.any(String)) .thenReturn('user') .thenReturnOnce('admin')

结果是任何字符串的第一次调用都返回'admin',而不是只有'admin@example.com'命中:

expect(getRole('user@example.com')).toBe('admin') expect(getRole('user@example.com')).toBe('user')

如果你确实需要“特定参数一个临时行为、其余参数另一个兜底”,把临时行为写成该宽泛 behavior 的叠加 action(利用后注册先执行的顺序)更可靠,而不是指望用另一个具体值新建 behavior。

处理没有匹配到任何 behavior 的调用

默认情况下,spy 被未注册过的参数调用时,会回落到 spy 的原始实现;如果 spy 没有原始实现,返回undefinedvi.when(spy, options)options.onUnmatched提供三种替代方式:

1.onUnmatched: 'throw'—— 未注册参数直接抛错。错误类型和文案是固定的,不能自定义,但消息里包含未匹配的实参,便于定位:

vi.when(db.findById, { onUnmatched: 'throw' }) .calledWith(1) .thenResolve({ id: 1, name: 'Ella' }) await expect(db.findById(1)).resolves.toMatchObject({ name: 'Ella' }) await expect(db.findById(3)).rejects.toThrow( 'vi.when: no behavior defined when called with [3]', )

2. 传一个函数—— 未匹配时调用你的函数,参数与 spy 相同,返回值直接作为 spy 的结果。适合共享 mock 需要按测试不同兜底的场景;函数抛错或返回被拒绝的 Promise 时,错误会像普通 action 一样传播给调用方:

const db = { findById: vi.fn<FindById>() } test('returns a placeholder for unknown ids', async () => { vi.when( db.findById, { onUnmatched: id => Promise.resolve({ id, name: `User ${id}` }) } ) .calledWith(1) .thenResolve({ id: 1, name: 'Ella' }) await expect(db.findById(1)).resolves.toMatchObject({ name: 'Ella' }) await expect(db.findById(42)).resolves.toMatchObject({ name: 'User 42' }) })

3. 宽泛的非对称匹配器兜底—— 把最宽的calledWith放在链尾,作为“其他一切”的 fallback,它可以返回值、resolve/reject 或抛错:

vi.when(db.findById) .calledWith(1) .thenResolve({ id: 1, name: 'Ella' }) .calledWith(2) .thenResolve({ id: 2, name: 'Gracie' }) .calledWith(expect.any(Number)) .thenReject(new Error('user not found'))

用 toHaveBeenExhausted 验证每个行为都被调用过

要确认“注册的所有 behavior 都被实际匹配到、action 被消耗”,把vi.when返回的对象传给toHaveBeenExhausted(同样自 Vitest 5.0.0 可用,见 expect API):

test('loads both users', async () => { const db = { findById: vi.fn<FindById>() } const w = vi.when(db.findById) .calledWith(1) .thenResolveOnce({ id: 1, name: 'Ella' }) .calledWith(2) .thenResolveOnce({ id: 2, name: 'Gracie' }) await loadDashboard(db) expect(w).toHaveBeenExhausted() })

如果loadDashboard只调用了findById(1),测试失败,错误消息会列出从未匹配到的 behavior(文档示例输出):

AssertionError: expected all behaviors to have been exhausted, but some remain: calledWith(2) ✗ thenReturn({ id: 2, name: 'Gracie' }) never called

两条使用边界:没有任何 behavior 的vi.when链永远不被视为 exhausted,裸.calledWith()而没有then*也一样,都会让断言失败;无限 action(不带times)被调用过一次即满足 exhausted 条件,之后仍可继续响应。

用 using 自动恢复 spy

vi.when支持 Explicit Resource Management 协议。用using声明这条链,behavior 的作用域就被限制在当前块内,离开块时自动恢复 spy 的原始实现(需要运行环境支持该协议,否则仍用const声明):

const spy = vi.fn(() => 'original') test('with mocked behavior', () => { using w = vi.when(spy).calledWith('hello').thenReturn('mocked') expect(spy('hello')).toBe('mocked') }) // ← restored here test('without mocked behavior', () => { expect(spy('hello')).toBe('original') })

小结

  • 同一参数的多个返回值用vi.when(spy).calledWith(args).then*()链声明,替代mockImplementation里的手写 if/else;
  • behavior 按先注册先匹配,因此具体的放在宽泛的前面;action 按后注册先消耗,因此无限兜底先注册、times有限的临时行为后注册;
  • 未匹配调用默认回落到原始实现,onUnmatched: 'throw'onUnmatched: fn可改成抛错或自定义兜底;
  • expect(w).toHaveBeenExhausted()用于验证所有注册行为都被真正触发,失败时消息会列出未匹配的行为;
  • using声明可在测试块结束时自动还原 spy,避免污染后续用例。

这些能力均要求 Vitest 5.0.0+。完整示例见 Conditional Mocking 配方,API 细节见vi.whentoHaveBeenExhausted。如果你只需要判断某个值是不是When链(例如在工具函数里收窄类型),可以用配套的vi.isWhenChain

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

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

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

ArcGIS等高线生成与地形图拼图:从DEM到规范出图全流程指南

从接触ArcGIS到现在&#xff0c;我大部分时间都在跟地形图打交道。很多刚入行的朋友拿到高程点或者DEM&#xff0c;第一反应是打开ArcToolbox找等值线工具&#xff0c;点一下生成完事。结果出来的等高线要么锯齿感明显&#xff0c;要么穿出研究区边界老远&#xff0c;更别说后续…

作者头像 李华
网站建设 2026/9/15 13:12:17

支付宝H5支付唤起全链路解析:从选型到真机测试

先说个真实场景。我们上线H5商城的第二周&#xff0c;客服转来一条用户反馈&#xff1a;手机点支付&#xff0c;等了半天没反应&#xff0c;又跳回了订单页。起初我以为是极端个例&#xff0c;结果群里产品经理甩来一张截图&#xff0c;三个用户同时说支付点不动。那一刻我意识…

作者头像 李华
网站建设 2026/9/15 13:11:15

Unity AssetBundle入门:手动打包与加载实战,避免资源冗余

Unity AssetBundle 入门&#xff1a;别再把资源全塞进包里了&#xff0c;一分钟学会手动打包AB很多Unity开发者&#xff0c;尤其是做单机或者小体量项目的朋友&#xff0c;最初接触资源管理时&#xff0c;多半是直接往Resources文件夹里一丢&#xff0c;或者干脆用Scene引用就完…

作者头像 李华
网站建设 2026/9/15 13:10:35

单片机按键控制蜂鸣器:GPIO配置与消抖实现全解析

简介&#xff1a;面向单片机初学者和嵌入式爱好者的Keil入门实验&#xff0c;演示如何用按键输入控制蜂鸣器发声&#xff0c;覆盖GPIO输入输出配置、中断系统响应、C语言硬件编程等核心知识点&#xff0c;是理解单片机最小系统与交互控制的典型综合小项目。压缩包共7个文件&…

作者头像 李华