summary
本書在第三、四章示範了不少手動處理 stub/mock 的方式,但其實我們可以透過 jest 這類有提供 isolation api 的測試框架來省掉不少人工作業。
能理解本書作者想避免因為太輕鬆導致 stub 與 mock 被濫用,但一路看下來還是有點浪費時間。
5.1 Defining isolation frameworks
隔離框架(isolation framework)的定義如下:
此框架會提供 api 讓工程師定義假資料(包含 stub/mock)。稱之為「隔離」是因為它幫助我們在測試時「隔離」了某個單元的依賴。
5.2 Faking modules dynamically
再次見到我們的老朋友 verifyPassword,這個同時擁有進入型(service)與出走型(logger)依賴的單元:
const service = require("./configuration-service");
const logger = require("./complicated-logger");
const log = (text) => {
if (service.getLogLevel() === "info") {
logger.info(text);
}
if (service.getLogLevel() === "debug") {
logger.debug(text);
}
};
const verifyPassword = (input, rules) => {
const failed = rules
.map((rule) => rule(input))
.filter((result) => result === false);
if (failed.length === 0) {
log("PASSED");
return true;
}
log("FAIL");
return false;
};
透過 jest.mock() 與 mockFn.mockReturnValue() 來取代 verifyPassword 的兩種依賴,我們省去那些在 4 Interaction testing using mock objects#4.5 Modular-style mocks 為了「暴露 api 以便注入依賴」而進行的加工:
jest.mock("./configuration-service");
jest.mock("./complicated-logger");
const stubService = require("./configuration-service");
const mockLogger = require("./complicated-logger");
describe("password verifier", () => {
afterEach(jest.resetAllMocks);
test(`with info log level and no rules,
it calls the logger with PASSED`, () => {
// arrange
stubService.getLogLevel.mockReturnValue("info");
// act
verifyPassword("anything", []);
// assert
expect(mockLogger.info).toHaveBeenCalledWith(
//
expect.stringMatching(/PASS/),
);
});
test(`with debug log level and no rules,
it calls the logger with PASSED`, () => {
// arrange
stubService.getLogLevel.mockReturnValue("debug");
// act
verifyPassword("anything", []);
// assert
expect(mockLogger.debug).toHaveBeenCalledWith(
//
expect.stringMatching(/PASS/),
);
});
});
Consider abstracting away direct dependencies
雖然透過 jest.mock() 與 mockFn.mockReturnValue() 就能很輕鬆地在單元測試中使用 stub/mock,但還是建議工程師們盡量避免這種直接寫死依賴的實作方式。比較好的作法是多包一個抽象層來隔離「依賴」與「需要此依賴的功能」,可參考 4 Interaction testing using mock objects#多型 polymorphism 的概念。
多套一個抽象層的好處是:即使你依賴的套件更改它的使用方式,整包程式碼中依賴該套件的功能也不需要做什麼調整——畢竟那些功能互動的對象是你定義的介面。你唯一要做的事情,就是去更新這個抽象層呼叫套件的方法。
5.3 Functional mocks and stubs, dynamically
除了透過 mockFn.mockReturnValue() 來指定某功能的回傳值以外,我們還能將「需要被 mock 的功能」以 jest.fn() 覆蓋。
先回顧一下 4 Interaction testing using mock objects#4.6 Mocks in a functional style 的手動 mock 版本。我們在 const mockLog 中直接寫了一個假 info 功能,並透過驗證 let logged 的值來確定 mockLog.info() 有被順利呼叫:
const makeVerifier = (rules, logger) => {
return (input) => {
const failed = rules
.map((rule) => rule(input))
.filter((result) => result === false);
if (failed.length === 0) {
logger.info("PASSED");
return true;
}
logger.info("FAIL");
return false;
};
};
test("given logger and passing scenario", () => {
// arrange
let logged = "";
const mockLog = {
info: (text) => {
logged = text;
},
};
const passVerify = makeVerifier([], mockLog);
// act
passVerify("any input");
// assert
expect(logged).toMatch(/PASSED/);
});
更簡單粗暴的作法是把 jest.fn() 餵進 const mockLog 裡,接著就能搭配 .toHaveBeenCalledWith() 來驗證 mockLog.info() 是否有被正確呼叫:
test("given logger and passing scenario", () => {
// arrange
const mockLog = { info: jest.fn() };
const verify = makeVerifier([], mockLog);
// act
verify("any input");
expect(mockLog.info).toHaveBeenCalledWith(
//
expect.stringMatching(/PASS/),
);
});
5.4 Object-oriented dynamic mocks and stubs
接下來要示範如何使用 jest.spyOn() 來測試擁有相對對複雜介面的單元。借用一下第四章的 interface ComplicatedLogger 與 class PasswordVerifier2 作為範例功能:
interface ComplicatedLogger {
info: (text: string) => void;
debug: (text: string, obj: any) => void;
warn: (text: string) => void;
error: (text: string, location: string, stacktrace: string) => void;
}
class PasswordVerifier2 {
#rules: any[];
#logger: ComplicatedLogger;
constructor(rules: any[], logger: ComplicatedLogger) {
this.#rules = rules;
this.#logger = logger;
}
verify(input: string) {
const failed = this.#rules
.map((rule) => rule(input))
.filter((result) => result === false);
if (failed.length === 0) {
this.#logger.info("PASSED");
return true;
}
this.#logger.info("FAIL");
return false;
}
}
進行測試時,我們先呼叫 const realLogger = new RealLogger(); 來取得 logger 實例,然後再注入靈魂透過 const mockLoggerInfo = jest.spyOn(realLogger, "info"); 來把 realLogger.info() mock 起來。接著,檢查 mockLoggerInfo 是否有被呼叫、且被傳入參數 PASSED 即可:
describe("PasswordVerifier2", () => {
it('whan pass empty rule, should call logger.info with "PASSED"', () => {
// arrange
const realLogger = new RealLogger();
const mockLoggerInfo = jest.spyOn(realLogger, "info");
const verifier = new PasswordVerifier2([], realLogger);
// act
verifier.verify("someInput");
// assert
expect(mockLoggerInfo).toHaveBeenCalledWith("PASSED");
// restore
mockLoggerInfo.mockRestore();
});
});
最後記得執行 mockLoggerInfo.mockRestore(); 來還原被 mock 起來的 .info() 功能。
現在讓事情變得更過分一點——這個驗證密碼的實例現在追加了「檢查系統是否正在維護中」的邏輯。這個單元現在有三大組退出點:
interface MaintenanceWindow {
isUnderMaintenance: () => boolean;
}
export class PasswordVerifier3 {
private _rules: any[];
private _logger: ComplicatedLogger;
private _maintenanceWindow: MaintenanceWindow;
constructor(
rules: any[],
logger: ComplicatedLogger,
maintenanceWindow: MaintenanceWindow,
) {
this._rules = rules;
this._logger = logger;
this._maintenanceWindow = maintenanceWindow;
}
verify(input: string): boolean {
if (this._maintenanceWindow.isUnderMaintenance()) {
// exit point set 1
this._logger.info("Under Maintenance");
return false;
}
const failed = this._rules
.map((rule) => rule(input))
.filter((result) => result === false);
if (failed.length === 0) {
// exit point set 2
this._logger.info("PASSED");
return true;
}
// exit point set 3
this._logger.info("FAIL");
return false;
}
}
幸好這一切都還在 jest 能搞定的範圍內。我們能透過 jest.spyOn() 與 .mockReturnValue() 的組合技來限制測試環境的 Maintainer.isUnderMaintenance() 一定要是 true:
describe("PasswordVerifier3", () => {
it('when under maintenance, should call logger.info with "Under Maintenance"', () => {
// arrange
const realMaintainer = new Maintainer();
const stubIsUnderMaintenance = jest.spyOn(
realMaintainer,
"isUnderMaintenance",
);
stubIsUnderMaintenance.mockReturnValue(true);
const realLogger = new ComplicatedLog();
const mockLoggerInfo = jest.spyOn(realLogger, "info");
const Verifier = new PasswordVerifier3([], realLogger, realMaintainer);
// act
Verifier.verify("someInput");
// assert
expect(mockLoggerInfo).toHaveBeenCalledWith("Under Maintenance");
// restore
stubIsUnderMaintenance.mockRestore();
mockLoggerInfo.mockRestore();
});
});
一組單元測試又平安的過去了,感謝 jest 提供的 api 與你自己的努力。

5.5 Stubbing behavior dynamically
除了透過 .mockReturnValue() 與 .mockReturnValueOnce() 來指定 stub 要回傳的內容外,還可以透過 .mockImplementation() 來模擬拋錯等「不是單純回傳值」的行為,比如:
test("stub a function that throw error", () => {
const stubFunc = jest.fn().mockImplementation(() => {
throw new Error("Oops!");
});
expect(stubFunc()).toThrow(/oops/);
});
5.6 Advantages and traps of isolation frameworks
隔離框架讓工程師在寫單元測試時,能輕鬆搞定需要 stub/mock 的部分。但這份便利性也可能導致濫用,比如你發現自己在驗證 stub ——要記得,我們需要 stub 是為了在單元測試中固定情境(確保每次都供應相同的資料),你不需要驗證 stub 是否有被呼叫。
你甚至要確認每一組單元測試是否都在驗證必要的部分。不要因為某段程式碼「看起來可以被測試」就寫了測試。請提醒自己:單元測試要確保的是「在情境不變時,該單元在退出點產生的結果就會維持一致」,莫忘初衷:單元測試的定義,最終版。
如果一個測試與「確認退出點的行為是否一致」無關,那它就應該被刪掉。