ES6 類別模擬
Jest 可用於模擬匯入至您要測試檔案的 ES6 類別。
ES6 類別是具備一些語法糖的建構函式。因此,ES6 類別的任何模擬都必須是函式或實際的 ES6 類別(也就是另一個函式)。因此,您可以使用 模擬函式 來模擬它們。
ES6 類別範例
我們將使用播放音效檔案的類別 SoundPlayer
,以及使用該類別的使用者類別 SoundPlayerConsumer
,作為虛構範例。我們將在 SoundPlayerConsumer
的測試中模擬 SoundPlayer
。
export default class SoundPlayer {
constructor() {
this.foo = 'bar';
}
playSoundFile(fileName) {
console.log('Playing sound file ' + fileName);
}
}
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__
資料夾中儲存模擬實作,建立手動模擬。這允許你指定實作,並可以在測試檔案中使用。
// 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 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。此類別將用於取代實際類別。這讓您可以為類別注入測試實作,但無法追蹤呼叫。
對於人為範例,模擬可能如下所示
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
});
});
手動模擬的等效方式如下
// 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
的完整測試檔案
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);
});