Vitest 中 spyOn 必须在测试作用域内声明的原因与配置冲突详解

9次阅读

Vitest 中 spyOn 必须在测试作用域内声明的原因与配置冲突详解

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 随测而生,随测而毁”原则,不仅能解决当前问题,更能提升测试的稳定性与可调试性。

text=ZqhQzanResources