跳至主要內容
版本:29.7

ES6 類別模擬

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) 會接收一個模組工廠參數。模組工廠是一個傳回模擬的函式。

為了模擬建構函式,模組工廠必須傳回一個建構函式。換句話說,模組工廠必須是一個傳回函式的函式,也就是高階函式 (HOF)。

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

下列範例會產生 ReferenceError,儘管在變數宣告時使用了 mock,但 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() 替換 mock

您可以替換上述所有 mock,以變更實作,針對單一測試或所有測試,只要在現有的 mock 上呼叫 mockImplementation() 即可。

呼叫 jest.mock 會提升至程式碼最上方。您可以稍後指定 mock,例如在 beforeAll() 中,只要在現有的 mock 上呼叫 mockImplementation() (或 mockImplementationOnce()),而不使用 factory 參數即可。這也讓您可以在需要時變更測試之間的 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();
});
});

深入探討:瞭解 mock 建構函式

使用 jest.fn().mockImplementation() 建立建構函式 mock 會讓 mock 看起來比實際上複雜。本節說明如何建立自己的 mock 來說明 mocking 的運作方式。

另一個 ES6 類別的手動 mock

如果您在 __mocks__ 資料夾中使用與被 mock 類別相同的檔名定義 ES6 類別,它將作為 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。同樣地,這允許您注入不同的行為進行測試,但無法監控呼叫。

* 模組工廠函式必須傳回函式

為了模擬建構函式,模組工廠必須傳回一個建構函式。換句話說,模組工廠必須是一個傳回函式的函式,也就是高階函式 (HOF)。

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 不是建構函式,除非程式碼轉譯為 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);
});