跳至主内容
版本:下一篇

ES6 类模拟

非官方测试版翻译

本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →

Jest 可用于模拟需要测试文件中导入的 ES6 类。

ES6 类本质上是通过语法糖封装的构造函数。因此,任何 ES6 类的模拟必须是一个函数或实际的 ES6 类(同样也是函数)。你可以使用模拟函数来实现这类模拟。

ES6 类示例

我们将使用一个简化示例:播放音频文件的类 SoundPlayer 及其使用者类 SoundPlayerConsumer。在测试 SoundPlayerConsumer 时,我们将模拟 SoundPlayer

sound-player.js
export default class SoundPlayer {
constructor() {
this.foo = 'bar';
}

playSoundFile(fileName) {
console.log('Playing sound file ' + fileName);
}
}
sound-player-consumer.js
import SoundPlayer from './sound-player';

export default class SoundPlayerConsumer {
constructor() {
this.soundPlayer = new SoundPlayer();
}

playSomethingCool() {
const coolSoundFileName = 'song.mp3';
this.soundPlayer.playSoundFile(coolSoundFileName);
}
}

创建 ES6 类模拟的 4 种方式

自动模拟

调用 jest.mock('./sound-player') 会返回一个实用的"自动模拟",可用于监视类构造函数及其所有方法的调用。它会用模拟构造函数替换 ES6 类,并将所有方法替换为始终返回 undefined模拟函数。方法调用会被记录在 theAutomaticMock.mock.instances[index].methodName.mock.calls 中。

备注

如果类中使用箭头函数,它们将_不会_被包含在模拟中。这是因为箭头函数不存在于对象原型上,它们仅是持有函数引用的属性。

如果不需要替换类的实现,这是最简单的配置方案。例如:

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);
});

手动模拟

通过在 __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;

导入模拟对象和所有实例共享的模拟方法:

sound-player-consumer.test.js
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);
});

使用模块工厂参数调用 jest.mock()

jest.mock(path, moduleFactory) 接收模块工厂参数。模块工厂是返回模拟对象的函数。

要模拟构造函数,模块工厂必须返回构造函数(即返回函数的函数——高阶函数):

import SoundPlayer from './sound-player';
const mockPlaySoundFile = jest.fn();
jest.mock('./sound-player', () => {
return jest.fn().mockImplementation(() => {
return {playSoundFile: mockPlaySoundFile};
});
});
注意

由于 jest.mock() 调用会被提升至文件顶部,Jest 会阻止访问作用域外的变量。默认情况下,不能先定义变量再在工厂中使用。Jest 会对以 mock 开头的变量禁用此检查,但你仍需确保它们能及时初始化。请注意暂时性死区问题。

例如,以下代码因变量声明使用 fake 而非 mock 将抛出作用域外错误:

// Note: this will fail
import SoundPlayer from './sound-player';
const fakePlaySoundFile = jest.fn();
jest.mock('./sound-player', () => {
return jest.fn().mockImplementation(() => {
return {playSoundFile: fakePlaySoundFile};
});
});

以下代码尽管在变量声明中使用了 mock,仍会抛出 ReferenceError,因为提升后 mockSoundPlayer 在初始化前被访问,且未被箭头函数包裹:

import SoundPlayer from './sound-player';
const mockSoundPlayer = jest.fn().mockImplementation(() => {
return {playSoundFile: mockPlaySoundFile};
});
// results in a ReferenceError
jest.mock('./sound-player', () => {
return mockSoundPlayer;
});

使用 mockImplementation()mockImplementationOnce() 替换模拟

你可以在现有模拟上调用 mockImplementation() 来替换上述所有模拟的实现,适用于单个测试或全部测试。

对 jest.mock 的调用会被提升到代码顶部。如需稍后指定模拟(例如在 beforeAll() 中),可在现有模拟对象上调用 mockImplementation() (或 mockImplementationOnce()) 替代工厂函数参数。这还允许你在需要时在测试间切换模拟:

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();
});
});

深入理解:模拟构造函数

使用 jest.fn().mockImplementation() 构建构造函数模拟会让流程显得复杂。本节展示如何创建自定义模拟以揭示其工作原理:

手动模拟实现(另一个 ES6 类)

如果在 __mocks__ 目录创建与被模拟类同名的 ES6 类,该自定义类将作为模拟对象。它会替代原始类,但无法追踪调用信息:

针对示例场景的模拟实现:

__mocks__/sound-player.js
export default class SoundPlayer {
constructor() {
console.log('Mock SoundPlayer: constructor was called');
}

playSoundFile() {
console.log('Mock SoundPlayer: playSoundFile was called');
}
}

使用模块工厂参数模拟

传入 jest.mock(path, moduleFactory) 的模块工厂函数可以是返回函数的 HOF(高阶函数)*。这使得能在模拟对象上调用 new,但仍无法追踪调用:

* 模块工厂函数必须返回函数

要模拟构造函数,模块工厂必须返回构造函数(即返回函数的函数——高阶函数):

jest.mock('./sound-player', () => {
return function () {
return {playSoundFile: () => {}};
};
});
备注

模拟对象不能是箭头函数:JavaScript 不允许对箭头函数使用 new。以下写法无效:

jest.mock('./sound-player', () => {
return () => {
// Does not work; arrow functions can't be called with new
return {playSoundFile: () => {}};
};
});

这将抛出 TypeError: _soundPlayer2.default is not a constructor(除非代码被转译为 ES5,例如通过 @babel/preset-env。ES5 没有箭头函数和类,两者都会被转译为普通函数)。

模拟类的特定方法

假设需要模拟或监视类 SoundPlayer 中的 playSoundFile 方法:

// 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();
});

静态方法、getter 和 setter

假设 SoundPlayer 类包含 getter 方法 foo 和静态方法 brand

export default class SoundPlayer {
constructor() {
this.foo = 'bar';
}

playSoundFile(fileName) {
console.log('Playing sound file ' + fileName);
}

get foo() {
return 'bar';
}
static brand() {
return 'player-brand';
}
}

可通过以下方式轻松模拟/监视:

// 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();
});

追踪使用情况(监视模拟对象)

注入测试实现很有用,但通常还需验证构造函数和方法是否以正确参数调用:

监视构造函数

要追踪构造函数调用,需用 Jest 模拟函数替换 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);

模拟非默认导出的类

若类不是模块的默认导出,需返回包含同名键的对象:

import {SoundPlayer} from './sound-player';
jest.mock('./sound-player', () => {
// Works and lets you check for constructor calls:
return {
SoundPlayer: jest.fn().mockImplementation(() => {
return {playSoundFile: () => {}};
}),
};
});

监视类的方法

我们模拟的类需要提供测试过程中将被调用的所有成员函数(如示例中的 playSoundFile),否则调用不存在函数时会报错。此外,我们通常还需要监听这些方法的调用情况,确保它们是以预期参数被调用的。

每次在测试中调用模拟构造函数时都会创建新对象。为了监听所有这些对象中的方法调用,我们用另一个模拟函数填充 playSoundFile,并将该模拟函数的引用存储在测试文件中,确保测试期间可访问。

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
});
});

等效的手动模拟实现如下:

__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;

用法与模块工厂函数类似,区别在于:可以省略 jest.mock() 的第二个参数;由于被模拟方法不再定义于测试文件中,必须将其导入测试文件。导入时使用原始模块路径,不要包含 __mocks__

在测试之间清理

为了清除模拟构造函数及其方法的调用记录,我们在 beforeEach() 函数中调用 mockClear()

beforeEach(() => {
SoundPlayer.mockClear();
mockPlaySoundFile.mockClear();
});

完整示例

以下是使用 jest.mock 模块工厂参数的完整测试文件示例:

sound-player-consumer.test.js
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);
});