Intro
我們在第一章學到單元的退出點(exit point)有三種類型:
- 回傳值
- 改變狀態
- 呼叫依賴(dependency)
依賴指的是「我們無法或很難控制的部分」,時間、非同步功能、檔案系統(file system)、網路——或是任何設定麻煩、跑起來非常耗時的要素——這些都算。在第二章我們為「回傳值」與「改變狀態」這兩種退出點寫了測試,在這一章則會討論如何測試那些會呼叫「進入型依賴」的單元。
(筆記備註:本書原文提到第二章討論了「回傳值」與「改變狀態」這兩種測試,但第二章的例子基本上沒有處理到改變狀態的部分⋯⋯🌚)
3.1 Types of dependencies
依賴可分為兩種類型:進入型(incoming dependency)、出走型(outgoing dependency)。

進入型
此類依賴會為單元提供資訊,扮演一個單元的「起點」。當我們要為需要這類依賴的單元撰寫測試時,會以稱為 stubs 的假資料代替依賴。
常見進入型依賴:從資料庫撈出的內容、來自 api 的回應(response)、從硬碟讀取的檔案內容。
出走型
代表一個單元的退出點(exit point),執行到這裡就代表該單元「結束」了。執行單元測試時,會以稱為 mock 的假資料替代出走型依賴,並測試該 mock 是否有被呼叫。順帶一提,因為 mock 代表退出點,所以一個單元測試一次只應關注一個 mock。
常見出走型依賴:呼叫 logger 印出結果、將內容保存到資料庫、寄信、呼叫 api 等。
名詞整理
本書以假資料(fake)取代原始依賴的「位置」來決定名稱。如果假資料要扮演供給資訊的起點,那就稱之 stub。如果假資料要在退出點發揮作用,那就稱之 mock。而 fake 是用來囊括 stub 與 mock 的泛稱。就這樣,沒有其他名字了。
3.2 Reasons to use stubs
const moment = require("moment");
const SUNDAY = 0;const SATURDAY = 6;
const verifyPassword = (input, rules) => { const currentDay = moment().day(); if ([SATURDAY, SUNDAY].includes(currentDay)) { throw Error("It's the weekend!"); }
const errors = []; rules.forEach((rule) => { const result = rule(input); if (!result.passed) { errors.push(`error ${result.reason}`); } }); return errors;};以上方在週末就會拋錯的 verifyPassword 功能為例——如果工程師在單元測試中不使用 stub 來控制 currentDay 的值,寫出來的就不是「好」的單元測試。
原因是,一個良好的單元測試不應被執行環境影響結果。不論我們在什麼日子執行測試,只要 verifyPassword 的實作沒有變,單元測試的結果就該維持一致,而不是在週間綠燈、週末紅燈。
為了確保單元測試的品質,我們可以選擇重構(本章主要內容),或是透過 jest 的 isolation api 來控制 currentDay 的值(請看本書第五章)。
3.3 Generally accepted design approaches to stubbing
3.3.1 Stubbing out time with parameter injection
我們可以選擇重構,將 currentDay 透過參數傳入——這能降低單元測試的變異性(variability)。現在提供 currentDay 的責任落在呼叫此單元的人身上,我們就能輕鬆地控制測試情境了:
const verifyPassword2 = (input, rules, currentDay) => { if ([SATURDAY, SUNDAY].includes(currentDay)) { throw Error("It's the weekend!"); }
const errors = []; rules.forEach((rule) => { const result = rule(input); if (!result.passed) { errors.push(`error ${result.reason}`); } }); return errors;};
const SUNDAY = 0;const SATURDAY = 6;const MONDAY = 1;
describe("verifyPassword2", () => { test("on weekends, should throw exceptions", () => { expect(() => verifyPassword2("anything", [], SUNDAY)).toThrow( "It's the weekend!", ); });});把 currentDay 參數化總共有三個意義:
- 我們能控制單元測試的情境
- 我們使用「依賴反轉(dependency inversion)」的概念來重構這個功能
- 現在
verifyPassword2是個純函數(pure function)了
Dependency inversion (D from SOLID)
中文維基翻譯為「依賴反轉」。核心概念是:高層次的模組不依賴低層次模組的實作細節——依賴關係被反轉——反該由低層次模組配合高層次模組的抽象需求。終極目標是解除模組之間的耦合。
Pure function
維基百科的定義如下:
- 此功能的回傳值只受參數影響——如果參數沒變,回傳值就不會變
- 此功能不會產生副作用(side effects),不會影響到它範圍外的任何東西
3.3.2 Dependencies, injections, and control
整理到目前為止出現的一些名詞與其定義:
- 依賴(dependency):一個單元中無法或很難控制(control)的部分
- 控制(control):在本書指的是「影響依賴的行為」。以
verifyPassword為例,我們無法控制變數currentDay的值。然而在verifyPassword2中,我們能透過參數currentDay來決定要提供什麼樣的日期資訊 - 控制反轉(inversion of control):當我們將
verifyPassword改寫為verifyPassword2時,就是透過參數注入(parameter injection)來將控制權從功能中反轉出來 - 依賴注入(dependency injection):指透過介面(design interface)來為一個單元提供依賴。以
verifyPassword2為例,我們透過「參數」這個介面來注入依賴 - 接縫(seam):指那些能注入依賴的部位。以
verifyPassword2為例,此功能的接縫就是它的參數currentDay。接縫會大幅影響單元測試的可讀性與維護性——畢竟越容易控制一個單元的行為,就能越輕鬆地為各種使用情境撰寫測試。
3.4 Functional injection techniques
除了透過參數傳入依賴外,也可透過傳入功能,或是將原先的 verifyPassword 柯里化(currying)來避免把依賴寫死在功能中。
Factory functions are functions that return other functions, pre-configured with some context.
以下列 snippet 為例,我們將原本的密碼驗證功能拆成「產生驗證功能」與「密碼驗證」兩階段。呼叫 makeVerifier 時我們需要傳入 rules 與 getCurrentDayFn 這兩項參數,而 makeVerifier 會回傳「已經指定好 rules 與 getCurrentDayFn 的密碼驗證功能(theVerifier)」。
// the arrangeconst SATURDAY = 6;const SUNDAY = 0;function makeVerifier(rules, getCurrentDayFn) { const theVerifier = (input) => { if ([SATURDAY, SUNDAY].includes(getCurrentDayFn())) { throw new Error("It's the weekend!"); }
const errors = []; rules.forEach((rule) => { const result = rule(input); if (!result.passed) { errors.push(`error ${result.reason}`); } }); return errors; }; return theVerifier;}
describe("verifier", () => { test("factory method: on weekends, throws exceptions", () => { const alwaysSunday = () => SUNDAY; const verifyPassword = makeVerifier([], alwaysSunday); // the act and assert expect(() => verifyPassword("anything")).toThrow("It's the weekend!"); });});以上重構讓單元測試變的俐落,但個人認為這波改動也降低了密碼驗證功能的可讀性。我偏向透過參數來執行依賴注入。
3.5 Modular injection techniques
以下是本書關於模組化注入(modular injection)的實作說明。首先,功能部分要追加 api 來允許模組注入:
const originalDependencies = { moment: require("moment"),};let dependencies = { ...originalDependencies };const inject = (fakes) => { Object.assign(dependencies, fakes); return function reset() { dependencies = { ...originalDependencies }; };};
const SUNDAY = 0;const SATURDAY = 6;const verifyPassword = (input, rules) => { const currentDay = dependencies.moment().day(); if ([SATURDAY, SUNDAY].includes(currentDay)) { throw Error("It's the weekend!"); } // more code goes here... // return list of errors found.. return [];};
module.exports = { SATURDAY, verifyPassword, inject,};並且測試後要復原模組狀態:
const { inject, verifyPassword, SATURDAY,} = require("./password-verifier-time00-modular");
const injectDate = (newDay) => { const reset = inject({ moment: () => ({ // we're faking the moment.js module's API here. day: () => newDay, }), }); return reset;};
describe("verifyPassword", () => { describe("when its the weekend", () => { it("throws an error", () => { const reset = injectDate(SATURDAY); expect(() => verifyPassword("any input")).toThrow("It's the weekend!"); reset(); }); });});結論:不要自找麻煩。請選擇透過參數注入或柯里化來反轉依賴。
3.6 Moving towards objects with constructor functions
3.7 Object oriented injection techniques
3.7.1 Constructor injection
以下是重構為物件導向風味的 PasswordVerifier 功能。現在我們能透過建構子(constructor)注入依賴了:
class PasswordVerifier { constructor(rules, getCurrentDayFn) { this.rules = rules; this.currentDay = getCurrentDayFn; } verify(input) { if ([SATURDAY, SUNDAY].includes(this.currentDay())) { throw new Error("It's the weekend!"); } const errors = []; //more code goes here.. return errors; }}注意——在下列單元測試中,我們都透過 makeVerifier 這個工廠功能(factory function)來建立驗證密碼的實例,而不是直接在每一道測試中呼叫 new PasswordVerifier(rules, getCurrentDayFn):
const { PasswordVerifier } = require("./password-verifier");
describe("refactored with constructor", () => { const makeVerifier = (rules, getCurrentDayFn) => { return new PasswordVerifier(rules, getCurrentDayFn); };
test("class constructor: on weekends, throws exceptions", () => { // arrange const alwaysSunday = () => "SUNDAY"; const verifier = makeVerifier([], alwaysSunday); // act and assert expect(() => verifier.verify("anything")).toThrow("It's the weekend!"); });
test("class constructor: on weekdays, with no rules, passes", () => { // arrange const alwaysMonday = () => "MONDAY"; const verifier = makeVerifier([], alwaysMonday); // act const result = verifier.verify("anything"); // assert expect(result.length).toBe(0); });});多包一層工廠功能(抽象層)的好處是:日後即使 PasswordVerifier 的實作方式變了,我們也不需要逐一調整單元測試的內容,而是更新 makeVerifier 就好。
更多關於抽象層的說明可以參考 第五章筆記 Consider abstracting away direct dependencies
3.7.2 Injecting an object instead of a function
3.7.3 Extracting a common interface
除了功能(function),實例(instance)也能作為依賴被注入。
首先我們實作了 class PasswordVerifier,並定義好介面 TimeProvider。每個試圖建立 class PasswordVerifier 實例的人,都必須傳入一個根據 interface TimeProvider 實踐(implements)的實例。
export interface TimeProvider { getDay(): number;}
const SUNDAY = 0;const SATURDAY = 6;
class PasswordVerifier { rules: any[];
timeProvider: TimeProvider;
constructor(rules: any[], timeProvider: TimeProvider) { this.rules = rules; this.timeProvider = timeProvider; }
verify(input: any): string[] { // saturday, sunday if ([6, 0].includes(this.timeProvider.getDay())) { throw new Error("It's the weekend!"); } const errors: string[] = []; this.rules.forEach((rule) => { const result = rule(input); if (!result.passed) { errors.push(`error ${result.reason}`); } }); return errors; }}當我們要為 class PasswordVerifier 撰寫單元測試時,只要捏出一個實踐 interface TimeProvider 的測試用實例就好:
class FakeTimeProvider implements TimeProvider { fakeDay: number;
getDay(): number { return this.fakeDay; }}
describe("password verifier with interfaces", () => { test("on weekends, throws exceptions", () => { // arrange const stub = new FakeTimeProvider(); stub.fakeDay = 0; const verifier = new PasswordVerifier([], stub); // act and assert expect(() => verifier.verify("anything")).toThrow("It's the weekend!"); });});而在我們正式使用 PasswordVerifier 時,就搭配當下使用的函式庫實作一個「真」的實例即可:
import moment from "moment";
class RealTimeProvider implements TimeProvider { getDay(): number { return moment().day(); }}summary
總結本章內容——如果我們想透過重構來建立可靠的單元測試,可參考以下作法將依賴自單元中分離出來:
- 透過參數來傳入,參數可以是原始值(primitive value)、功能(function)或實例(instance)
- 透過柯里化(currying)或工廠功能(factory function)把依賴分離出來
- 透過暴露注入模組(module)的 api 來允許動態傳入、重置實際要使用的模組
當然,不是每一次都能這麼順利地執行重構,有些遺留程式碼(legacy code)或許真的碰不得——如果是這種狀況,建議活用 jest 的 isolation api 來幫忙控制依賴回傳的值(詳見 第五章筆記 5.3 Functional mocks and stubs, dynamically 與 第五章筆記 5.5 Stubbing behavior dynamically)。