利用模擬物件進行互動測試 | 單元測試的藝術 第 3 版 | 閱讀筆記
26 Apr 2024「單元測試的藝術」讀書會 - 利用模擬物件進行互動測試 (The Art of Unit Testing, 3e - Interaction Testing Using Mock Objects) 閱讀筆記。
簡介
- 如何確認是否正確的與外部依賴互動?可以檢查是誰呼叫,以及帶入什麼參數來做判斷。在實作上是利用 mock object 來取代外部依賴,並在測試中驗證這些 mock object 的互動情況。注意,一個測試只驗證一個需求,因此一次只能有一個 mock object。
- 帶入 Mock 的方法有:利用 mock object 來取代外部依賴的作法有以下幾種:當成參數帶入、模組抽象化、帶入函式(分為有無 curry 兩種)、物件導向的介面。
- 這個模擬物件在概念上類似 Jest 的
jest.spyOn
功能,用於監視函式或物件的呼叫與使用情況。
當成參數帶入
步驟:
- 在原本的函式增加一個新的參數,由這個參數帶入要呼叫的第三方函式。
- 在測試程式中,實作 mock function 並將其當成參數帶入函式。
優點:由於是當成參數帶入,降低呼叫者的負擔,降低呼叫者與第三方函式的耦合度。
舉例來說,checkValentinesDay
函式會檢查今天是否為情人節,若今天是 2 月 14 日,就回傳字串 情人節快樂
,若不是則回傳 今天不是情人節
。對於 checkValentinesDay
來說,取得今天的日期的函式 getToday
是一個外部依賴,若想確認 getToday
有沒有被正確呼叫,可以在測試程式中模擬取得日期的函式當成參數傳入 checkValentinesDay
函式中,然後來監控這個模擬,進而確認是否正確的呼叫 getToday
。
const checkValentinesDay = (getToday) => {
const today = getToday();
return today === '2/14' ? '情人節快樂' : '今天不是情人節';
};
由於 mockGetToday
有正確的被 checkValentinesDay
,因此 today 的值符合預期,間接確認 getToday
會被正確呼叫。
describe('checkValentinesDay', () => {
it('2/12 should not be Valentines Day', () => {
let today = '';
const mockGetToday = () => {
today = '2/12';
return today;
};
expect(checkValentinesDay(mockGetToday)).toBe('今天不是情人節');
expect(today).toBe('2/12');
});
it('2/14 should be Valentines Day', () => {
let today = '';
const mockGetToday = () => {
today = '2/14';
return today;
};
expect(checkValentinesDay(mockGetToday)).toBe('情人節快樂');
expect(today).toBe('2/14');
});
});
模組抽象化
步驟:
- 建立一個新的模組,將原本的模組依賴關係抽象化。
- 取代部份模組的依賴關係。
- 測試完畢後,將模組還原。
優點:簡單。
缺點:需要手動 inject 和 reset 模組。
舉例來說,在先前模組注入中提到的例子,以下這段設定即是手動抽象畫介面與 inject 模組。
import bakeAllCakes, { bakeMatchaRoll } from './bakeUtils';
jest.mock('./bakeUtils', () => {
const originalModule = jest.requireActual('./bakeUtils');
return {
__esModule: true,
...originalModule,
default: jest
.fn()
.mockReturnValue('Chocolate Pudding and Matcha Roll are all baked.'),
};
});
測試執行完畢後,清除模擬,還原為原本的模組。
afterEach(() => {
jest.clearAllMocks();
});
帶入函式
- 有 Curry:透過
curry
函式將函式的參數拆開,並將部份參數固定,讓呼叫者只需傳入剩餘的參數。 - 無 Curry:透過工廠函式將函式的參數固定,並回傳一個預設的函式。
物件導向的介面
Class-based Design with Constructor
利用 class 的方式實作,再透過 constructor 強制呼叫者提供參數。
改寫前面的例子 checkValentinesDay
函式,將取得今天日期的函式注入到建構子 (constructor) 中,並且在 checkValentinesDay
函式中呼叫這個函式,最後確認 today 的值是否正確。
class ValentinesDayChecker {
constructor(getToday) {
this.getToday = getToday;
this.today = '';
}
checkValentinesDay() {
this.today = this.getToday();
return this.today === '2/14' ? '情人節快樂' : '今天不是情人節';
}
}
describe('checkValentinesDay', () => {
it('2/12 should not be Valentines Day', () => {
const getToday = () => '2/12';
const checker = new ValentinesDayChecker(getToday);
expect(checker.checkValentinesDay()).toBe('今天不是情人節');
expect(checker.today).toBe('2/12');
});
it('2/14 should be Valentines Day', () => {
const getToday = () => '2/14';
const checker = new ValentinesDayChecker(getToday);
expect(checker.checkValentinesDay()).toBe('情人節快樂');
expect(checker.today).toBe('2/14');
});
});
Interface-based Design
利用介面的方式實作,透過介面來定義依賴的行為,並注入依賴。
優點:有彈性、耦合度低,且可以避免錯誤。
interface TimeProvider {
getToday: () => string;
today: string;
}
class FakeTimeProvider implements TimeProvider {
constructor(private fakeDay: string) {}
getToday() {
this.today = this.fakeDay;
return this.fakeDay;
}
}
class ValentinesDayChecker {
constructor(private timeProvider: TimeProvider) {}
checkValentinesDay() {
const today = this.timeProvider.getToday();
return today === '2/14' ? '情人節快樂' : '今天不是情人節';
}
}
describe('checkValentinesDay', () => {
it('2/12 should not be Valentines Day', () => {
const checker = new ValentinesDayChecker(new FakeTimeProvider('2/12'));
expect(checker.checkValentinesDay()).toBe('今天不是情人節');
expect(checker.today).toBe('2/14');
});
it('2/14 should be Valentines Day', () => {
const checker = new ValentinesDayChecker(new FakeTimeProvider('2/14'));
expect(checker.checkValentinesDay()).toBe('情人節快樂');
expect(checker.today).toBe('2/14');
});
});
注意:
- 在複雜介面的狀況下,若參數太冗長,可以考慮使用部份模擬,意即只實作部份的方法。
- 介面這解法適用於介面完全可控的狀況下,或是介面恰好符合最小介面原則的狀況。