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