
Jest ES6 Class Mock 完全指南四种 Mock 方式与 spy 跟踪机制深入解析【免费下载链接】jestDelightful JavaScript Testing.项目地址: https://gitcode.com/gh_mirrors/je/jest导读在 Jest 中测试依赖 ES6 类的模块例如播放器类、服务类、数据库访问类时最核心的诉求是不触发真实类的副作用网络请求、IO、日志又能精确断言构造函数被调用了多少次、实例方法被传入了什么参数。本指南以官方文档 Es6ClassMocks.md 为主体骨架围绕SoundPlayer/SoundPlayerConsumer示例完整讲解创建 ES6 类 mock 的四种方式自动 mock、手动 mock、模块工厂参数、mockImplementation动态替换并从 jest-mock 源码层面剖析mock.instances、mock.calls、hoisting 与 mock 构造函数生成等底层原理。读完本文你将能在真实测试中按需选择 mock 策略并写出可维护、可断言的类依赖测试。为什么 ES6 类可以被 mock本质是构造函数ES6 类本质上是带有语法糖的构造函数因此任何针对 ES6 类的 mock 都必须是一个函数或另一个 ES6 类类归根结底也是函数。这意味着你可以直接借助 mock functions 的能力来 mock 类用jest.fn()生成的 mock 函数天然具备mock.calls、mock.instances、mock.results等调用记录由于 mock 函数可以通过new调用它可以完整替代类的构造行为。从 jest-mock 的类型定义可以看到Jest 将类与普通函数分开建模ClassLike new (...args: any) any并定义了MockedClassT构造签名 实例成员全部被 mock与MockedFunctionT等类型packages/jest-mock/src/index.ts#L40-L61。这从类型层面印证了mock 一个类就是把构造函数和原型上的方法一起替换。示例工程SoundPlayer 与 SoundPlayerConsumer我们先构建一个被 mock 的目标类与它的消费方。SoundPlayer是播放声音文件的类export default class SoundPlayer { constructor() { this.foo bar; } playSoundFile(fileName) { console.log(Playing sound file fileName); } }SoundPlayerConsumer在构造函数中new一个SoundPlayer并在playSomethingCool()中调用其实例方法import SoundPlayer from ./sound-player; export default class SoundPlayerConsumer { constructor() { this.soundPlayer new SoundPlayer(); } playSomethingCool() { const coolSoundFileName song.mp3; this.soundPlayer.playSoundFile(coolSoundFileName); } }在测试中我们不希望playSoundFile真正打印日志或播放文件只关心SoundPlayerConsumer是否构造了SoundPlayer、是否以正确参数调用了playSoundFile。下面四种方式都能达成目标差别在于是否可 spy、是否可跨测试文件复用、是否支持动态换实现。方式一Automatic mock自动 mock只需调用jest.mock(./sound-player)Jest 就会返回一个自动 mock用mock 构造函数替换原 ES6 类并把类的所有原型方法替换为返回undefined的 mock 函数。方法调用会被记录在theAutomaticMock.mock.instances[index].methodName.mock.calls即先通过mock.instances[i]拿到第 i 次new产生的实例再访问该实例上对应方法的.mock.calls。注意箭头函数陷阱如果你的类方法是用箭头函数定义的它们不会被自动 mock。原因在于箭头函数不在对象的prototype上它们只是持有函数引用的普通实例属性自动 mock 只替换原型链上的方法。如果不需要替换类的实现这是最省事的方案import SoundPlayer from ./sound-player; import SoundPlayerConsumer from ./sound-player-consumer; jest.mock(./sound-player); // SoundPlayer is now a mock constructor beforeEach(() { // Clear all instances and calls to constructor and all methods: SoundPlayer.mockClear(); }); it(We can check if the consumer called the class constructor, () { const soundPlayerConsumer new SoundPlayerConsumer(); expect(SoundPlayer).toHaveBeenCalledTimes(1); }); it(We can check if the consumer called a method on the class instance, () { // Show that mockClear() is working: expect(SoundPlayer).not.toHaveBeenCalled(); const soundPlayerConsumer new SoundPlayerConsumer(); // Constructor should have been called again: expect(SoundPlayer).toHaveBeenCalledTimes(1); const coolSoundFileName song.mp3; soundPlayerConsumer.playSomethingCool(); // mock.instances is available with automatic mocks: const mockSoundPlayerInstance SoundPlayer.mock.instances[0]; const mockPlaySoundFile mockSoundPlayerInstance.playSoundFile; expect(mockPlaySoundFile.mock.calls[0][0]).toBe(coolSoundFileName); // Equivalent to above check: expect(mockPlaySoundFile).toHaveBeenCalledWith(coolSoundFileName); expect(mockPlaySoundFile).toHaveBeenCalledTimes(1); });底层原理在 jest-mock 的 mock 构造函数实现中每次new调用都会依次执行mockState.instances.push(this)、mockState.contexts.push(this)、mockState.calls.push(args)从而把实例、上下文、参数完整记录下来同时MockFunctionState类型明确声明了calls所有调用的参数列表、instances从 mock 实例化的所有对象、contexts调用时的this上下文等字段packages/jest-mock/src/index.ts#L251-L265。这正是SoundPlayer.mock.instances[0]可用的源码依据。方式二Manual mock手动 mock把 mock 实现保存到__mocks__目录即可指定实现细节并且可以跨测试文件复用。先创建__mocks__/sound-player.js// Import this named export into your test file: export const mockPlaySoundFile jest.fn(); const mock jest.fn().mockImplementation(() { return {playSoundFile: mockPlaySoundFile}; }); export default mock;这里的技巧是把mockPlaySoundFile作为具名导出让所有实例共享同一个 mock 函数引用这样断言的是同一个函数的调用记录再让默认导出的 mock 构造函数返回{playSoundFile: mockPlaySoundFile}作为每个实例的形态。测试文件中导入 mock 类与共享的 mock 方法import SoundPlayer, {mockPlaySoundFile} from ./sound-player; import SoundPlayerConsumer from ./sound-player-consumer; jest.mock(./sound-player); // SoundPlayer is now a mock constructor beforeEach(() { // Clear all instances and calls to constructor and all methods: SoundPlayer.mockClear(); mockPlaySoundFile.mockClear(); }); it(We can check if the consumer called the class constructor, () { const soundPlayerConsumer new SoundPlayerConsumer(); expect(SoundPlayer).toHaveBeenCalledTimes(1); }); it(We can check if the consumer called a method on the class instance, () { const soundPlayerConsumer new SoundPlayerConsumer(); const coolSoundFileName song.mp3; soundPlayerConsumer.playSomethingCool(); expect(mockPlaySoundFile).toHaveBeenCalledWith(coolSoundFileName); });与 手动 mock 通用机制 一致位于__mocks__目录、与被 mock 模块同名的文件会自动替代真实模块区别在于这里 mock 的形态是构造函数 实例方法而不是普通对象。方式三通过jest.mock()的模块工厂参数jest.mock()接受第二个参数moduleFactory模块工厂一个返回 mock 的函数。要 mock 构造函数模块工厂返回的必须是一个构造函数——即工厂是返回函数的函数也就是高阶函数HOFimport SoundPlayer from ./sound-player; const mockPlaySoundFile jest.fn(); jest.mock(./sound-player, () { return jest.fn().mockImplementation(() { return {playSoundFile: mockPlaySoundFile}; }); });Hoisting 与变量作用域陷阱重点由于jest.mock()调用会被提升hoist到文件顶部工厂函数内无法访问外层作用域的变量。Jest 默认会对工厂内出现的外层变量做检查但对以mock开头的变量放行这是有意的逃生舱。不过即使放行你也必须保证这些变量在工厂执行时已被初始化注意 let 的暂时性死区Temporal Dead Zone 问题。反例 1变量名不以mock开头抛 out-of-scope 错误// Note: this will fail import SoundPlayer from ./sound-player; const fakePlaySoundFile jest.fn(); jest.mock(./sound-player, () { return jest.fn().mockImplementation(() { return {playSoundFile: fakePlaySoundFile}; }); });反例 2虽然以mock开头但没有包在箭头函数里提升后访问了未初始化变量抛ReferenceErrorimport SoundPlayer from ./sound-player; const mockSoundPlayer jest.fn().mockImplementation(() { return {playSoundFile: mockPlaySoundFile}; }); // results in a ReferenceError jest.mock(./sound-player, () { return mockSoundPlayer; });源码证据babel-plugin-jest-hoist 中ALLOWED_IDENTIFIERS允许jest、expect、require、Node.js 全局对象与 ES2015 内置对象出现在工厂内并注释we also allow variables prefixed withmockas an escape-hatch在检查逻辑中判断条件是(scope.hasGlobal(name) ALLOWED_IDENTIFIERS.has(name)) || /^mock/i.test(name) || /^(?:__)?cov/.test(name)packages/babel-plugin-jest-hoist/src/index.ts#L150-L156。因此只有mock*前缀或白名单内置标识符才能通过 hoist 检查。方式四用mockImplementation()/mockImplementationOnce()动态替换jest.mock()的调用会被提升到文件顶部因此你可以不在工厂参数里指定实现而是先用自动 mock 占位再在beforeAll()等时机通过mockImplementation()注入实现。这样既能处理工厂无法访问外层变量的限制也允许在不同测试之间切换行为import SoundPlayer from ./sound-player; import SoundPlayerConsumer from ./sound-player-consumer; jest.mock(./sound-player); describe(When SoundPlayer throws an error, () { beforeAll(() { SoundPlayer.mockImplementation(() { return { playSoundFile: () { throw new Error(Test error); }, }; }); }); it(Should throw an error when calling playSomethingCool, () { const soundPlayerConsumer new SoundPlayerConsumer(); expect(() soundPlayerConsumer.playSomethingCool()).toThrow(); }); });mockImplementationOnce()则只对下一次调用生效适合第一次成功、第二次失败这种顺序依赖的场景。相关 API 详见 MockFunctionAPI.md。四种方式速查对比方式是否可 spy 调用是否可自定义实现是否可跨文件复用适用场景Automatic mockjest.mock(./x)✅ 构造函数与全部原型方法❌默认返回undefined✅ 每个测试文件自动生效只想断言调用了谁、传了什么参数Manual mock__mocks__目录✅通过共享的具名 mock 方法✅✅需要复用一套实现且要断言Module factoryjest.mock(./x, factory)✅✅❌工厂只作用于当前文件单文件内定制实现并 spymockImplementation(Once)()动态替换✅✅可随时切换✅不同测试需要不同行为如抛错/成功深入理解mock 构造函数是怎么工作的用jest.fn().mockImplementation()拼装 mock 看似复杂实际上它只是把构造函数调用与实例方法这两件事拆开。本节能让你彻底理解其运行机制。手动 mock 直接写成另一个 ES6 类在__mocks__目录下用与被 mock 类相同的文件名定义一个 ES6 类它就会充当 mock。这个类会在测试中替代真实类可以注入测试实现但无法 spy 调用export default class SoundPlayer { constructor() { console.log(Mock SoundPlayer: constructor was called); } playSoundFile() { console.log(Mock SoundPlayer: playSoundFile was called); } }模块工厂必须是返回函数的高阶函数为了让 mock 支持new调用模块工厂返回的必须是普通函数jest.mock(./sound-player, () { return function () { return {playSoundFile: () {}}; }; });注意箭头函数不可 newmock 不能是箭头函数因为 JavaScript 不允许对箭头函数使用new。下面的写法会失败jest.mock(./sound-player, () { return () { // Does not work; arrow functions cant be called with new return {playSoundFile: () {}}; }; });会抛出TypeError: _soundPlayer2.default is not a constructor除非代码被转译为 ES5例如通过babel/preset-env因为 ES5 既没有箭头函数也没有类二者都会被转成普通函数。源码依据在 jest-mock 中matchArity()根据函数形参个数生成对应签名的mockConstructor包装19 个参数逐一生成包装函数并在 packages/jest-mock/src/index.ts#L1047-L1076 通过MOCK_CONSTRUCTOR_NAME值为mockConstructor见 packages/jest-mock/src/index.ts#L293构造动态函数。这正是mock 必须是可new的构造函数这一约束在实现层面的体现。只 mock 类中的某一个具体方法如果你只想替换或监听SoundPlayer.prototype上的playSoundFile用jest.spyOn()即可// your jest test file below import SoundPlayer from ./sound-player; import SoundPlayerConsumer from ./sound-player-consumer; const playSoundFileMock jest .spyOn(SoundPlayer.prototype, playSoundFile) .mockImplementation(() { console.log(mocked function); }); // comment this line if just want to spy it(player consumer plays music, () { const player new SoundPlayerConsumer(); player.playSomethingCool(); expect(playSoundFileMock).toHaveBeenCalled(); });去掉.mockImplementation(...)就是纯 spy不改变原行为只记录调用。仓库的单元测试对spy 原型方法后mockRestore()能恢复原实现有直接验证见 packages/jest-mock/src/tests/class-mocks.test.ts#L10-L25。静态方法、getter 与 setter假设SoundPlayer增加了 getterfoo与静态方法brandexport default class SoundPlayer { constructor() { this.foo bar; } playSoundFile(fileName) { console.log(Playing sound file fileName); } get foo() { return bar; } static brand() { return player-brand; } }它们同样可以轻松被 mock/spy// your jest test file below import SoundPlayer from ./sound-player; const staticMethodMock jest .spyOn(SoundPlayer, brand) .mockImplementation(() some-mocked-brand); const getterMethodMock jest .spyOn(SoundPlayer.prototype, foo, get) .mockImplementation(() some-mocked-result); it(custom methods are called, () { const player new SoundPlayer(); const foo player.foo; const brand SoundPlayer.brand(); expect(staticMethodMock).toHaveBeenCalled(); expect(getterMethodMock).toHaveBeenCalled(); });要点静态方法spyOn(SoundPlayer, brand)目标为类本身实例 getterspyOn(SoundPlayer.prototype, foo, get)第三个参数指定访问器类型实例 setter 同理使用set作为第三参数。仓库测试覆盖了实例方法、继承自父类的方法/静态方法/getter/setter 的 spy 与mockRestore()恢复行为包括名为get、set的方法与属性访问器见 packages/jest-mock/src/tests/class-mocks.test.ts。跟踪使用情况对 mock 的 spy注入测试实现只是第一步多数情况下你还需要断言构造函数与方法是否以正确参数被调用。spy 构造函数把 HOF 返回的函数替换为jest.fn()并配合mockImplementation()指定实例形态import SoundPlayer from ./sound-player; jest.mock(./sound-player, () { // Works and lets you check for constructor calls: return jest.fn().mockImplementation(() { return {playSoundFile: () {}}; }); });随后即可通过SoundPlayer.mock.calls检查构造调用expect(SoundPlayer).toHaveBeenCalled(); // 或近等价写法 expect(SoundPlayer.mock.calls.length).toBeGreaterThan(0);mock 非默认导出的类如果被 mock 的类不是模块的默认导出模块工厂必须返回一个键名与导出名一致的对象import {SoundPlayer} from ./sound-player; jest.mock(./sound-player, () { // Works and lets you check for constructor calls: return { SoundPlayer: jest.fn().mockImplementation(() { return {playSoundFile: () {}}; }), }; });spy 类的方法mock 类必须提供测试期间会被调用的成员函数否则会因调用不存在的函数而报错同时我们又想 spy 这些方法。由于每次调用 mock 构造函数都会创建一个新对象要在所有实例上统一 spy就需要让每个实例共享同一个 mock 函数引用并把这个引用保存在测试文件中import SoundPlayer from ./sound-player; const mockPlaySoundFile jest.fn(); jest.mock(./sound-player, () { return jest.fn().mockImplementation(() { return {playSoundFile: mockPlaySoundFile}; // Now we can track calls to playSoundFile }); });手动 mock 的等价写法// Import this named export into your test file export const mockPlaySoundFile jest.fn(); const mock jest.fn().mockImplementation(() { return {playSoundFile: mockPlaySoundFile}; }); export default mock;用法与模块工厂方式类似区别在于可以省略jest.mock()的第二个参数模块工厂存在于__mocks__中并且**必须使用原始模块路径不含__mocks__**导入被 mock 的方法到测试文件中因为它在测试文件中已不再定义。测试之间的清理用mockClear()清空 mock 构造函数及其方法的调用记录通常放在beforeEach()中避免测试间互相污染beforeEach(() { SoundPlayer.mockClear(); mockPlaySoundFile.mockClear(); });mockClear()只清空调用记录mock.calls、mock.instances、mock.results不会重置已注入的实现若连实现一起重置应使用mockReset()详见 MockFunctionAPI.md。完整示例一个可直接运行的测试文件下面是一个完整的测试文件综合运用了模块工厂参数、mockClear()清理与三类断言构造成功、构造次数、方法参数import SoundPlayer from ./sound-player; import SoundPlayerConsumer from ./sound-player-consumer; const mockPlaySoundFile jest.fn(); jest.mock(./sound-player, () { return jest.fn().mockImplementation(() { return {playSoundFile: mockPlaySoundFile}; }); }); beforeEach(() { SoundPlayer.mockClear(); mockPlaySoundFile.mockClear(); }); it(The consumer should be able to call new() on SoundPlayer, () { const soundPlayerConsumer new SoundPlayerConsumer(); // Ensure constructor created the object: expect(soundPlayerConsumer).toBeTruthy(); }); it(We can check if the consumer called the class constructor, () { const soundPlayerConsumer new SoundPlayerConsumer(); expect(SoundPlayer).toHaveBeenCalledTimes(1); }); it(We can check if the consumer called a method on the class instance, () { const soundPlayerConsumer new SoundPlayerConsumer(); const coolSoundFileName song.mp3; soundPlayerConsumer.playSomethingCool(); expect(mockPlaySoundFile.mock.calls[0][0]).toBe(coolSoundFileName); });小结与选型建议围绕本文的SoundPlayer场景给出实践建议只想断言依赖关系构造了几次、方法传参对不对→ 用Automatic mock零样板代码需要跨多个测试文件复用一套实现→ 把实现放进__mocks__目录做Manual mock并用具名导出的共享 mock 方法单个文件内既要自定义实现又要 spy→ 用Module factory注意mock前缀与 TDZ 陷阱不同测试需要不同行为如正常/抛错分支→ 自动 mock 占位 mockImplementation()/mockImplementationOnce()按需替换只替换/监听某一个方法、getter、setter 或静态方法→jest.spyOn()精确打击必要时配合mockRestore()恢复原实现。Jest 官方同样版本与更新的文档可参考 docs/Es6ClassMocks.mdmock 函数通用能力见 docs/MockFunctions.md 与 docs/MockFunctionAPI.mdjest.mock与jest.spyOn的完整签名见 docs/JestObjectAPI.md。若想从源码层面继续深挖可阅读 packages/jest-mock/src/index.tsmock 状态与构造函数生成与 packages/jest-mock/src/tests/class-mocks.test.ts覆盖实例/静态/getter/setter/继承场景的测试以及 packages/babel-plugin-jest-hoist/src/index.tshoist 作用域检查规则。【免费下载链接】jestDelightful JavaScript Testing.项目地址: https://gitcode.com/gh_mirrors/je/jest创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考