[note] Jest 筆記
keywords: test
, test-driven development
, TDD
- Jest @ Official Doc
npx jest --showConfig # 檢視設定
npx jest -- Checkbox # 測試檔名包含 Checkbox 的檔案
npx jest --coverage # 顯示全部的測試覆蓋率
npx jest --collectCoverageFrom="utils/**/*.{js,jsx,ts,tsx}" # 檢視某一 glob 底下的測試覆蓋率
安裝與設定
$ npm install jest --save-dev
在 package.json
中將 test
的指令改成:
// package.json
"scripts": {
"test": "jest" // --watch, --watchAll
},
預設情況下,Jest 會自動去找:
__tests__
資料夾內的.js
,.jsx
,.ts
,.tsx
- 以
.test
或.spec
結尾的檔案,例如,Component.test.js
orComponent.spec.js
預設的情況下,jest
會去找專案資料夾中的 tests
資料夾:
$ mkdir __tests__
執行測試:
$ npm test -- --coverage # --coverage 會顯示覆蓋率
Jest Configuration
Jest Configuration @ official Doc
在 Jest 中使用 Babel (Import)
Using Babel @ Jest > Getting Started
預設的情況下,Jest 是在 Node 環境下執行,在 ESModule 還沒於 Node 環境普遍被支援前,可以使用 Babel。
安裝 babel 相關套件
$ npm install babel-jest @babel/core @babel/preset-env -D
新增設定檔 babel.config.js
// babel.config.js
module.exports = {
presets: [
[
'@babel/preset-env',
{
targets: {
node: 'current',
},
},
],
],
};
檢視測試覆蓋率
檢視測試覆蓋率有兩種方式,第一種是透過指令:
$ npm test -- --coverage # --coverage 會顯示覆蓋率
第二種是在 package.json
中設定:
// package.json
"scripts": {
"test": "jest"
},
"jest": {
"collectCoverage": true
},
或者:
// package.json
"scripts": {
"test": "jest --coverage"
},
基本語法
Globals @ Jest
在 Jest 中,一個基本的測試會長這樣:
- Test Suite:被
describe()
包起來的部分 - Test Case:被
test()
包起來的部分
describe('here is test suite', () => {
test('here is test case', () => {
/* here is assertions */
});
test.todo('...');
test.skip('here is test case', () => {
/* assertions */
});
test.only('here is test case', () => {
/* assertions */
});
test.failing('here is test case', () => {
/* assertions */
});
// Vitest 的話是叫 fails
test.fails('here is test case', () => {
/* assertions */
});
});
在 Jest 中,test
和 it
是一樣的意思,自己選擇喜歡用的那個即可。
先把測試標題寫好
如果還沒開始撰寫測試的細節,但知道要執行那些測試項目,可以使用 test.todo(name)
。
專注和略過某些測試
在 Jest 中可以使用 describe.skip()
或 test.skip()
就可以略過特定測試項目:
test.skip()
等同於it.skip()
、xit()
、xtest()
如果只希望專注執行某些測試項目,其他都不要執行的話,可以使用 describe.only()
或 test.only()
:
test.only()
等同於it.only()
、fit()
測試會失敗的情境
某些情況下,你預期這個測試會失敗,這時候可以使用 test.failing()
。
Using Matcher
- Using Matcher @ Jest Docs > Introduction
- Expect @ Jest API
常用的匹配
toBe:比對值是否相同
// toBe 使用 Object.is 來比對,若想要比對物件內容是否一樣需使用 toEqual
test('two plus two is four', () => {
expect(2 + 2).toBe(4);
});
toEqual:比對物件內容是否相同
test('object assignment', () => {
const data = { one: 1 };
data['two'] = 2;
expect(data).toEqual({ one: 1, two: 2 });
});
not:是否不同
test('adding positive numbers is not zero', () => {
expect(1 + 3).not.toBe(0);
});
資料型別檢驗
keywords: expect.any()
, expect.anyThing()
expect.anything(); // matches anything but null or undefined
expect.any(Number); // matches anything that was created with the given constructor
匹配的時候記得要使用 expect.toEqual()
的方法,例如:
const number = 3;
expect(number).toEqual(expect.any(Number));
比對布林
keywords: toBeNull
, toBeUndefined
, toBeDefined
, toBeTruthy
, toBeFalsy
test('null', () => {
const n = null;
expect(n).toBeNull();
expect(n).toBeDefined();
expect(n).not.toBeUndefined();
expect(n).not.toBeTruthy();
expect(n).toBeFalsy();
});
test('zero', () => {
const z = 0;
expect(z).not.toBeNull();
expect(z).toBeDefined();
expect(z).not.toBeUndefined();
expect(z).not.toBeTruthy();
expect(z).toBeFalsy();
});
比對數值
keywords: toBeGreaterThan
, toBeGreaterThanOrEqual
, toBeLessThan
, toBeLessThanOrEqual
, toBeCloseTo
對於浮點數(floating number)來說,應該使用 toBeCloseTo
而不要用 toBe
或 toEqual
,因為可能會有進位問題:
test('adding floating point numbers', () => {
const value = 0.1 + 0.2;
//expect(value).toBe(0.3); This won't work because of rounding error
expect(value).toBeCloseTo(0.3); // This works.
});
比對物件中的屬性(object property)
keywords: toMatchObject(object)
, objectContaining()
, toEqual()
What's the difference between '.toMatchObject' and 'objectContaining' @ stack overflow
要比對物件中的屬性可以使用 toMatchObject
或「 objectContaining()
搭配 toEqual()
」 使用。一般來說,使用 toMatchObject
會是比較容易的做法。
toMatchObject
:匹配物件中的部分屬性和值即可toEqual
:物件中的屬性和值需要完全相同objectContaining
:較少用,可能會搭配 Array 的toContain
和toContainEqual
的方法使用
物件內不包含物件
使用 toMatchObject
或 object.containing()
有一樣的效果。只要 matched object 的屬性都有列在 expected object 內即通過:
const schema = {
gender: expect.any(String),
age: expect.any(Number),
phone: expect.anything(),
interests: expect.any(Array),
};
test('object containing', () => {
const data = {
gender: 'female',
age: 30,
phone: '0987345672',
interests: ['computer', 'guitar'],
comments: 'Nothing to comment',
};
expect(data).toMatchObject(schema); // PASS
expect(data).toEqual(expect.objectContaining(schema)); // PASS
});
物件內包含物件
當物件內又含有其他物件時,使用 toMatchObject
或 object.containing()
的效果不同:
- 通常用這個:使用
toMatchObject
中需包含所列出的屬性和值 - 使用
objectContaining
的話,需包含該物件完整的屬性和值,除非物件內又使用objectContaining
去做匹配
test('nested object', () => {
/**
* toMatchObject
*/
// 通過:物件內還有物件,包含完整的 props/values
expect({ position: { x: 0, y: 0 } }).toMatchObject({
position: {
x: expect.any(Number),
y: expect.any(Number),
},
});
// 主要差異處!!
// 通過:物件內還有物件,只包含部分的 props/values
expect({ position: { x: 0, y: 0 } }).toMatchObject({
position: {
x: expect.any(Number),
},
});
/**
* objectContaining
*/
// 通過:物件內還有物件,包含完整的 props/values
expect({ position: { x: 0, y: 0 } }).toEqual(
expect.objectContaining({
position: {
x: expect.any(Number),
y: expect.any(Number),
},
}),
);
// 主要差異處!!
// 失敗:物件內還有物件,但只包含部分的 props/values,而沒有再定義 expect.objectContaining
expect({ position: { x: 0, y: 0 } }).toEqual(
expect.objectContaining({
position: {
x: expect.any(Number),
},
}),
);
// 通過:物件內還有物件,但物件屬性內又定義 objectContaining
expect({ position: { x: 0, y: 0 } }).toEqual(
expect.objectContaining({
position: expect.objectContaining({
x: expect.any(Number),
}),
}),
);
});
比對字串(透過正規式)
keywords: toMatch
test('there is no I in team', () => {
expect('team').not.toMatch(/I/);
});
test('but there is a "stop" in PJCHENder', () => {
expect('PJCHENder').toMatch(/stop/);
});
判斷回傳的是否為字串:
test('but there is a "stop" in PJCHENder', () => {
expect('PJCHENder').toEqual(expect.any(String));
});
比對陣列是否包含
keywords: toContain
, toContainEqual
判斷陣列中是否包含**某一元素(原生值)**時,可以使用 toContain
:
const shoppingList = ['diapers', 'kleenex', 'trash bags', 'paper towels', 'beer'];
test('the shopping list has beer on it', () => {
expect(shoppingList).toContain('beer');
expect(new Set(shoppingList)).toContain('beer');
});
判斷陣列中是否包含某一物件,可以使用 toContainEqual
:
const users = [
{ name: 'aaron', email: 'aaron@gmail.com' },
{ name: 'pjchender', email: 'pjchender@gmail.com' },
];
expect(users).toContainEqual({ name: 'aaron', email: 'aaron@gmail.com' });
如果是要判斷陣列中是否包含某一物件中的部分屬性,可以使用 toContainEqual
搭配 expect.objectContaining()
,例如:
const users = [
{ name: 'aaron', email: 'aaron@gmail.com' },
{ name: 'pjchender', email: 'pjchender@gmail.com' },
];
// expect(users).toContainEqual({ name: 'aaron' }); // wrong: deep equality
expect(users).toContainEqual(
expect.objectContaining({
name: 'aaron',
}),
);
例外處理(Exception)
keywords: toThrow
, toThrowError
function compileAndroidCode() {
throw new ConfigError('you are using the wrong JDK');
}
test('compiling android goes as expected', () => {
expect(compileAndroidCode).toThrow();
expect(compileAndroidCode).toThrow(ConfigError);
// You can also use the exact error message or a regexp
expect(compileAndroidCode).toThrow('you are using the wrong JDK');
expect(compileAndroidCode).toThrow(/JDK/);
});
Mock Functions
Mock Functions @ Jest Docs > Introduction
const mockFn = jest.fn(() => customImplementation);
expect(mockFn).toHaveBeenCalled();
基本使用
keywords: jest.fn(<implementation>)
若想要測試一個函式,透過 Mock Functions 可以檢驗該函式被呼叫了幾次、帶入的參數為何、回傳的結果為何等等。只需透過 jest.fn
即可建立 Mock Functions,並透過此函式的 mock
屬性即可看和此函式有關的訊息:
💡
jest.fn(implementation)
只是jest.fn().mockImplementation(implementation)
的縮寫。
// 使用 jest.fn(<implementation>) 建立 Mock Function
it('mockCallback', () => {
// 建立 Mock Functions,取名為 mockCallback
const mockCallback = jest.fn((x) => 42 + x);
[0, 1].forEach(mockCallback);
// 檢查該函式被呼叫了幾次
expect(mockCallback.mock.calls.length).toBe(2);
// 該函式第一次被呼叫時的第一個參數為 0
expect(mockCallback.mock.calls[0][0]).toBe(0);
// 該函式第二次被呼叫時的第一個參數為 1
expect(mockCallback.mock.calls[1][0]).toBe(1);
// 該函式第一次被呼叫時的回傳值是 42
expect(mockCallback.mock.results[0].value).toBe(42);
});
Mock Function 預設會回傳 undefined
:
// jest.fn 預設回傳 undefined
it('default mock function', () => {
const myMock = jest.fn();
expect(myMock()).toBe(undefined);
});
設定回傳值(mock return values)
keywords: mockReturnValueOnce
, mockReturnValue
透過 Mock Function 的 API 可以去修改 mock function 的回傳值,讓每一次呼叫得到不同的回傳值:
it('default mock function', () => {
const myMock = jest.fn();
expect(myMock()).toBe(undefined);
// 透過 mockReturnValueOnce 來設定該 Function 每次呼叫後會回傳的值
myMock.mockReturnValueOnce(10).mockReturnValueOnce('x').mockReturnValue(true);
expect(myMock()).toBe(10);
expect(myMock()).toBe('x');
expect(myMock()).toBeTruthy();
});
Mock 第三方套件(mocking modules)
keywords: jest.mock(<package>)
, __mocks__
假設我們使用 node-fetch
這個套件,但我們不想要真的向 API 發送請求,而是想要模擬回傳的結果,可以使用 jest.mock(<package>)
來自動模擬 node-fetch 套件。步驟如下:
- 先找出「原程式」中要 Mock 的 module,例如
fetch
- 在「測試檔」中載入要被 Mock 的 module,例如
import fetch from 'node-fetch';
- 在「測試檔」中使用
jest.mock<path_or_name>
來 mock 該 module - (TS Optional)使用 mockFunction 這個 utility 或使用 Type Assertion
- 在「測試檔」中,撰寫該 function 的 mock return value,例如,
fetch.mockResolvedValue(users)
- 在「原程式」中確認該 function 執行的回傳值是 mock 後的結果
// users.test.js
import fetch from 'node-fetch';
// 模擬 node-fetch 這個套件
jest.mock('node-fetch');
// 使用模擬後的 fetch 方法
it('mock modules and async', async () => {
const users = [{ name: 'Bob' }];
// fetch 會自動 mock 成 jest.fn(),將可以使用所有 mockFn 提供的方法
const fetchMock = fetch.mockResolvedValue(users);
const response = await fetchMock(); // 記得要執行 fetchMock() 才會有值
expect(response).toEqual(users);
});
透過 jest.mock
模擬的內容只會作用在該隻測試檔案中,其他測試檔案即使使用的相同的套件也不會受影響(參考:jest.mock)。
另一種作法是建立一個名為 __mocks__
的資料夾,在裡面建立要 mock 的套件,例如 ./__mocks__/node-fetch.js
,jest 會自動用寫在 __mocks__
中與套件同名的檔案來 mock。
模擬套件中的某個方法
在 useAutofillPromo
這個套件中會使用到 next/router
中的 useRouter
方法,為了進行測試,需要模擬 next/router
這個套件中的 useRouter
方法。
如果是希望整隻測試檔都套用相同的 mock value 的話,可以這樣寫:
// useAutofillPromo.test.tsx
jest.mock('next/router', () => ({
useRouter() {
return {
query: {
PromoCode: 'foo',
},
};
},
}));
describe(() => {
/* ... */
});
如果是希望在不同的 test case 中有不同的 mock value 的話,可以這樣寫:
// useAutofillPromo.test.tsx
// @ts-nocheck
import * as nextRouter from 'next/router';
// 模擬 nextRouter 中的 useRouter 方法
// nextRouter.useRouter 將可以使用所有 mockFn 提供的方法
nextRouter.useRouter = jest.fn();
describe('useAutofillPromo', () => {
afterEach(() => {
// 使用 mockReset 可以把每次 mock 的 value 清空
nextRouter.useRouter.mockReset();
});
it('get promoCode after invoking refreshAutofillPromo', () => {
// 把該 function 會回傳的值進行模擬
nextRouter.useRouter.mockImplementation(() => ({
query: {
PromoCode: 'foo',
},
}));
// 也可以使用 mockReturnValue()
// nextRouter.useRouter.mockReturnValue({
// query: {
// PromoCode: 'foo',
// },
// });
// useAutofillPromo 中呼叫到的 useRouter 會是 mock 過的
const { result } = renderHook(() => useAutofillPromo());
// ...
});
});
要看 mock 回傳的結果是什麼,最簡單的方式就是到原本就會呼叫改 mock module 的檔案中去把它 console 出來,也就是這裡的 useAutofillPromo
:
// useAutofillPromo.tsx
const useAutofillPromo = () => {
const router = useRouter();
// 直接 console.log,就能知道有沒有 mock 成功 ,還有 mock 的結果為何
console.log('[useAutofillPromo]', { router });
// ...
};
要看 mock module 回傳的值,最快的方式,就是到原本就會呼叫改 mock module 的檔案中去把它 console 出來,也可以清楚知道每次 mock 的 value 有沒有被清空。
Mock 物件中的特定方法(Spy on Methods)
Spy 和 Mock 的概念基本一樣,差別在於 Spy 可以觀察一個 object 中的特定 method:
// 不改變原本 method 的實作
const mockFn = jest.spyOn(object, methodName);
// 如果希望不要使用執行原本的方法,可以搭配 mockImplementation
const mockFn = jest.spyOn(object, methodName).mockImplementation(() => customImplementation);
// 或者改用 replaceProperty 也可以
const mockFn = jest.replaceProperty(
object,
methodName,
jest.fn(() => customImplementation),
);
Mock Time
- Fake Timers @ Jest > Jest Object
- Using Fake Timers @ testing-library/react
beforeEach(() => {
// vi.setSystemTime(1687072779984);
jest.useFakeTimers({
now: 1687072779984,
});
});
afterEach(() => {
jest.useRealTimers();
});
模擬非同步的回傳結果(Mock Resolved Value)
mockFn.mockResolvedValue(value)
mockFn.mockResolvedValueOnce(value)
mockFn.mockRejectedValue(value)
mockFn.mockRejectedValueOnce(value)
// users.test.js
const fetch = require('node-fetch');
// 模擬 node-fetch 這個套件
jest.mock('node-fetch');
// 使用模擬後的 fetch 方法
it('mock modules and async', async () => {
const users = [{ name: 'Bob' }];
// 模擬後的 fetch 方法可以使用 mockResolvedValue 這個方法
// 產生一個會回傳 Promise 的函式
const fetchMock = fetch.mockResolvedValue(users);
const response = await fetchMock(); // 記得要執行 fetchMock() 才會有值
expect(response).toEqual(users);
});
模擬回呼函式(callback function with mock implementation)
mock implementations @ Jest Docs
假設有一個函式中帶有 callback function:
// session.connection 這個方法帶有兩個參數
// 第一個是 token;第二個是 callback function
connect(token, (error) => {
if (error) {
handleError(error);
} else {
const connectionId = session.connection.connectionId;
action.update({
connectionId,
isSessionConnected: true,
});
}
});
撰寫測試時可以這樣透過 mockFn.mockImplementation()
的方式,例如:
// 第一個參數帶 token,第二個帶 callback function
// 其中執行時帶入的參數 null,就會變成實際上 callback function 中 error 的值
const connect = jest.fn((token, completeHandler) => completeHandler(null));
驗證 Mock Function 如何被使用
mockFn.mock.calls
mockFn.mock.results
mockFn.mock.instances
mockFn.mockImplementation(fn)
mockFn.mockImplementationOnce(fn)
方便易用的方法:
// 該 mock 函式至少被呼叫過一次
expect(mockFunc).toHaveBeenCalled();
// 該 mock 函式至少被呼叫一次,並且帶有特定的參數
expect(mockFunc).toHaveBeenCalledWith(arg1, arg2);
// 該 mock 函式最後一次被呼叫時帶有特定的參數
expect(mockFunc).toHaveBeenLastCalledWith(arg1, arg2);
// All calls and the name of the mock is written as a snapshot
expect(mockFunc).toMatchSnapshot();
使用原生的 mock 物件做比對:
// 該 mock 函式至少被呼叫過一次
expect(mockFunc.mock.calls.length).toBeGreaterThan(0);
// 該 mock 函式至少被呼叫一次,並且帶有特定的參數
expect(mockFunc.mock.calls).toContainEqual([arg1, arg2]);
// 該 mock 函式最後一次被呼叫時帶有特定的參數
expect(mockFunc.mock.calls[mockFunc.mock.calls.length - 1]).toEqual([arg1, arg2]);
// 該 mock 函式最後一次被呼叫時的第一個參數是 42
// (note that there is no sugar helper for this specific of an assertion)
expect(mockFunc.mock.calls[mockFunc.mock.calls.length - 1][0]).toBe(42);
// A snapshot will check that a mock was invoked the same number of times,
// in the same order, with the same arguments. It will also assert on the name.
expect(mockFunc.mock.calls).toEqual([[arg1, arg2]]);
expect(mockFunc.getMockName()).toBe('a mock name');
測試非同步程式碼(Testing Asynchronous Code)
Testing Asynchronous Code @ Jest Docs > Introduction
promise
若使用的是 promise 可以直接將該 promise 在 test 的函式中 return
出來,Jest 會等待該 promise 被 resolve
後才繼續,若該 promise 被 reject
,則該測試會自動失敗:
const fetchData = require('./../modules/fetchData');
const schema = {
userId: expect.any(Number),
id: expect.any(Number),
title: expect.any(String),
completed: expect.any(Boolean),
};
test('fetch data with json placeholder', () => {
return fetchData('https://jsonplaceholder.typicode.com/todos/1').then((data) => {
expect(data).toMatchObject(schema);
});
});
async...await
const fetchData = require('./../modules/fetchData');
const schema = {
userId: expect.any(Number),
id: expect.any(Number),
title: expect.any(String),
completed: expect.any(Boolean),
};
test('fetch data with json placeholder', async () => {
expect.assertions(1);
try {
const data = await fetchData('https://jsonplaceholder.typicode.com/todos/1');
expect(data).toMatchObject(schema);
} catch (e) {
expect(e).toMatch('error');
}
});
callbacks
如果測試的程式碼中有非同步的操作,可以在 test
的函式中帶入參數 done
,如此 jest 會等到 done()
被執行時才結束測試;若 done()
一直沒有被呼叫到,則顯示測試失敗。
test('fetch data with json placeholder', (done) => {
fetchData('https://jsonplaceholder.typicode.com/todos/1')
.then((data) => {
expect(data).toMatchObject(schema);
done();
})
.catch((error) => done(error));
});
重複某一行為(Setup and Teardown)
keywords: beforeEach
, beforeAll
, afterEach
, afterAll
setup and teardown @ Jest
開始測試前,常會有一些前置的操作或設定要先被執行,這裡 Jest 提供一些 helpers 來方便使用。
每個測試前都要重複執行(repeating setup for many tests)
如果你有一些前置動作是要在許多測試前都要重複執行的,那麼可以使用 beforeEach
和 afterEach
:
// 在每一個 test 前都會先執行 beforeEach,test 後都會執行 afterEach
beforeEach(() => {
initializeCityDatabase();
});
afterEach(() => {
clearCityDatabase();
});
test('city database has Vienna', () => {
expect(isCity('Vienna')).toBeTruthy();
});
test('city database has San Juan', () => {
expect(isCity('San Juan')).toBeTruthy();
});