讀 Jest Doc - 測試替身

  1. 測試替身
    1. 那 Mock 要怎麼用?
  2. Mock Functions
    1. Using a mock function
    2. .mock property
    3. Mock Return Values
    4. Mocking Modules
    5. Mock Implementations
    6. Mock Names
    7. Custom Matchers
    8. 先告一段落

測試替身

戲劇演員的替身, 在英文裡叫 body double 或 stunt double[1]

待測物與相依元件[2]

  • SUT:System Under Test 或 Software Under Test 的簡寫,代表待測程式
  • DOC:Depended-on Component (相依元件),又稱為 Collaborator (合作者)。DOC 是 SUT 執行的時候會使用到的元件。

在實案例中,模組之間是會相依在一起的,執行單元測試時,要視情況將「與外部溝通」的相依元件進行替換,這是一種不修改產品 code 的替換手法,例如有些情況,是不確定相依元件的穩定性,而進行 test double 的替換。[3]

  1. 一來保持測試速度都是在記憶體內完成,不會到讀寫檔案、Web API…等,與外部資源互動而拖長測試時間。
  2. 針對問題的範圍進行測試,可以視情況的縮小 specical case,也就是說,可以讓 SUT 不依賴原本的 DOC 也可以測試。

xunitpatterns 裡特別分成五種 Test Double
分別是 Dummy Object、Test Stub、Test Spy、Fake Object 與 Mock Object。

其實,就是假的,到超級假的等級區分。

No Implement > Dummies > Stubs > Spies > Facke > Real Implement

常見的術語,只要知道 Stub、Spy、Mock

  • Stub: 回傳 hack-code 內容。
  • Spy: 跟著真實本體,告訴你他發生了什麼。
  • Mock: 假的本體替身,看他身上的痕跡,了解他發生了什麼。

如果說 jest.fn 能夠作為一個 Function 的替身,那麼 jest.mock 就是能模擬整個模組的 Mock!
– 神Q超人

jest.spyOn()

SUT 一直享受著假資料帶來的美好,當過程中動用到 DOC 邏輯等或修改回傳值時。上線時卻會有「什麼!這傢伙怎麼會回傳這種東西?測試明明就沒有錯!」之類的慘劇發生。

但是 jest.spyOn() 不同,它會去重現 DOC 的邏輯,即使在任何時候修改了它的任何內容都一樣,SUT 在測試時便會發現 DOC 已經和之前測試時有所不同,不論是邏輯、回傳值等等,這時候捕獲了真實資料的測試結果才會有效。

那 jest.mock() 整個那麼假,可以用在哪裡?用在模擬無法重現邏輯、或者根本不會去變動的第三方套件時。
– 神Q超人

那 Mock 要怎麼用?

Q: 是不是一次只測一個 function 其它都 mock 掉呢?
Q: 模組相依一起測算是整合測試嗎?

這些問題,也許可以用探討單元測試和整合測試的涵蓋範圍來解答

重點:

  1. 單元測試有兩派: 孤立型(Solitary)or 社交型(Sociable)
  2. 如果你是 BDD 或 TDD 的實踐者,那麼你的單元測試就可能是跨多個類別的 社交型單元測試,因為測試的對象是 一個行為,而非一個類別。
  3. 2017 年,Uncle Bob 在 Twitter 有對網友說明 TDD 單元測試的對象是一個"行為",而非一個"方法"

Mock Functions

Mock 函數有這些作用

  • 取代真正的函式程式碼 (建立替身函數)
  • 替代函式被呼叫與接收參數 (呼叫替身函數)
  • 當 new 一個物件實例時,替代物件實例的建構式,指定函式回傳值 (指定替身函數回傳值)

有兩種方式可以擁有 mock functions

  1. 建立在測試用的 mock 函數
  2. 手動 mock 覆蓋相依模組

Using a mock function

Let’s imagine we’re testing an implementation of a function forEach, which invokes a callback for each item in a supplied array.

假設我們寫了一個 forEach 想要測試它,而執行它,需要 Array 和 callback 當作參數。

function forEach(items, callback) {
for (let index = 0; index < items.length; index++) {
callback(items[index]);
}
}

為了要測試 forEach,可以使用一個 mock function ,並且檢查它被呼叫的情況。

const mockCallback = jest.fn(x => 42 + x);
forEach([0, 1], mockCallback);

// The mock function is called twice
expect(mockCallback.mock.calls.length).toBe(2);

// The first argument of the first call to the function was 0
expect(mockCallback.mock.calls[0][0]).toBe(0);

// The first argument of the second call to the function was 1
expect(mockCallback.mock.calls[1][0]).toBe(1);

// The return value of the first call to the function was 42
expect(mockCallback.mock.results[0].value).toBe(42);

.mock property

每個 mock function,都有一個叫 .mock 的屬性,裡面會記載著 function 如何被呼叫、丟了什麼參數,回傳什麼回傳值。隨著 JavaScript 的持性, mock function 也會記得每次被呼叫時的 this 是誰。

myMock
NaN
NaN
NaN
NaN
NaN
NaN
NaN
NaN
NaN
NaN
NaN
NaN
NaN

const a = new myMock(); // 第一次呼叫
const b = {
name: "b"
};
const bound = myMock.bind(b);
bound(); // 第二次呼叫

console.log(myMock.mock.instances);
// > [ <a>, <b> ]

// 實際測出來,這樣印較清楚
console.log(myMock.mock.instances.constructor.name, "\n", JSON.stringify(myMock.mock.instances));

實測印出

console.log 2020-04-16/mock.test.js:11
Array
[{},{"name":"b"}]

mock 物件的內容,用在斷言下面幾種情況

  1. mock function 吃什麼參數
  2. mock function 在測試中的回傳值
  3. mock function new 了幾個實例

下面的實際執行程式碼,也可以直接翻譯。

test('mock object of mock function as call mockFunction', () => {
const someMockFunction = jest.fn(() => 'return value');
someMockFunction('first arg', 'second arg');

// someMockFunction.假物件屬性.呼叫.次數
expect(someMockFunction.mock.calls.length).toBe(1);

// someMockFunction.假物件屬性.呼叫[第幾次][第幾個參數]
expect(someMockFunction.mock.calls[0][0]).toBe('first arg');
expect(someMockFunction.mock.calls[0][1]).toBe('second arg');

// someMockFunction.假物件屬性.回傳值[第幾次]
expect(someMockFunction.mock.results[0].value).toBe('return value');
});

test('mock object of mock function as new mockFunction', () => {
const someMockFunction = jest.fn();
const a = new someMockFunction();
a.name = 'test';
const b = new someMockFunction();

// someMockFunction.假物件屬性.用 new 呼叫.次數
expect(someMockFunction.mock.instances.length).toBe(2);

// someMockFunction.假物件屬性.用 new 呼叫[第幾次]的物件.name
expect(someMockFunction.mock.instances[0].name).toEqual('test');
});

Mock Return Values

執行假物件的回傳值,可以在呼叫前 mockReturnValueOnce 快速設定。
不用寫任何邏輯的方式,直接設定回傳值。

test('return Value of mock function', () => {
const myMock = jest.fn();

// console.log(myMock());
// > undefined
expect(myMock()).toBeUndefined();

myMock.mockReturnValueOnce(10)
.mockReturnValueOnce('x')
.mockReturnValue(true);

// console.log(myMock(), myMock(), myMock(), myMock());
// > 10, 'x', true, true
expect(myMock()).toBe(10);
expect(myMock()).toBe('x');
expect(myMock()).toBe(true);
expect(myMock()).toBe(true);

console.log(JSON.stringify(myMock.mock.results));
});
console.log 2020-04-16/returnValue.test.js:19
[{
"type":"return"
}, {
"type":"return","value":10
}, {
"type":"return","value":"x"
}, {
"type":"return","value":true
}, {
"type":"return","value":true
}]

在呼叫之前,直接塞回傳值。取代真實行為時,避免需要複雜的 stub


test('mock to stub', () => {
const filterTestFn = jest.fn();

filterTestFn
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);

const result = [11, 12].filter(num => filterTestFn(num));

console.log(result);
// > [11]
console.log(filterTestFn.mock.calls);
// > [ [11], [12] ]
});

實際上,該 mock 掉的都是一些相依套件的情況。
但是技術上是相同的,避免在假物件內實作測不到的邏輯

Mocking Modules

來看看更接近真實案例的 code,對 axios 進行 mock!

// users.js
import axios from 'axios';

class Users {
static all() {
return axios.get('/users.json').then(resp => resp.data);
}
}

export default Users;

為了不要真的發送 API 又不修改 axios,又要執行測試,就是要把 axios 模組給 mock 起來。

mock 好了之後,axios.get() 就變成 mock function ,可以用 .mockResolvedValue 設定它回傳 prmise 時,裡面要帶什麼值。

// users.test.js
import axios from 'axios';
import Users from './users';

jest.mock('axios');

test('should fetch users', () => {
const users = [{name: 'Bob'}];
axios.get.mockResolvedValue({
data: users
});

return Users.all().then(data => expect(data).toEqual(users));

});

或者用 axios.get.mockImplementation(() => Promise.resolve(resp)) 也可以

這幾個差在哪?
mockResolvedValue: 直接定回傳 promise
mockImplementation: 實作 function 內容
jest.fn(): 實作 function 內容

注意: jest.fn(implementation)jest.fn().mockImplementation(implementation) 的縮寫.[4]

Mock Implementations

假實作 fake 比起假回傳的 Stub 來得更強大。強大到幾乎可以取代假回傳 (stub),但是就是自己要寫很多 code。

test('mock function', () => {
const myMockFn = jest.fn(callback => callback(null, true));

myMockFn((err, val) => console.log(val));
// 印出 true

expect(myMockFn.mock.calls[0][0]).toBeInstanceOf(Function);
});

mockImplementation 可以定義一個 mock function 的預設的假行為
瘋狂的完全取代原本的模組,就算它還沒有實作。(危險)

emptyImplement.js

module.exports = function () {
// some implementation;
};

emptyImplement.test.js

jest.mock('./emptyImplement'); // this happens automatically with automocking
const emptyImplement = require('./emptyImplement');

test('mock empty module', () => {
// emptyImplement is a mock function
emptyImplement.mockImplementation(() => 42);

expect(emptyImplement()).toBe(42);
});

覆蓋率是 0

-------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------------|---------|----------|---------|---------|-------------------
All files | 100 | 100 | 0 | 100 |
emptyImplement.js | 100 | 100 | 0 | 100 |
------------------|---------|----------|---------|---------|-------------------

當你需要「假物件實作免洗筷」時,可以使用 mockImplementationOnce

const myMockFn = jest
.fn()
.mockImplementationOnce(cb => cb(null, true))
.mockImplementationOnce(cb => cb(null, false));

myMockFn((err, val) => console.log(val));
// > true

myMockFn((err, val) => console.log(val));
// > false

免洗筷用完時,再呼叫會跑預設假行為

test('mock mockImplementationOnce call lots of times', () => {
const myMockFn = jest
.fn(() => 'default')
.mockImplementationOnce(() => 'first call')
.mockImplementationOnce(() => 'second call');

expect(myMockFn()).toMatch('first call');
expect(myMockFn()).toMatch('second call');
expect(myMockFn()).toMatch('default');
expect(myMockFn()).toMatch('default');

expect(myMockFn.mock.calls[0][0]).toBeUndefined();
expect(myMockFn.mock.calls[1][0]).toBeUndefined();
expect(myMockFn.mock.calls[2][0]).toBeUndefined();
expect(myMockFn.mock.calls[3][0]).toBeUndefined();
expect(myMockFn.mock.calls.length).toBe(4);
});

通常這種 免洗筷 都是用來應付鍊狀呼叫
而鍊狀呼叫的假物件,就是回傳 this ,可以使用 mockReturnThis() 來實作。

疑問: axios.create() 就可以這樣做?

const otherObj = {
myMethod: jest.fn(function () {
return this;
}),
};

可以寫成

const myObj = {
myMethod: jest.fn().mockReturnThis(),
};

Mock Names

一個 function 都會有一個 name,就算是 jest.fn() 也是。

  • 可以用 .mockName() 給一個 mockName。
  • 可以用 .getMockName() 要取得 function name。
test('mock name', () => {
const myMockFn = jest
.fn()
.mockReturnValue('default')
.mockImplementation(scalar => 42 + scalar);

expect(myMockFn.getMockName()).toMatch("jest.fn()");

myMockFn.mockName('add42');
expect(myMockFn.getMockName()).toMatch("add42");
});

Custom Matchers

為了 mock function 而生的 Matcher

test('test mock function matcher', () => {
const mockFunc = jest.fn();

const arg1 = 'chris'
const arg2 = 'mary'
mockFunc(arg1, arg2);
expect(mockFunc).toHaveBeenCalled();

// The mock function was called at least once with the specified args
expect(mockFunc).toHaveBeenCalledWith(arg1, arg2);

// The last call to the mock function was called with the specified args
expect(mockFunc).toHaveBeenLastCalledWith(arg1, arg2);

// All calls and the name of the mock is written as a snapshot
expect(mockFunc).toMatchSnapshot();
});

其中的 Snapshot 第一次執行,會儲存執行結果。
第二次執行,會拿上次執行的結果與這次比對。

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`test mock function matcher 1`] = `
[MockFunction] {
"calls": Array [
Array [
42,
43,
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
}
`;

以上這些都是語法糖。

// The mock function was called at least once
expect(mockFunc.mock.calls.length).toBeGreaterThan(0);

// The mock function was called at least once with the specified args
expect(mockFunc.mock.calls).toContainEqual([arg1, arg2]);

// The last call to the mock function was called with the specified args
expect(mockFunc.mock.calls[mockFunc.mock.calls.length - 1]).toEqual([
arg1,
arg2,
]);

// The first arg of the last call to the mock function was `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');

先告一段落

對於 Jest 系列,其實讀書會還在進行中,預計再過一個月左右。
會針對幾個議題做深入的討論。

不過那個與測試入門就比較不相關,就再看是不是需要寫文章跟大家介紹了。
感謝支持,如果有想看的文章類型也歡迎在底下留言哦~

我們下次見


  1. “替身”, “分身” 的英文怎麼說? ↩︎

  2. Test Double(1):什麼是測試替身? - 搞笑談軟工 ↩︎

  3. When To Use It - Test Double at XUnitPatterns.com ↩︎

  4. mockFn.mockImplementation(fn) ↩︎