如何用 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的简写形式(thenReturnOnce、thenThrowOnce、thenResolveOnce、thenRejectOnce)等价于传{ 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,被消耗一次后退出),第二次调用回落到无限的thenResolve。expect(fetchInstance).toHaveBeenCalledTimes(2)验证了“先失败、重试后成功”这一行为链。
后文配方示例(如
readConfig、sendEmail、getUserById、loadDashboard)引用的是各自项目里的被测函数与类型(如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 没有原始实现,返回undefined。vi.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.when与toHaveBeenExhausted。如果你只需要判断某个值是不是When链(例如在工具函数里收窄类型),可以用配套的vi.isWhenChain。
【免费下载链接】vitestNext generation testing framework powered by Vite.项目地址: https://gitcode.com/GitHub_Trending/vi/vitest
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考