在 TypeScript 与 Jest 配合使用的项目里,有一个非常典型的报错场景:你用 jest.mock 替换了某个模块,然后在测试里调用 mockRejectedValue 或 mockReturnValue,TypeScript 却提示该函数上不存在这些属性。问题的表层是类型不兼容,底层则是两条执行轨道的错位——Jest 在运行时把模块替换成 Mock 函数,而 TypeScript 在编译期仍然按照原始模块声明做检查。要想彻底解决,需要先理解这两条轨道各自维护的类型信息,再选择适合项目的桥接方式。

一、冲突的根源:运行时替换与编译期类型检查的错位
Jest 的模块 Mock 机制本质上是在 Node.js 的模块缓存层做替换。当测试文件顶部执行 jest.mock('./userService') 时,Jest 会拦截对 userService 模块的 require 调用,返回一个由 jest.fn() 构造出来的 Mock 版本。原始模块中导出的 fetchUser 函数签名是 (id: number) => Promise<User>,但运行时它已经被替换成了 jest.fn() 对象,这个对象额外携带了 mockReturnValue、mockResolvedValue、mockImplementation 等方法。
TypeScript 并不执行这些运行时代码,它只读取 userService.ts 的声明信息。因此在编译阶段,fetchUser 仍然被看作一个普通函数,不具备 Jest Mock 对象的方法。当你写下 fetchUser.mockResolvedValue(...) 时,TypeScript 会报出 TS2339:类型 (id: number) => Promise<User> 上不存在属性 mockResolvedValue。这种错位还会带来更隐蔽的问题:即使你通过类型断言强行把 fetchUser 转成 jest.Mock,也会丢失原始参数和返回值类型,导致后续测试代码失去参数检查能力。
很多团队的第一反应是使用 as any 或者 as unknown as jest.Mock。这些写法虽然能消除编译错误,但本质上是把类型安全交给了开发者自觉。对于一个规模稍大的测试套件来说,一旦 Mock 函数的参数类型发生变化,类型系统无法给出任何提示,维护成本会迅速上升。因此我们需要找到一种既能保留原始签名,又能获得 Jest Mock 方法的类型桥接方案。
二、用 jest.mocked 和 jest.MockedFunction 精准还原 Mock 类型
在现代版本的 Jest 和 @types/jest 中,jest.mocked() 是一个专门用来解决这一冲突的辅助函数。它接收一个普通函数或对象作为参数,返回带有 Mock 方法且保留原始类型的版本。假设 userService.ts 导出了 fetchUser 函数,测试代码可以这样写:
import { fetchUser } from './userService';
jest.mock('./userService');
const mockFetchUser = jest.mocked(fetchUser);
test('should fetch user', async () => {
mockFetchUser.mockResolvedValue({ id: 1, name: 'Alice' });
await expect(fetchUser(1)).resolves.toEqual({ id: 1, name: 'Alice' });
expect(mockFetchUser).toHaveBeenCalledWith(1);
});
这里 jest.mocked(fetchUser) 的返回类型是 jest.MockedFunction<typeof fetchUser>。它一方面保留了 fetchUser 的参数和返回值类型,另一方面附加了 mockResolvedValue、mockRejectedValue 等 Jest Mock 方法。这样当 User 接口的字段发生变化时,测试中的 mock 返回值依然会触发类型检查,而不是被 as any 掩盖。
除了单个函数,jest.mocked() 还可以作用于整个模块对象。如果 userService 同时导出了 fetchUser 和 updateUser,可以导入命名空间后整体转换:
import * as userService from './userService';
jest.mock('./userService');
const mockedUserService = jest.mocked(userService);
test('should update user', async () => {
mockedUserService.updateUser.mockResolvedValue({ id: 2, name: 'Bob' });
await expect(userService.updateUser(2, { name: 'Bob' }))
.resolves.toEqual({ id: 2, name: 'Bob' });
});
这种写法把所有导出函数都转换成对应的 MockedFunction,并且保持了命名空间的访问方式,是处理多导出模块的推荐做法。需要注意的是,jest.mocked() 的使用要求 @types/jest 版本至少为 27.0.0,并且 Jest 本身也需要在较新版本上。如果你的项目还在使用旧版类型定义,可以考虑显式断言:const mockFetchUser = fetchUser as jest.MockedFunction<typeof fetchUser>,效果类似,但每次都要写完整泛型,略显繁琐。
三、手动 Mock 文件与类型声明文件的配合
当 Mock 逻辑需要在多个测试文件中复用时,通常会创建 __mocks__/userService.ts 文件。Jest 在遇到 jest.mock('./userService') 时会自动使用该文件,但 TypeScript 默认仍然按照原始 userService.ts 做类型检查。于是又会出现同样的类型冲突:运行时已经是 jest.fn(),类型上却还是普通函数。
一个直接的解决办法是在手动 Mock 文件里显式声明导出类型。例如:
// __mocks__/userService.ts
import type { User } from '../userService';
export const fetchUser = jest.fn<Promise<User>, [number]>();
export const updateUser = jest.fn<Promise<User>, [number, Partial<User>]>();
这里通过 jest.fn 的泛型参数指定了返回值和参数类型。测试文件导入时,TypeScript 会读取手动 Mock 文件中的 jest.fn<...> 类型,因此 fetchUser.mockResolvedValue 可以正常使用,并且参数类型也得到保留。这种方式的优点是类型信息与 Mock 实现放在了一起,不需要在测试文件中反复做断言。
如果项目不想手动维护泛型参数,也可以利用 jest.requireActual 获取真实模块类型,再配合类型转换。例如在测试文件中:
import { fetchUser } from './userService';
jest.mock('./userService', () => {
const actual = jest.requireActual('./userService');
return {
...actual,
fetchUser: jest.fn(),
};
});
const mockFetchUser = fetchUser as jest.MockedFunction<typeof fetchUser>;
这种部分 Mock 方案保留了模块中其他真实实现,只替换目标函数,适合只 Mock 一部分行为的场景。不过它仍然需要一次显式断言,而且在模块结构变化时容易遗漏。相比之下,直接依赖 jest.mocked() 会少一些样板代码。
四、避免 any 的治理策略与最佳实践
在实际项目中,Mock 类型冲突往往伴随着大量历史代码。很多开发者为了快速通过编译,会写出 (fetchUser as any).mockResolvedValue(...) 这样的代码。短期内似乎没有问题,但一旦 User 接口新增必填字段,或者函数参数签名发生调整,类型系统无法在 Mock 调用处给出任何反馈。测试依然可能通过,但生产代码已经与测试假设产生偏差,这正是类型安全被破坏的代价。
更稳妥的替代方案是使用 as unknown as jest.MockedFunction<typeof fetchUser>。它不会像 as any 那样完全关闭类型检查,而是在明确承认「我知道这个转换不完全是类型兼容的」的前提下,仍然保留目标类型的约束。不过这只是一种折中,优先级仍然低于 jest.mocked()。在团队规范中,建议将 jest.mocked() 作为默认选择,仅在处理第三方模块或无法使用新版本类型定义时才允许显式断言。
对于模块级 Mock,推荐使用 jest.Mocked<typeof import('./userService')> 这样的类型表达式。它在语义上表示「整个模块的所有导出都被 Jest Mock 化了」,比逐个函数断言更清晰。例如:
import * as userService from './userService';
jest.mock('./userService');
const mockedUserService = userService as jest.Mocked<typeof userService>;
这种写法同样保留了 userService 模块的所有导出类型,并为每个函数附加了 Mock 方法。如果后续模块新增了 deleteUser 函数,类型转换会自动覆盖它,测试代码只需正常使用 mockedUserService.deleteUser.mockRejectedValue(...) 即可。
最后还要注意 ts-jest 与类型定义版本的匹配问题。旧版 @types/jest 可能没有 jest.mocked 或 jest.MockedFunction 的完整定义,导致这类方案无法使用。建议在项目中固定 Jest 和 @types/jest 的版本,并定期升级。对于使用 ESM 的项目,还需要确保 jest.mock 在模块导入之前执行,因为 ESM 的静态导入提升会破坏 Jest 的替换时机。总体而言,只要理解类型系统和运行时的边界,选择统一的桥接工具,TypeScript 与 Jest 的 Mock 类型冲突完全可以被控制在一个很小的范围内。
TypeScriptJestMock类型修改时间:2026-09-28 03:01:52