DEBUG.WIKI
搬瓦工;先看线路,再选 VPS;CN2 GIA · 对应线路套餐;查看线路 →
野草云;香港 200Mbps 峰值;不限月流量 · BGP VPS;查看套餐 →
RackNerd;$21.99/年 · 1GB KVM

Vitest spyOn 报 Cannot redefine property,改用模块级 spy

Vitest 在 Node 中 spyOn 外部模块也可能报 ESM namespace 不可配置。用模块级 spy 保留实现,并区分执行器包装、原生加载和同文件内部调用。

测试在 Node 中运行,vi.spyOn() 却报 Module namespace is not configurable in ESM,并指向 Browser Mode 文档。这不一定是测试环境选错了。只要拿到的是原生 ESM 模块命名空间,其导出属性就不能像普通可配置对象一样被重新定义。

Vitest 4.0.17 有这样一条具体表现:对通过原生模块路径加载的 lodash 命名空间监听 after,会同时出现 Cannot spy on export "after" 和 Cannot redefine property: after。浏览器使用原生 ESM 时也有同样限制,不能仅凭错误链接判断它只发生在浏览器。

import * as 得到的对象不一定能改

下面展示失败形态,前提是项目已经安装了 lodash:

import { vi } from 'vitest';
import * as lodash from 'lodash';

vi.spyOn(lodash, 'after');

spyOn 需要接管对象上的属性。Vitest 的模块执行器可以为经它处理的模块提供可替换包装,但外部依赖可能由原生加载器处理。运行环境名为 Node,并不保证所有导入都走了可修改的包装。

若错误底层是属性不可重新定义,增加 as any 只会改变 TypeScript 检查,不能让运行时描述符变成可配置。复制一个命名空间对象再监听副本,也不会自动让被测代码改为调用这个副本。

在导入前登记模块级 spy

需要保留原实现并记录调用时,可让 Vitest 在模块加载阶段建立 spy。下面用一个自有模块说明完整用法,避免把 lodash 不同入口的导出形态混在示例里:

// answer.ts
export function answer() {
  return 42;
}
// answer.test.ts
import { expect, test, vi } from 'vitest';
import { answer } from './answer';

vi.mock('./answer', { spy: true });

test('保留实现并记录调用', () => {
  expect(answer()).toBe(42);
  expect(answer).toHaveBeenCalledOnce();
});

这里 vi.mock 会参与 Vitest 的提升转换,在导入执行前登记模块处理;spy: true 保留函数实现。实际项目应把路径替换为被测代码使用的同一个模块入口。需要替换某个返回值时,再对相应的 mocked 函数设置行为,不能假定登记 spy 已经替你提供了假的业务数据。

若坚持在 Node 模块执行器中使用 spyOn,可以检查是否应将特定外部依赖纳入 test.server.deps.inline。这会改变该依赖的处理路径,只适用于对应执行模式,不能让 Browser Mode 的原生命名空间变得可修改。与其全量内联所有依赖,通常更适合先选择明确的模块 mock。

调用发生的位置仍决定能否记录

模块级 spy 也不会改写同一文件里直接引用局部函数的所有内部调用。若导出的 outer() 直接调用本文件的 answer(),对导出建立监听不等于替换那个词法引用。需要通过模块边界或已有依赖注入接口组织测试,不能只看导出对象上已有 spy 就认为所有调用都被接管。

还要核对 setup 文件是否提前导入过模块,以及业务调用发生在登记之前还是之后。普通 spyOn 无法追溯已经执行的调用。

后续针对这条错误的文档链接修正,只是把指向浏览器的提示改成通用模块 mock 文档,并未取消 ESM 的属性限制。验收应检查真实被测入口调用了原实现、调用次数符合预期,以及测试之间没有因残留 mock 状态互相影响;不能只以错误消息变了作为修复完成。