PJCHENder 未整理筆記

[npm] Jest 使用筆記

2019-08-29

[npm] Jest 使用筆記

@(JavaScript)[npm, JavaScript, test]

keywords: test, test-driven development, TDD

Jest @ Official Document

安裝與設定

1
$ npm install jest --save-dev

package.json 中將 test 的指令改成:

1
2
3
4
// package.json
"scripts": {
"test": "jest" // --watch, --watchAll
},

預設情況下,Jest 會自動去找:

  • __tests__ 資料夾內的 .js, .jsx, .ts, .tsx
  • .test.spec 結尾的檔案,例如,Component.test.js or Component.spec.js

預設的情況下,jest 會去找專案資料夾中的 tests 資料夾:

1
$ mkdir __tests__

執行測試:

1
$ npm test -- --coverage        # --coverage 會顯示覆蓋率

在 Jest 中使用 Babel (Import)

Using Babel @ Jest > Getting Started

預設的情況下,Jest 是在 Node 環境下執行,在 ESModule 還沒於 Node 環境普遍被支援前,可以使用 Babel。

安裝 babel 相關套件

1
$ npm install babel-jest @babel/core @babel/preset-env -D

新增設定檔 babel.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
// babel.config.js
module.exports = {
presets: [
[
'@babel/preset-env',
{
targets: {
node: 'current',
},
},
],
],
};

檢視測試覆蓋率

檢視測試覆蓋率有兩種方式,第一種是透過指令:

1
$ npm test -- --coverage        # --coverage 會顯示覆蓋率

第二種是在 package.json 中設定:

1
2
3
4
5
6
7
// package.json
"scripts": {
"test": "jest"
},
"jest": {
"collectCoverage": true
},

或者:

1
2
3
4
// package.json
"scripts": {
"test": "jest --coverage"
},

Using Matcher

Using Matcher @ Jest Docs > Introduction

常用的匹配

toBe:比對值是否相同

1
2
3
4
// toBe 使用 Object.is 來比對,若想要比對物件內容是否一樣需使用 toEqual
test('two plus two is four', () => {
expect(2 + 2).toBe(4);
});

toEqual:比對物件內容是否相同

1
2
3
4
5
test('object assignment', () => {
const data = {one: 1};
data['two'] = 2;
expect(data).toEqual({one: 1, two: 2});
});

not:是否不同

1
2
3
test('adding positive numbers is not zero', () => {
expect(1 + 3).not.toBe(0);
})

資料型別檢驗

keywords: expect.any(), expect.anyThing()
1
2
expect.anything()   // matches anything but null or undefined
expect.any(Number) // matches anything that was created with the given constructor

匹配的時候記得要使用 expect.toEqual() 的方法,例如:

1
2
const number = 3;
expect(number).toEqual(expect.any(Number));

比對真偽值

keywords: toBeNull, toBeUndefined, toBeDefined, toBeTruthy, toBeFalsy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 而不要用 toBetoEqual,因為可能會有進位問題:

1
2
3
4
5
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 的 toContaintoContainEqual 的方法使用

物件內不包含物件

使用 toMatchObjectobject.containing() 有一樣的效果。只要 matched object 的屬性都有列在 expected object 內即通過

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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
});

物件內包含物件

當物件內又含有其他物件時,使用 toMatchObjectobject.containing() 的效果不同:

  • 通常用這個:使用 toMatchObject 中需包含所列出的屬性和值
  • 使用 objectContaining 的話,需包含該物件完整的屬性和值,除非物件內又使用 objectContaining 去做匹配
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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
1
2
3
4
5
6
7
test('there is no I in team', () => {
expect('team').not.toMatch(/I/);
});

test('but there is a "stop" in PJCHENder', () => {
expect('PJCHENder').toMatch(/stop/);
});

判斷回傳的是否為字串:

1
2
3
test('but there is a "stop" in PJCHENder', () => {
expect('PJCHENder').toEqual(expect.any(String));
});

匹配陣列是否包含

keywords: toContain, toContainEqual

判斷陣列中是否包含**某一元素(原生值)**時,可以使用 toContain

1
2
3
4
5
6
7
8
9
10
11
12
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

1
2
3
4
5
6
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(),例如:

1
2
3
4
5
6
7
8
9
10
11
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
1
2
3
4
5
6
7
8
9
10
11
12
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

基本使用

keywords: jest.fn(<implementation>)

若想要測試一個函式,透過 Mock Functions 可以檢驗該函式被呼叫了幾次、帶入的參數為何、回傳的結果為何等等。只需透過 jest.fn 即可建立 Mock Functions,並透過此函式的 mock 屬性即可看和此函式有關的訊息:

💡 jest.fn(implementation) 只是 jest.fn().mockImplementation(implementation) 的縮寫。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 使用 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

1
2
3
4
5
// 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 的回傳值,讓每一次呼叫得到不同的回傳值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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();
});

模擬套件(mocking modules)

keywords: jest.mock(<package>)

假設我們使用 axios 這個套件,但我們不想要真的向 API 發送請求,而是想要模擬回傳的結果,可以使用 jest.mock(<package>) 來自動模擬 axios 套件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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' }];

const fetchMock = fetch.mockResolvedValue(users);
const response = await fetchMock(); // 記得要執行 fetchMock() 才會有值
expect(response).toEqual(users);
});

模擬非同步的回傳結果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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() 的方式,例如:

1
2
3
// 第一個參數帶 token,第二個帶 callback function
// 其中執行時帶入的參數 null,就會變成實際上 callback function 中 error 的值
const connect = jest.fn((token, completeHandler) => completeHandler(null));

使用匹配與客製化匹配(Custom Matchers)

方便易用的方法:

1
2
3
4
5
6
7
8
9
10
11
// 該 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 物件做比對:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 該 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,則該測試會自動失敗:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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() 一直沒有被呼叫到,則顯示測試失敗。

1
2
3
4
5
6
7
8
test('fetch data with json placeholder', (done) => {
fetchData('https://jsonplaceholder.typicode.com/todos/1').then(
(data) => {
expect(data).toMatchObject(schema);
done();
}
);
});

重複某一行為(Setup and Teardown)

keywords: beforeEach, beforeAll, afterEach, afterAll

setup and teardown @ Jest

開始測試前,常會有一些前置的操作或設定要先被執行,這裡 Jest 提供一些 helpers 來方便使用。

每個測試前都要重複執行(repeating setup for many tests)

如果你有一些前置動作是要在許多測試前都要重複執行的,那麼可以使用 beforeEachafterEach

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 在每一個 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();
});

測試前執行一次(One-Time Setup)

有些時候,這些設定只需要在開始前後(所有 test 執行前後)執行一次就好,這時候可以使用 beforeAllafterAll 的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 在所有 test 執行前先執行 beforeAll;所有 test 執行完後再執行 afterAll
beforeAll(() => {
return initializeCityDatabase();
});

afterAll(() => {
return clearCityDatabase();
});

test('city database has Vienna', () => {
expect(isCity('Vienna')).toBeTruthy();
});

test('city database has San Juan', () => {
expect(isCity('San Juan')).toBeTruthy();
});

Scope

預設的情況下,beforeafter套用到一個檔案中的所有測試(test),透過 describe 你可以把這些測試分成不同區塊,在 describe 區塊中使用的 beforeafter 只會在該區塊內有作用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// beforeAll, afterAll 會在該檔案中的所有 test 前後執行
beforeAll(() => console.log('1 - beforeAll'));
afterAll(() => console.log('1 - afterAll'));

// beforeEach, afterEach 會在該檔案中的每一個 test 前後執行
beforeEach(() => console.log('1 - beforeEach'));
afterEach(() => console.log('1 - afterEach'));

test('', () => console.log('1 - test'));

describe('Scoped / Nested block', () => {
// 這裡的 before, after 只會作用在此 block 內
beforeAll(() => console.log('2 - beforeAll'));
afterAll(() => console.log('2 - afterAll'));
beforeEach(() => console.log('2 - beforeEach'));
afterEach(() => console.log('2 - afterEach'));
test('', () => console.log('2 - test'));
});

// 1 - beforeAll
// 1 - beforeEach
// 1 - test
// 1 - afterEach

// 2 - beforeAll
// 1 - beforeEach
// 2 - beforeEach
// 2 - test
// 2 - afterEach
// 1 - afterEach
// 2 - afterAll
// 1 - afterAll

測試事件(Event and EventEmitter)

若要測試事件,可以使用 Node.js 中提供的 Event 和 EventEmitter

例如要測試第三方套件的 session 物件,該物件有 on, offdispatch 事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// session.js
// 建立第三方套件 session 的 mock modules
const EventEmitter = require('events');
const sessionEvent = new EventEmitter();

const session = {
dispatch: jest.fn(type => {
sessionEvent.emit(type);
}),
on: jest.fn((type, callback) => {
sessionEvent.on(type, callback);
}),
off: jest.fn((type, callback) => {
sessionEvent.off(type, callback);
}),
};

module.exports = session;

測試時:

1
2
3
4
5
6
7
8
9
10
11
// session.test.js
const sessionEvent = require('./session');

it('test sessionEvent', () => {
const eventHandler = jest.fn();
sessionEvent.on('eventName', eventHandler);
expect(eventHandler.mock.calls.length).toBe(0);

sessionEvent.dispatch('eventName');
expect(eventHandler.mock.calls.length).toBe(1);
});

範例程式碼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
describe('Filter function', () => {
test('it should filter by a search term(link)', () => {
// actual test
const input = [
{ id: 1, url: 'https://www.url1.dev' },
{ id: 2, url: 'https://www.url2.dev' },
{ id: 3, url: 'https://www.link3.dev' },
];

const output = [{ id: 3, url: 'https://www.link3.dev' }]

expect(filterByTerm(input, 'LINK')).toEqual(output);
});
});

function filterByTerm(inputArr, searchTerm) {
return inputArr.filter((item) => item.url.match(searchTerm));
}

API Reference

Mock Functions

Mock Functions @ Jest API

Jest Object

The Jest Object @ Jest API

Mock Modules

Mock Functions

Mock Timers

Misc

參考資源

掃描二維條碼,分享此文章