利用 Stub 隔絕依賴 | 單元測試的藝術 第 3 版 | 閱讀筆記
18 Apr 2024「單元測試的藝術」讀書會 - 利用 Stub 隔絕依賴 (The Art of Unit Testing, 3e - Breaking Dependencies with Stubs) 閱讀筆記。
在實作單元測試時,為了有效測試特定情境和條件,必須利用 stub 對給定的函式或模組進行隔絕依賴,目的是為了讓測試更加穩定,避免造成不穩定的測試結果。
本文將會討論 stub 的目的、使用情境,以及如何透過不同的注入技術來隔絕依賴。
依賴有哪些
依賴 (dependency) 有哪些?
- incoming dependency:給定輸入的資料,通常是經由先前的操作而被動流入 unit of work 的資料。例如:query 資料庫的資料、API 的回應。
- outgoing dependency:呼叫外部函式,這是 unit of work 的一種 exit point。例如:寫入資料庫、呼叫 API 或 webhook。
如何造假:Stub vs Mock
為了隔絕依賴,我們需要使用一些造假的方法,這些造假的方法統稱為 test double 或 fake,可再根據目的分為 stub 和 mock:
- stub 是指用於提供假的輸入,無論是行為或資料,目的是隔離來自其他元件組件的依賴 (incoming dependency)。stub 通常用於取代不可靠的依賴,確保測試的獨立性,避免外部變化影響測試結果。
- mock 是指模擬實作並取代原本的實作,用於模擬外部元件或函式的行為,例如:呼叫外部 API、寫入資料庫等。mock 通常用於隔離 outgoing dependency,作為假的輸出。由於這是 exit point,因此建議一個測試只要有一個 mock 就好。
整理相關名詞:
類型 | 定義 | 目的 | 範例 |
---|---|---|---|
test double 或 fake | stub 和 mock 的總稱 | - | - |
stub | 假的輸入,模擬實作並取代原本的實作 | 隔離 incoming dependency | 假造的測試資料、物件或函式 |
mock | 假的輸出,模擬實作並取代原本的實作 | 隔離 outgoing dependency | 呼叫假的服務、寫入假的資料庫 |
spy | 監控呼叫,模擬實作但不取代原本的實作 | 記錄互動 | 是否被正確呼叫 |
利用 Stub 隔絕依賴的方法
列出三種利用 stub 隔絕依賴的方法:函式注入、模組注入、物件導向注入。
函式注入
利用函式注入 (functional injection) 的技術來隔絕依賴,可分為以下兩種:function parameter 與 partial application。
Function Parameter
將依賴包裝成函式,把結果傳進 SUT。這個解法的好處是最為簡便,有效降低測試的複雜度。
舉例來說,checkValentinesDay
函式會檢查今天是否為情人節,若今天是 2 月 14 日,就回傳字串 情人節快樂
,若不是則回傳 今天不是情人節
。對於 checkValentinesDay
來說,今天的日期是一個依賴,我們可以將取得日期的函式當成參數傳入 checkValentinesDay
函式中,這樣就可以隔絕依賴。
const checkValentinesDay = (today) => {
return today === '2/14' ? '情人節快樂' : '今天不是情人節';
};
describe('checkValentinesDay', () => {
it('2/12 should not be Valentines Day', () => {
const getToday = () => '2/12';
const today = getToday();
expect(checkValentinesDay(today)).toBe('今天不是情人節');
});
it('2/14 should be Valentines Day', () => {
const getToday = () => '2/14';
const today = getToday();
expect(checkValentinesDay(today)).toBe('情人節快樂');
});
});
Partial Application
利用 partial application (或稱 currying、higher-order factory function) 將依賴包裝成函式,把函式傳進 SUT。相較前一方法更為精簡。
const checkValentinesDay = (fn) => {
return fn() === '2/14' ? '情人節快樂' : '今天不是情人節';
};
describe('checkValentinesDay', () => {
it('2/12 should not be Valentines Day', () => {
const getToday = () => '2/12';
expect(checkValentinesDay(getToday)).toBe('今天不是情人節');
});
it('2/14 should be Valentines Day', () => {
const getToday = () => '2/14';
expect(checkValentinesDay(getToday)).toBe('情人節快樂');
});
});
模組注入
一個模組(module)通常包含多個方法,開發者在實作測試時不見得需要對所有方法進行模擬,可能只需要模擬特定方法以指定輸入來得到特定的輸出,這時就可以使用模組注入 (modular injection) 的技術。
舉例來說,bakeUtils.js
這隻檔案包含以下幾個函式 bakeChocolatePudding
、bakeLemonTart
、bakeMatchaRoll
和 bakeAllCakes
,預設會匯出 bakeAllCakes
這個函式。
const bakeChocolatePudding = () => 'Chocolate Pudding is baked.';
const bakeLemonTart = () => 'Lemon Tart is baked.';
const bakeMatchaRoll = () => 'Matcha Roll is baked.';
const bakeAllCakes = () =>
'Chocolate Pudding, Lemon Tart and Matcha Roll are all baked.';
export default bakeAllCakes;
export { bakeChocolatePudding, bakeLemonTart, bakeMatchaRoll };
實作測試如下,利用 jest.mock
模擬 bakeUtils
module,並且用 jest.requireActual('./bakeUtils')
取得真實的 bakeUtils
這個 module,帶入不需要模擬的函式,便能夠保留不想被取代的實作細節。
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.'),
};
});
因此,當呼叫 bakeAllCakes
時,就會執行 mock 的部份而回傳假造的字串「Chocolate Pudding and Matcha Roll are all baked.」,而非原本實作的結果。
describe('bakeAllCakes', () => {
it('should bake Chocolate Pudding and Matcha Roll', () => {
expect(bakeAllCakes()).toBe(
'Chocolate Pudding and Matcha Roll are all baked.'
);
});
});
然而,當呼叫 bakeMatchaRoll
,由於仍是帶入原本的實作細節,因此並沒有改變輸出的字串。
describe('bakeMatchaRoll', () => {
it('should bake Matcha Roll', () => {
expect(bakeMatchaRoll()).toBe('Matcha Roll is baked.');
});
});
這樣只 mock 模組的某部份的方式,便能讓開發者任意的控制模組個別部份的行為,以及減少不必要的模擬,以便進行測試。
物件導向注入
物件導向注入 (object-oriented injection) 是指透過建立物件並注入依賴,以達到隔絕依賴的目的。物件導向注入有以下幾種技術:建構子、注入物件、提取共同介面。
建構子
改寫前面的例子 checkValentinesDay
函式,將取得今天日期的函式注入到建構子 (constructor) 中,並且在 checkValentinesDay
函式中呼叫這個函式。
class ValentinesDayChecker {
constructor(getToday) {
this.getToday = getToday;
}
checkValentinesDay() {
const today = this.getToday();
return 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('今天不是情人節');
});
it('2/14 should be Valentines Day', () => {
const getToday = () => '2/14';
const checker = new ValentinesDayChecker(getToday);
expect(checker.checkValentinesDay()).toBe('情人節快樂');
});
});
利用 constructor 的缺點是:(1) 當依賴過多時,建構子會變得很長,且不易閱讀;(2) 和依賴緊密耦合,不易測試。解法是拆解依賴,將依賴注入到 provider 中,並透過 provider 來注入依賴。
注入物件
將依賴當成函式注入建構子雖然能隔絕依賴,但可能會有過於冗長與耦合度過高的問題,以下改用 provider 會更有彈性。
const getToday = () => {
const today = new Date();
const month = today.getMonth() + 1;
const day = today.getDate();
return `${month}/${day}`;
};
function TimeProvider(fakeDay) {
this.getToday = function () {
return getToday;
};
}
class ValentinesDayChecker {
constructor(timeProvider) {
this.getToday = timeProvider.getToday;
}
checkValentinesDay() {
const today = this.getToday();
return today === '2/14' ? '情人節快樂' : '今天不是情人節';
}
}
function FakeTimeProvider(fakeDay) {
this.getToday = function () {
return fakeDay;
};
}
describe('checkValentinesDay', () => {
it('2/12 should not be Valentines Day', () => {
const checker = new ValentinesDayChecker(FakeTimeProvider('2/12'));
expect(checker.checkValentinesDay()).toBe('今天不是情人節');
});
it('2/14 should be Valentines Day', () => {
const checker = new ValentinesDayChecker(FakeTimeProvider('2/14'));
expect(checker.checkValentinesDay()).toBe('情人節快樂');
});
});
這樣照樣造句的方式稱為 duck typing。
提取共同介面
有彈性是好的,但是必須要有規範來避免錯誤,這時可以透過提取共同介面來定義依賴的行為,並注入依賴。以下以 TypeScript 為例,定義 TimeProvider
介面,並且讓 FakeTimeProvider
實作這個介面。
interface TimeProvider {
getToday: () => string;
}
class FakeTimeProvider implements TimeProvider {
constructor(private fakeDay: string) {}
getToday() {
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('今天不是情人節');
});
it('2/14 should be Valentines Day', () => {
const checker = new ValentinesDayChecker(new FakeTimeProvider('2/14'));
expect(checker.checkValentinesDay()).toBe('情人節快樂');
});
});
利用共用介面的方式來定義依賴,可以讓開發者更容易理解依賴的行為,並且提高程式碼的可讀性;而與 duck typing 的差異在於 duck typing 是在執行時期檢查,而 common interface 是在編譯時期檢查。