如何在 Vitest 中正确模拟模块内副作用函数

vue/vitest 项目中常遇到这类问题:vitest 无法直接 mock 同一模块内的内部函数调用(如 `parent` 调用同模块的 `child`),因其导入绑定在模块初始化时已固化;解决方案是将被测副作用函数拆分到独立模块,再通过 `vi.mock()` 精确替换。

在单元测试中,模拟(mock)副作用函数(如日志、API 调用、随机生

成等)是隔离依赖、保证测试确定性的关键。但使用 Vitest 时,一个常见误区是试图直接 vi.spyOn() 或 vi.mock() 当前模块内定义并被同模块其他函数调用的函数——这在 ES 模块环境下必然失败。原因在于:ESM 的导入导出是静态绑定且不可变的,parent() 内部对 child() 的引用指向的是模块作用域内的原始函数,而非后续通过 spyOn 创建的代理。

✅ 正确做法是遵循「依赖可注入」原则,将副作用逻辑抽离为独立模块:

// dummy-child.ts
export function child(): string {
  console.log('calling actual child');
  return 'bar';
}
// dummy-parent.ts
import { child } from './dummy-child';

export function parent(): string {
  return `foo${child()}`;
}

随后在测试中,使用 vi.mock() 在导入 dummy-parent 之前,为 dummy-child 提供模拟实现:

// dummy.test.ts
import { parent } from './dummy-parent';

// ✅ 在 import 之后、测试前 mock 依赖模块
vi.mock('./dummy-child', () => ({
  child: () => 'baz',
}));

describe('parent', () => {
  it('should return foobaz when child is mocked', () => {
    expect(parent()).toBe('foobaz'); // ✅ 通过
  });
});

⚠️ 关键注意事项:

  • vi.mock() 必须置于顶层作用域(不能在 describe 或 it 内部),且需在目标模块被 import 之后、首次使用前执行;
  • 若使用 vi.mock('./dummy-child', async (importActual) => {...}) 形式,记得 await importActual() 并显式覆盖所需导出;
  • 不要尝试 vi.spyOn(module, 'fn') 来 mock 同模块内函数调用——它只影响通过该引用调用的场景(如 module.child()),对模块内部直调无效;
  • 对于无法重构的遗留代码,可考虑使用 vi.hoisted + vi.mock 动态重写,但应视为临时方案。

总结:Vitest 的模块模拟机制基于 ESM 的静态导入图,因此「解耦副作用」是可靠 mock 的前提。将 child 提取为独立模块,不仅使测试可行,也提升了代码的可维护性与关注点分离度。