- 测试
- 开发工具
【免费下载链接】ts-jest
A Jest transformer with source map support that lets you use Jest to test projects written in TypeScript.
导读
在 TypeScript + Jest(ts-jest)的测试场景中,jest.mock()一个默认导出(export default)的 ES6 类时,直接返回一个jest.fn()往往会抛出TypeError: sound_player_1.default is not a constructor。本文以 ts-jest 官方文档《Mock ES6 class》为核心,解释该错误产生的转译原理,给出 default 导出与命名导出两种场景下的正确 mock 写法,并结合仓库源码(hoist-jestAST 转换器、端到端测试)验证jest.mock的提升机制与模块模拟结构,帮助你写出稳定、可维护的单元测试。
一、问题背景:TypeScript 转译后的模块形态
TypeScript 编译器会将.ts文件转译为 JavaScript,而 ts-jest 正是通过 TypeScript 编译器 API 完成这一转译的(其核心实现在 src/transpilers/typescript/transpile-module.ts)。在常见的 CommonJS 目标下,export default class SoundPlayer {}会被转译为类似exports.default = SoundPlayer的结构。
因此,当你通过 require 方式拿到模块命名空间对象时:
const soundPlayer = require('./sound-player')实际的类并不在soundPlayer本身,而是在它的default属性上,创建实例自然要写成:
new soundPlayer.default()这就是文档中反复强调的“soundPlayer.default才是真正的构造函数”这一背景。模块被转译后,import语法本身并不会让测试代码直接获得类,你在测试文件里看到的所有关于“模块形状”的认知,都必须建立在这个转译产物的基础之上。
二、常见错误:直接 mock 类导致default is not a constructor
很多开发者会按照“直接 mock 一个函数”的直觉来写,比如照搬 Jest 文档中针对普通函数/类的常见 mock 写法:
jest.mock('./sound-player', () => { return jest.fn().mockImplementation(() => { return { playSoundFile: mockPlaySoundFile } }) })这段代码的问题在于:jest.mock的工厂函数整体替换了模块导出。也就是说,Jest 会用jest.fn()这个函数去替换整个./sound-player模块,而不是替换其中的default导出。转译后的代码在运行时访问soundPlayer.default,得到的却是undefined,于是立刻抛出:
TypeError: sound_player_1.default is not a constructor从错误信息sound_player_1.default is not a constructor可以清晰地看到,出错点正是访问了模块命名空间对象(sound_player_1)的default属性:soundPlayer.default没有指向一个函数(构造函数),因此new操作无法执行。
三、正确方案:mock 必须返回带default属性的对象
既然运行时依赖的是soundPlayer.default,那么 mock 工厂函数返回的对象就必须包含一个名为default的属性,且该属性指向一个函数(jest.fn())。修改后如下:
jest.mock('./sound-player', () => { return { default: jest.fn().mockImplementation(() => { return { playSoundFile: mockPlaySoundFile, } }), } })这里有两个关键点需要理解:
- 外层对象模拟的是“模块导出对象”:
default这个 key 与转译后的exports.default一一对应,保证了new soundPlayer.default()能拿到构造函数; jest.fn().mockImplementation()模拟的是“类的实例”:mockImplementation返回的对象就是new之后的实例形态,其中的playSoundFile等属性/方法需要与被 mock 的类实例保持一致。
这样,测试代码中通过new SoundPlayer()或new soundPlayer.default()创建出来的对象,其playSoundFile方法就会被替换为mockPlaySoundFile,从而实现对类实例行为的精确控制,而无需真正加载源文件。
四、命名导入(Named Import)的 mock 写法
如果你的模块不是默认导出,而是命名导出,例如:
import { OAuth2 } from './oauth'那么 mock 时就不能用default这个 key,而是要把default替换为导入的模块名,也就是OAuth2:
jest.mock('./oauth', () => { return { OAuth2: ... // mock here } })这一规则的本质与 default 导出完全一致:Jest 的 mock 工厂函数替换的是整个模块导出对象,因此返回对象的属性名必须与转译后模块的导出名(exports.OAuth2)严格对应。
这种“返回对象 + 命名导出属性”的写法在仓库的端到端测试中可以直接找到实证。在 e2e/typescript-pre-7-compat/tests/hoist.spec.ts 中,被测文件通过命名导入使用依赖,而 mock 工厂返回的正是与导出名一致的属性:
import { dependency } from '../src/dependency' jest.mock('../src/dependency', () => ({ dependency: 'mocked' })) it('hoists Jest mocks before imports', () => { expect(dependency).toBe('mocked') })注意这里{ dependency: 'mocked' }的对象结构与第四节介绍的{ OAuth2: ... }完全同构:命名导入dependency,mock 返回对象里就放一个同名的dependency属性。同理,typescript-7-compat目录下也存在结构完全相同的验证用例(见 e2e/typescript-7-compat/tests/hoist.spec.ts),表明该写法在当前与未来 TypeScript 版本下均被持续回归验证。
五、原理纵深:jest.mock的提升(hoisting)机制
在编写上述 mock 时,你可能会好奇:jest.mock出现在import语句之后,为什么还能在模块加载时生效?这背后是 ts-jest 内置的hoist-jestAST 转换器在起作用。
在 src/transformers/hoist-jest.ts 的源码中可以看到:
- 转换器维护了一张“需要提升的方法清单”:
HOIST_METHODS = ['mock', 'unmock', 'enableAutomock', 'disableAutomock', 'deepUnmock']; - 它识别
jest.mock(...)/jest.unmock(...)等调用表达式,并将这些语句排序、提前到所有 import 之前执行; - 无论是否使用全局
jest对象,还是通过@jest/globals导入,都会被正确识别和提升; - 该转换器带有独立的
version = 4版本号,用于通知 Jest 在转换器内容变更时不要复用旧缓存(源码注释对此有明确说明)。
整个流程在 website/docs/processing.md 描述的ts-jest处理管线中也有对应环节:TypeScript 编译完成后,会进入“custom AST transformers”阶段,注释明确指出“这里就是进行 jest.mock 提升(hoisting)以及基于配置的用户自定义转换的地方”。
因此,你在测试文件里写的jest.mock('./sound-player', () => ({ default: jest.fn()... }))会被 ts-jest 自动提升到 import 之前执行,mock 工厂函数得以在模块真正被 require 之前完成对导出对象的替换——这正是上面所有 mock 写法能够生效的底层保证。
六、常见疑问与补充说明
Q1:为什么外层是普通对象,内层default又必须是jest.fn()?
外层对象对应模块命名空间(exports),普通对象即可;内层default对应构造函数本身,测试中会对其执行new,所以必须是一个函数,jest.fn()既能被new,又能通过mockImplementation定制实例行为。
Q2:如果类上有多个方法怎么办?
在mockImplementation返回的实例对象中,把需要替换的方法逐一列出即可,例如{ playSoundFile: mockPlaySoundFile, playAnother: mockAnother },未列出的方法则不会被 mock。
Q3:命名导出与 default 导出能否混用?
可以。若模块同时导出default和其他命名导出,mock 工厂返回的对象里同时放default和对应命名 key 即可,规则互不冲突,一一对应转译后的exports属性。
Q4:这些写法在 ESM 模式下是否适用?
需要结合具体运行模式判断。ESM 场景下模块形态、import语义与 CommonJS 不同,ts-jest 对 ESM 有独立的配置要求(涉及extensionsToTreatAsEsm、useESM等,详见 website/docs/guides/esm-support.md),且 Jest 运行时对 ES 模块的 mock 支持有其自身限制。本文讨论的“模块导出对象包含default/命名属性”这一核心思想仍然成立,但在 ESM 模式下应优先查阅对应模式的官方说明与实际表现。
七、小结
- TypeScript 转译后,
export default的类位于模块导出对象的default属性上,实例化需要new soundPlayer.default(); - 直接返回
jest.fn()的 mock 会因soundPlayer.default不是函数而报TypeError: sound_player_1.default is not a constructor; - 正确的 default 导出 mock:工厂函数返回
{ default: jest.fn().mockImplementation(() => ({ ...实例方法 })) }; - 命名导出(如
OAuth2)时,把default换成对应的导出名即可:{ OAuth2: ... }; jest.mock之所以能在 import 之前生效,靠的是 ts-jest 内置hoist-jestAST 转换器的语句提升机制(src/transformers/hoist-jest.ts),仓库端到端测试(e2e/typescript-pre-7-compat/tests/hoist.spec.ts)为“返回对象需与导出名一致”提供了直接实证。
掌握“mock 替换的是整个导出对象”这一原则,无论面对 default 导出还是任意数量的命名导出,你都能写出与转译产物严格对齐、可长期维护的 Jest mock 代码。
- 测试
- 开发工具
【免费下载链接】ts-jest
A Jest transformer with source map support that lets you use Jest to test projects written in TypeScript.
相关推荐
OHIF 3.10 uiModalService 迁移指南:Modal API 重构与组件化改造实战
OHIF 3.10 uiModalService 迁移指南:Modal API 重构与组件化改造实战 本指南围绕 OHIF Viewers 3.9 → 3.10
测试开发工具同一模块中的默认导出与命名导出:ES6 Module 混合导出实战(til 知识库)
同一模块中的默认导出与命名导出:ES6 Module 混合导出实战(til 知识库) 本篇技术指南以 til 仓库中的 javascript/default a
文档教程知识库Cal.diy 代码库模块导入导出模式:命名导出、生成文件与 Build* 工厂命名实践
Cal.diy 代码库模块导入导出模式:命名导出、生成文件与 Build 工厂命名实践 导读 本文以 agents/rules/quality imports.
后端前端企业应用
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考