使用 vi.when 进行条件模拟
前置条件
本配方假定你已经对 Vitest 中的 mocking 有一定了解。
当一个 mock 需要根据接收到的参数返回不同的值时,mockReturnValue 就帮不上忙了,因为它总是返回相同的值。标准做法是使用带有 switch 或一系列 if/else 语句的 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)
})这能工作,但会变得繁琐,因为你必须自己编写参数匹配逻辑。在使用 vi.when 5.0.0+ API 时,Vitest 可以帮你处理这件事。
模式
vi.when 接受一个 spy,并允许你定义与参数相关的行为。
调用 .calledWith(...args) 来声明要匹配哪些参数。这会创建一个 behavior。
然后通过调用 then* 方法附加一个 action。该 action 决定当 behavior 匹配时会发生什么。
同一个 spy 上可以链式配置多个 behavior:
import { test, vi } from 'vitest'
import { getUserById } from './user.ts'
test('返回用户数据', async () => {
const db = { findById: vi.fn<FindById>() }
vi.when(db.findById)
.calledWith(1)
.thenResolve({ id: 1, name: 'Ella' })
.calledWith(2)
.thenResolve({ id: 2, name: 'Gracie' })
await expect(getUserById(db, 1)).resolves.toEqual({ name: 'Ella' })
await expect(getUserById(db, 2)).resolves.toEqual({ name: 'Gracie' })
})这种方法适用于所有 mock 结果类型。以下是完整的 action 及其等价形式:
| Action | 等价于 | 等价代码 |
|---|---|---|
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) |
叠加 actions
一个 behavior 可以附加多个 action。当 behavior 匹配时,actions 会按 后进先出 的顺序被 consumed:最近注册的 action 先执行。一旦该 action 被消耗,Vitest 就回退到前一个。使用 times 选项可以限制一个 action 在切换到下一个 action 之前处理多少次调用。没有 times 限制的 action 会无限期运行。
由于 actions 是按注册顺序的逆序进行评估的,因此应先注册无限期的 action,这样后面有限次的 action 才能暂时覆盖它们。
import { test, vi } from 'vitest'
import { readConfig } from './config.ts'
test('在初始失败后重试', async () => {
const fetchInstance = vi.fn<() => Promise<unknown>>()
vi.when(fetchInstance)
.calledWith('/data/config.json')
.thenResolve(new Response('{ debug: true }'))
// ↳ 无限期回退
.thenReject(new Error('network error'), { times: 1 })
// ↳ 先应用,并在一次调用后被消耗
await expect(readConfig(fetchInstance)).resolves.toEqual({ debug: true })
expect(fetchInstance).toHaveBeenCalledTimes(2)
})为方便起见,还提供了 then*Once 速记形式,它们等同于 { times: 1 }:thenReturnOnce、thenResolveOnce、thenThrowOnce、thenRejectOnce。
非对称匹配器
calledWith 支持 asymmetric matchers。当你关心参数的形状或类型而不是其精确值时,这很有用:
test('向每个收件人发送邮件', () => {
vi.when(sendEmail)
.calledWith(expect.stringContaining('@'))
.thenReturn({ ok: true, message: 'sent via external relay' })
})与 actions 不同,behaviors 按 先进先出 的顺序匹配。参数匹配调用的第一个 behavior 会获胜,就像一串 if/else 语句一样。因此,更具体的匹配器必须先注册,再注册范围更广的匹配器。
test('向每个收件人发送邮件', () => {
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 会按注册顺序检查已有的 behaviors。如果新的参数已经匹配到某个现有 behavior,则新的 action 会合并进该 behavior,而不是创建一个新的。
这对于范围很广的非对称匹配器尤其重要:
vi.when(getRole)
.calledWith(expect.any(String))
.thenReturn('user')
.calledWith('admin@example.com')
.thenReturnOnce('admin')因为第二次注册会合并到已有的 behavior 中,所以 'admin' action 并不会仅作用于 'admin@example.com'。相反,它会成为整个 expect.any(String) behavior 的下一个 action。最终得到的 behavior 表现得就像下面这样:
vi.when(getRole)
.calledWith(expect.any(String))
.thenReturn('user')
.thenReturnOnce('admin')因此,第一次传入任意字符串时会返回 'admin',而后续调用会返回 'user':
expect(getRole('user@example.com')).toBe('admin')
expect(getRole('user@example.com')).toBe('user')处理未匹配的调用
默认情况下,当 spy 使用的参数没有匹配到任何已注册的 behavior 时,它会回退到 spy 的原始实现。如果 spy 没有原始实现,则返回 undefined。
有三种不同的处理方式:
onUnmatched: 'throw'
传入 { onUnmatched: 'throw' },当 spy 使用未注册的参数被调用时就抛出错误:
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]',
)错误消息中包含未匹配的参数。错误类型和消息是固定的,不能自定义。
onUnmatched: fn
传入一个函数来处理未匹配的调用,并应用自定义逻辑,例如当共享 mock 需要针对每个测试提供不同的回退值时。
const db = { findById: vi.fn<FindById>() }
test('为未知 id 返回占位符', 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' })
})该函数会以与 spy 相同的参数被调用,并且其返回值会直接作为 spy 的结果使用。如果它抛出错误或返回 rejected promise,则该错误会像任何 action 一样传播给调用方。
将非对称匹配器作为兜底
最后注册一个宽泛的 calledWith 会作为不匹配任何更早、更具体 behavior 的调用的回退。这个兜底 behavior 可以返回特定值、resolve 或 reject 一个 promise,或者抛出一个类型化错误。
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'))断言所有 behaviors 都已被调用
要检查所有已注册的 behaviors 是否都已实际匹配且其 actions 已被消耗,可以使用 vi.when 返回对象支持的 toHaveBeenExhausted 断言:
test('加载两个用户', 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),测试会失败,并显示从未被匹配到的 behaviors 列表:
AssertionError: expected all behaviors to have been exhausted, but some remain:
calledWith(2)
✗ thenReturn({ id: 2, name: 'Gracie' }) never called注意事项
没有 behaviors 的 vi.when 链永远不会被视为已耗尽。没有附加任何 then* action 的单独 .calledWith() 也是如此。两者都会始终导致 toHaveBeenExhausted 失败。
无限期 actions(没有 times 限制)在至少使用一次后即可满足耗尽检查。之后它们仍会继续响应,但断言会通过。
使用 using 自动清理
vi.when 支持 显式资源管理 协议。
使用 using 声明该链,以将 behaviors 限定在当前块内,并在执行离开时自动恢复 spy。
const spy = vi.fn(() => 'original')
test('带有 mock 行为', () => {
using w = vi.when(spy).calledWith('hello').thenReturn('mocked')
expect(spy('hello')).toBe('mocked')
}) // ← 此处恢复
test('没有 mock 行为', () => {
expect(spy('hello')).toBe('original')
})