閱讀筆記:The Art of Unit Testing Chapter 5 Isolation frameworks
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 是否有被呼叫。
你甚至要確認每一組單元測試是否都在驗證必要的部分。不要因為某段程式碼「看起來可以被測試」就寫了測試。請提醒自己:單元測試要確保的是「在情境不變時,該單元在退出點產生的結果就會維持一致」,莫忘初衷:單元測試的定義,最終版。
如果一個測試與「確認退出點的行為是否一致」無關,那它就應該被刪掉。