Jest 是目前前端领域最主流的单元测试框架之一,而 mock 机制则是它的核心能力。不过很多团队在使用 jest.fn() 时都踩过一个坑:一个没有设置任何实现的模拟函数被调用时,不会报错,也不会警告,只会安静地返回 undefined。这种静默失败会把真正的逻辑缺陷一路掩盖到运行时深处,排查起来非常费劲。本文就来聊聊如何让未实现的 mock 函数默认抛出错误,从源头堵住这个测试漏洞。

为什么默认行为是个隐患
先看一个典型的例子。假设我们有一个订单服务,依赖一个支付网关接口:
// paymentGateway.js
export const paymentGateway = {
charge(amount, cardInfo) {
// 真实的支付逻辑
}
};
// orderService.js
import { paymentGateway } from './paymentGateway';
export function createOrder(order) {
const result = paymentGateway.charge(order.amount, order.card);
return { orderId: order.id, status: result.status };
}在测试里,我们通常会这样写:
jest.mock('./paymentGateway', () => ({
paymentGateway: {
charge: jest.fn() // 没有设置实现
}
}));
test('创建订单应返回支付状态', () => {
const order = createOrder({ id: '1', amount: 100, card: {} });
expect(order.status).toBe('success');
});这段测试几乎必然失败,但失败信息往往令人困惑:你会看到 Received: undefined,而不是"支付网关没有被正确模拟"这样的提示。更糟糕的情况是被测代码对返回值做了容错处理,比如 result?.status ?? 'pending',此时测试可能意外通过,你也就彻底失去了对支付逻辑的验证能力。
问题的根源在于 jest.fn() 的默认实现是 () => undefined。这个设计在简单场景下很方便,但在接口依赖复杂的项目里,它等于给所有"忘记配置的模拟"发了一张通行证。一旦模块有十几个方法,漏掉一两个 mock 实现是大概率事件。
方案一:封装抛错的 mock 工具函数
最直接的思路是在创建 mock 时就注入一个会抛错的默认实现。我们可以封装一个工具函数:
// testUtils.js
export function unimplementedFn(name = 'unimplemented mock') {
return jest.fn(() => {
throw new Error(
`调用了未实现的 mock 函数: ${name}。` +
`请在测试中通过 mockImplementation 提供真实行为。`
);
});
}使用时把 jest.fn() 替换掉即可:
import { unimplementedFn } from './testUtils';
jest.mock('./paymentGateway', () => ({
paymentGateway: {
charge: unimplementedFn('paymentGateway.charge')
}
}));
test('创建订单应返回支付状态', () => {
const paymentGateway = require('./paymentGateway').paymentGateway;
paymentGateway.charge.mockImplementation(() => ({ status: 'success' }));
const order = createOrder({ id: '1', amount: 100, card: {} });
expect(order.status).toBe('success');
});这样做的最大好处是错误信息非常明确。如果哪个测试忘记配置实现,Jest 会直接抛出带函数名的错误,一眼就能定位到遗漏的位置。而且这个工具函数完全由团队自己掌控,可以按需扩展,比如在抛错前打印调用参数,方便补实现。
它的局限也很明显:需要团队约定每个人都主动使用这个工具函数,靠 code review 兜底。如果有人图省事直接写了 jest.fn(),防线就失效了。所以更彻底的做法是把规则固化下来,配合 ESLint 加一条禁用规则:
// .eslintrc.js 片段
module.exports = {
rules: {
'no-restricted-properties': [
'error',
{
object: 'jest',
property: 'fn',
message: '请使用 unimplementedFn 代替 jest.fn,确保未实现的 mock 会抛错'
}
]
]
};方案二:全局自动化转换 jest.fn
如果项目已经存在大量测试代码,逐个改写成本太高,可以通过 Jest 的全局配置统一处理。思路是在 setup 文件里重写全局的 jest.fn,让它默认带上抛错实现:
// jest.setup.js
const originalFn = jest.fn.bind(jest);
jest.fn = (implementation) => {
if (implementation !== undefined) {
// 调用方显式提供了实现,直接透传
return originalFn(implementation);
}
return originalFn(() => {
throw new Error('调用了未设置实现的 jest.fn,请检查该 mock 是否需要 mockImplementation');
});
};
// jest.config.js
module.exports = {
setupFilesAfterEach: ['./jest.setup.js'],
setupFilesAfterEnv: ['./jest.setup.js']
};注意这段代码要放在 setupFiles 或 setupFilesAfterEnv 中加载,确保它在任何测试文件执行前生效。这个方案的优点是零改动覆盖存量代码,新写的测试也自动获得保护,不依赖个人自觉。
但它也有需要权衡的地方:全局改写框架行为属于侵入式手段,当引入第三方测试工具库(比如某些依赖 jest.fn 默认行为的辅助库)时可能出现兼容问题。另外,有些测试确实需要"调用后返回 undefined"的宽松 mock,此时需要显式传入实现 jest.fn(() => undefined) 来恢复旧行为。建议在团队内先小范围试点,确认没有兼容性问题后再全量推广。
TypeScript 项目的额外保障
如果是 TypeScript 项目,还可以借助类型系统提前发现问题。开启 noImplicitReturns 后,任何声明了返回类型的函数都必须显式返回值。更进一步,可以为 mock 定义严格的类型约束:
interface PaymentGateway {
charge(amount: number, card: CardInfo): Promise<ChargeResult>;
refund(transactionId: string): Promise<RefundResult>;
}
function createStrictMock<T>(name: string): T {
return new Proxy({} as T, {
get(_target, prop) {
throw new Error(`调用了未实现的 mock 方法: ${name}.${String(prop)}`);
}
});
}
// 使用:属性访问即抛错,且带完整类型提示
const gatewayMock = createStrictMock<PaymentGateway>('paymentGateway');
gatewayMock.charge(100, card); // 直接抛出未实现错误Proxy 方案的巧妙之处在于不需要逐个列出接口方法,任何未被显式覆盖的属性访问都会立刻抛错,接口新增方法时也不用同步维护 mock 清单。配合 jest.spyOn 或 Object.assign 把需要的方法逐个补上实现,就能做到"默认严格、按需放开"。
最后提醒一点:无论选择哪种方案,抛错信息里带上模块名和方法名是关键细节。一个清晰错误信息的价值远超几十行的排查时间,这也是测试健壮性最实际的体现。让每一次未实现的调用都大声失败,比让它安静地返回 undefined 要可靠得多。