跳转至主内容
Version: 29.3

ES6 类模拟

Jest 可用于模拟你从文件导入的想要测试的ES6类。

ES6类是带有一些语法糖的构造函数。 因此,ES6类的任何 模拟都必须是一个函数或者一个真实的 ES6类(又是另一个函数)。 所以你可以通过 mock functions 模拟它们。

一个ES6类的例子

我们会举一个例子,在这个例子里面包含一个类 SoundPlayer 用来播放音频文件, 以及另外一个类 SoundPlayerConsumer, 它将会用到 SoundPlayer。 我们会在 SoundPlayerConsumer 的 test 文件里面模拟 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);
}
}

4 种方式去模拟 ES6 类

自动模拟

调用 jest.mock('./sound-payer') 会得到一个"自动模拟", 你可以监听这个模拟上 constructor 的调用以及它所有方法的调用。 它将会使用一个模拟的 constructor 替换原先的 Es6 类,以及使用返回 undefined 的[模拟函数](MockFunctions. md)替换掉这个类上所有的方法。 这些方法的调用会保存在 theAutomaticMock.mock.instances[index].methodName.mock.calls

note

If you use arrow functions in your classes, they will not be part of the mock. 这样做的原因是箭头函数不能代表一个对象的原型,它们仅仅是是一个函数的引用。

如果你不需要替换class的实现,这是最简单的设置方式。 例如:

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__ 文件夹,你可以创建一个手动模拟。 这使得你可以指定实现,这个实现将被test文件使用。

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

Import the mock and the mock method shared by all instances:

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) 能接收 模块工厂 参数。 模块工厂是一个函数,这个函数会返回 mock。

为了模拟 constructor 构造函数,模块工厂必须返回一个构造函数。 也就是说,模块工厂必须返回一个函数,而这个函数会返回另外一个函数 - 高阶函数(HOF)。

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

Since calls to jest.mock() are hoisted to the top of the file, Jest prevents access to out-of-scope variables. By default, you cannot first define a variable and then use it in the factory. Jest will disable this check for variables that start with the word mock. However, it is still up to you to guarantee that they will be initialized on time. Be aware of Temporal Dead Zone.

For example, the following will throw an out-of-scope error due to the use of fake instead of mock in the variable declaration.

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

The following will throw a ReferenceError despite using mock in the variable declaration, as the mockSoundPlayer is not wrapped in an arrow function and thus accessed before initialization after hoisting.

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

Replacing the mock using mockImplementation() or mockImplementationOnce()

对一个或者多个test,通过在已经存在的 mock 上调用mockImplementation(), 你可以更改所有 mock 的实现。

Jest.mock 的调用会被提升到文件顶部。 通过在已经存在的 mock 上调用mockImplementation() (或者 mockImplementationOnce()),这可以让你延迟指定它的实现,比如在beforeAll()里面,而不是之前提到过的使用工厂函数。 这能让你在你需要时,可以在不同 test 之间改变mock的实现。

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()去打造你的构造函数的 mock 将使得这些 mock 变得比它们自身更复杂。 这一章节会给你展示如何创建你自己的模拟,这会阐述模拟是如何工作的。

手工模拟是另外一个ES6类

如果你在__mocks__文件夹下的一个与你 mock 类同名的文件里定义一个 ES6 类,这个类将会被当成 mock 使用。 这个类将会被取代真实的类。 这将使得你可以为这个类注入一个 test 实现,但是这样做不能让你监测它的调用。

对于我们的例子来讲,这个 mock 也许会像这有:

__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 调用。 同样的,这种方式让你为测试注入不同的实现,但是不能让你去监测调用。

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

为了模拟 constructor 构造函数,模块工厂必须返回一个构造函数。 也就是说,模块工厂必须返回一个函数,而这个函数会返回另外一个函数 - 高阶函数(HOF)。

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

The mock can't be an arrow function because calling new on an arrow function is not allowed in JavaScript. So this won't work:

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

这会抛出一个错误w TypeError: _soundPlayer2.default is not a constructor,除非代码被编译成 ES5, 例如通过 @babel/preset-env。 (ES5 没有箭头函数或者类,所以他们都会被编译成简单的函数)

Mocking a specific method of a class

Lets say that you want to mock or spy the method playSoundFile within the class SoundPlayer. A simple example:

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

Static, getter and setter methods

Lets imagine our class SoundPlayer has a getter method foo and a static method 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';
}
}

You can mock/spy them easily, here is an example:

// your jest test file below
import SoundPlayer from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';

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

Keeping track of usage (spying on the mock)

注入一个测试的实现是非常有帮助的,但是你可能也需要去测试这个类的构造函数以及上面的方法被调用是是否传入了正确的参数。

监测构造函数

为了监测构造函数的调用,我们需要把高阶函数HOF替换成Jest 的模拟函数。 通过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: () => {}};
});
});

This will let us inspect usage of our mocked class, using SoundPlayer.mock.calls: expect(SoundPlayer).toHaveBeenCalled(); or near-equivalent: expect(SoundPlayer.mock.calls.length).toBeGreaterThan(0);

模拟非default export 出的类

如果这个类不是从另外一个模块作为 default export 导出的,这个时候你需要返回一个对象,这个对象上有一个 key 和这个类的名字要一样。

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), 这些方法会在测试文件中被调用,否则我们会收到调用不存在的方法的错误信息。 但是我们也许同时需要监测这些方法的调用,去确保他们调用的时候传入的参数是我们期望的。

在测试文件里每次 mock 的构造函数被调用会返回一个新的对象。 A new object will be created each time the mock constructor function is called during tests. To spy on method calls in all of these objects, we populate playSoundFile with another mock function, and store a reference to that same mock function in our test file, so it's available during tests.

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() 省略第二个参数,并且你必须在你的测试文件里面 import 它,因为它没有定义在在这里。 使用原来的模块地址 import 就可以了,不需要包含 __mocks__

在每个 test 之间清除 mock

我们可以通过在 beforeEach() 函数里调用 mockClear(), 去清除 mock 的构造函数以及它上面方法的调用记录。

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