Mock、Spy 与测试隔离
Mock 的价值是把不属于本次测试的边界变成可控输入。它不是把所有调用替换成“通过”:格式化函数、校验器这类自己的纯逻辑应尽量真实执行;网络、系统时间、随机数和支付网关才需要稳定替身。每个替身都要回答三个问题:替换了谁、返回什么、何时恢复。
先从依赖注入开始
给依赖一个参数,通常比模块级 mock 更容易读和恢复:
// src/profile.ts
export type ProfileApi = { getProfile(id: string): Promise<{ id: string; name: string }> }
export async function greeting(id: string, api: ProfileApi) {
const profile = await api.getProfile(id)
return `你好,${profile.name}`
}// src/profile.test.ts
import { expect, it, vi } from 'vitest'
import { greeting, type ProfileApi } from './profile'
it('只替换网络边界', async () => {
const api: ProfileApi = { getProfile: vi.fn().mockResolvedValue({ id: 'u1', name: '林' }) }
await expect(greeting('u1', api)).resolves.toBe('你好,林')
expect(api.getProfile).toHaveBeenCalledWith('u1')
})这里真正运行的是 greeting 的字符串拼接;只有 HTTP 被替换。若把 greeting 本身也 mock,测试就失去了发现拼接回归的能力。
spy、mock 和清理不是一回事
vi.spyOn(obj, 'method') 保留原实现,直到你指定返回值;它适合观察自己的适配器。vi.fn() 创建没有原实现的替身,适合注入回调。vi.mock() 在模块导入图层替换导出,适合无法注入的遗留代码,但它会被提升(hoist),并且模块缓存会影响后续测试。
import { afterEach, expect, it, vi } from 'vitest'
import * as clock from './clock'
afterEach(() => {
vi.restoreAllMocks() // 恢复 spy 原实现;不会替你清除 fake timer
vi.useRealTimers()
})
it('观察格式化器且控制当前时间', () => {
const formatSpy = vi.spyOn(clock, 'formatDate')
vi.setSystemTime(new Date('2026-01-02T00:00:00Z'))
clock.formatDate(new Date('2026-01-01T00:00:00Z'))
expect(formatSpy).toHaveBeenCalledOnce()
})区分三个清理操作:clearAllMocks 清调用记录;resetAllMocks 连实现也重置;restoreAllMocks 把 spy 放回原对象。若在 afterEach 只 clearAllMocks,上一个测试留下的返回值会污染下一个测试。
模块 mock 的导入顺序
假设 report.ts 直接导入 ./transport,无法注入参数:
vi.mock('./transport', () => ({
send: vi.fn(),
}))
import { send } from './transport'
import { publishReport } from './report'
it('把服务端错误转换成业务错误', async () => {
vi.mocked(send).mockRejectedValueOnce(new Error('offline'))
await expect(publishReport({ title: '周报' })).rejects.toThrow('offline')
})vi.mock 的声明会被提升到导入前,因此不要把运行时变量直接捕获进 factory;需要每个测试有不同实现时,用 vi.doMock 后配合 vi.resetModules(),并在同一个测试中动态 await import。这比依赖导入顺序更明确,但也更慢,优先改为依赖注入。
时间和网络:把时序写成输入
假定时器只推进 setTimeout/setInterval,不会自动结算一个悬空 Promise:
import { afterEach, expect, it, vi } from 'vitest'
afterEach(() => vi.useRealTimers())
it('两秒后才提交', () => {
vi.useFakeTimers()
const submit = vi.fn()
setTimeout(submit, 2000)
vi.advanceTimersByTime(1999)
expect(submit).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(submit).toHaveBeenCalledOnce()
})网络测试可以注入 fetcher,或在应用边界用 MSW 拦截真实 fetch。拦截器应设置 onUnhandledRequest: 'error',这样新添的未声明请求会立即失败,而不是悄悄打到外网。不要把服务端响应对象直接复制进每个测试;统一 handler 后在测试中只覆盖本例需要的异常。
练习:找出污染源
- 写
retry(operation):第一次失败、第二次成功;用vi.fn().mockRejectedValueOnce(...).mockResolvedValueOnce(...)断言两次调用和返回值。 - 连续写两个测试:第一个改变
Date.now或 fake timer,第二个不应受影响。故意删掉清理钩子,观察第二个测试怎样失败,再恢复。 - 对直接导入的模块 mock 写一个测试:一个用例返回 200,另一个返回 500。要求两个用例单独执行和整文件执行结果一致;若使用
vi.doMock,说明为何动态导入必须放在 mock 之后。
下一步阅读异步、定时器与竞态。