
vitest 中将 `vi.spyon()` 提前声明在 `describe` 外会导致失效,根本原因在于 `mockreset`、`restoremocks`、`clearmocks` 和 `threads: false` 等配置会干扰全局 spy 的生命周期管理;正确做法是移除这些冲突配置,并始终在 `it` 内创建 spy。
在从 Jest 迁移到 Vitest 的过程中,一个常见且易被忽视的陷阱是:vi.spyOn() 不能安全地定义在测试用例(it/test)作用域之外——例如放在 describe 顶层或模块级。你遇到的现象(外部声明的 spy 始终不被调用、断言失败)并非 bug,而是 Vitest 模块隔离机制与特定测试配置共同作用的结果。
? 根本原因分析
Vitest 默认启用模块级隔离(module mocking),并在每个测试用例前后自动执行 mock 状态重置逻辑。当你启用以下配置时:
test: {
mockReset: true, // 每个测试前调用 vi.resetModules()
restoreMocks: true, // 每个测试后恢复所有 mock 的原始实现
clearMocks: true, // 每个测试后清空所有 mock 调用记录(包括 spy)
threads: false, // 禁用多线程 → 强制串行执行,但加剧 mock 状态污染风险
}这些选项会在每个 it 执行前后主动清理或重置所有已安装的 mock/spy。而你在 describe 外创建的 notificationSpy 属于“模块级 spy”,其引用在测试生命周期中被反复重置或销毁,导致后续 expect(notificationSpy).toHaveBeenCalledOnce(...) 实际检查的是一个已被清空(甚至重建)的 spy 实例 —— 因此永远无法捕获到调用。
✅ 正确行为:spy 应与测试用例强绑定,即在 it 内创建、使用、断言,确保其生命周期完全受当前测试控制。
✅ 推荐解决方案
1. 移除冲突配置(最直接有效)
根据你的验证结果,只需从 vite.config.ts 的 test 配置中删除以下四项:
test: {
// ❌ 删除以下四行(Vitest v1.3+ 默认行为已足够稳健)
// mockReset: true,
// restoreMocks: true,
// clearMocks: true,
// threads: false,
globals: true,
environment: 'jsdom',
setupFiles: './vitest.setup.ts',
include: ['**/*.{test,spec}.{ts,tsx,js,jsx}'],
exclude: [...configDefaults.exclude, 'plop', './vitest.setup.ts'],
deps: { inline: ['vitest-canvas-mock'] },
testTimeout: 10000,
alias: tsconfigPaths(),
css: true,
}✅ 移除后,Vitest 将采用更轻量、更符合直觉的默认 mock 行为:仅在 vi.mock() 显式调用时隔离模块,vi.spyOn() 则保持稳定,允许在 describe 中复用(但仍强烈建议在 it 内声明以保证可维护性)。
2. 最佳实践:始终在 it 内创建 spy(推荐)
即使配置已修正,也应坚持如下写法:
describe('PostboxList', () => {
const renderComponent = (store: Store) => {
render( , { store });
};
it('shows notification when fetching status is HasError', async () => {
// ✅ 正确:spy 生命周期与测试完全对齐
const notificationSpy = vi.spyOn(NotificationActions, 'addNotification');
const store = mockStore({
postbox: {
documents: { data: [], fetchingStatus: DataFetchingStatus.HasError },
messages: { data: [], fetchingStatus: DataFetchingStatus.HasError },
},
});
renderComponent(store);
expect(notificationSpy).toHaveBeenCalledOnce({
title: 'POSTBOX.ERROR.TITLE',
text: 'POSTBOX.ERROR.TEXT',
});
// ✅ 可选:显式恢复(增强健壮性,尤其在 `restoreMocks: false` 时)
notificationSpy.mockRestore();
});
});⚠️ 注意事项
- 不要依赖 beforeEach 创建跨测试的 spy:它仍可能被 clearMocks 清空;
- 若必须复用 spy 逻辑(如多个测试需监听同一方法),可封装为工厂函数:
const createNotificationSpy = () => vi.spyOn(NotificationActions, 'addNotification');
- vi.restoreAllMocks() 应仅在 afterAll 或 teardown 中调用,避免干扰单个测试;
- 启用 --run 模式(单次执行)可帮助排查是否为并发状态污染问题。
✅ 总结
| 场景 | 是否推荐 | 说明 |
|---|---|---|
| vi.spyOn() 在 it 内声明 | ✅ 强烈推荐 | 生命周期可控,兼容所有配置,语义清晰 |
| vi.spyOn() 在 describe 外声明 | ❌ 不推荐 | 易受 clearMocks/restoreMocks 干扰,迁移期高风险 |
| 启用 clearMocks: true + 全局 spy | ❌ 避免 | 直接导致 spy 调用记录丢失,断言必然失败 |
遵循“spy 随测而生,随测而毁”原则,不仅能解决当前问题,更能提升测试的稳定性与可调试性。










