Mock、Spy 与测试隔离:练习答案
重试实现与有限次 mock
ts
export async function retry<T>(operation: () => Promise<T>, attempts = 2) {
let lastError: unknown
for (let i = 0; i < attempts; i++) {
try { return await operation() }
catch (error) { lastError = error }
}
throw lastError
}ts
const operation = vi.fn()
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce('ok')
await expect(retry(operation)).resolves.toBe('ok')
expect(operation).toHaveBeenCalledTimes(2)mockRejectedValueOnce 只影响下一次调用,正好表达“第一次失败、第二次成功”;用永久 mockRejectedValue 会让重试永远失败。
清理时间
ts
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
vi.clearAllMocks()
})useRealTimers 恢复宿主时钟,restoreAllMocks 恢复 spy 的原方法,clearAllMocks 清调用记录。若配置中打开 restoreMocks: true,仍需在使用 fake timer 的测试后恢复真实 timer;mock 配置不会代替时间清理。
模块替换的隔离
如果模块可改,优先把 send 作为参数注入。必须替换模块时:
ts
it('每个导入看到自己的 mock', async () => {
vi.resetModules()
vi.doMock('./transport', () => ({ send: vi.fn().mockResolvedValue({ status: 500 }) }))
const { publishReport } = await import('./report')
await expect(publishReport({ title: '周报' })).rejects.toThrow()
vi.doUnmock('./transport')
})doMock 不提升,动态 import 发生在 mock 之后;resetModules 清模块缓存,否则前一个用例导入的 report 仍持有旧依赖。若使用静态 import,mock 发生太晚,整文件执行时就会与单测执行顺序产生差异。
可运行核对:先只运行重试测试,再运行整个文件;两次都应得到相同调用次数。故意删除 afterEach 或 resetModules 后,必须能看到污染而不是仍然“通过”。