閱讀筆記:The Art of Unit Testing Chapter 2 A first unit test
- 筆記總結
- 2.1 About Jest
- 2.2 The Library, the Assert, the Runner & the Reporter
- 2.3 What unit testing frameworks offer
- 2.4 Introducing the Password Verifier Project
- 2.5 The first Jest test for verifyPassword
- 2.5.1 The Arrange-Act-Assert (AAA) structure
- 2.5.2 Testing the test
- 2.5.3 U.S.E naming pattern
- 2.5.4 String comparisons and maintainability
- 2.5.5 Using describe()
- 2.5.6 Structure can imply context
- 2.5.7 The it() function
- BDD (behavior-driven development)
- 2.5.8 Two Jest Flavors
- 2.5.9 Refactoring the Production Code
- 2.6 Trying the beforeEach() route
- 2.6.1 beforeEach() and scroll fatigue
- 2.7 Trying the factory method route
- 2.8 Going Full Circle to test()
- 2.9 Refactoring to parameterized tests
- 2.10 Checking for expected thrown errors
- 避免使用 .toMatchSnapshot()
- 2.11 Setting Test Categories
- 參考文件
筆記總結
第二章介紹了測試框架 Jest 與撰寫單元測試的架構、命名技巧。也說明濫用 .beforeEach() 的副作用,以及如何透過工廠模式(factory method)來避免 .beforeEach() 的快龍肥大問題。

2.1 About Jest
2.2 The Library, the Assert, the Runner & the Reporter
Jest 實際上包含四種與測試有關的功能:
- 提供執行測試的 api (
describe,test,it) - 提供比對結果的 api (
expect) - 執行單元測試(a test runner)
- 提供測試結果(a test reporter)
2.3 What unit testing frameworks offer
相較於自己開發「負責執行測試的功能」,使用框架有以下好處:
- 框架提供 api 與說明文件,讓所有人都知道如何撰寫、修改測試
- 測試失敗時,主動提供錯誤棧(error stack)
- 反覆執行、設定自動執行的成本很低——這會讓工程師更願意執行測試
- 省去維護自架工具的時間,讓工程師把時間拿去為每一個單元寫測試

提醒:使用測試框架並不代表你會自動寫出好讀、好維護或有意義的測試。
2.4 Introducing the Password Verifier Project
以下是我們要為之撰寫單元測試的功能:
function verifyPassword(input, rules) {
const errors = [];
rules.forEach((rule) => {
const result = rule(input);
if (!result.passed) {
errors.push(`error ${result.reason}`);
}
});
return errors;
}
2.5 The first Jest test for verifyPassword
2.5.1 The Arrange-Act-Assert (AAA) structure
指「先安排(arrange)環境,再執行(act)功能,最後驗證(assert)結果」的測試架構。此架構能讓工程師輕鬆理解一個測試的情境與目的。
test('badly named test', () => {
// arrange part
const fakeRule = (input) => ({ passed: false, reason: 'fake reason' });
// act part
const errors = verifyPassword('any value', [fakeRule]);
// assert part
expect(errors[0]).toMatch('fake reason');
});
2.5.2 Testing the test
如果把 errors.push() 這行註解掉,測試就會失敗——這證明該測試確實能檢驗一個單元的功能是否正常。
function verifyPassword(input, rules) {
const errors = [];
rules.forEach((rule) => {
const result = rule(input);
if (!result.passed) {
// errors.push(`error ${result.reason}`);
}
});
return errors;
}
2.5.3 U.S.E naming pattern
好理解的單元測試名稱應包含以下三種要素:
- 測試哪一個單元(unit)?請指出該單元的名稱
- 測試情境(scenario)是什麼?
- 預期結果(expected behavior)是什麼?
這樣做的好處是:當測試失敗時,終端提供的會是失敗的測試名稱。命名包含 USE 三要素可以讓工程師馬上知道「哪個單元」在「什麼情境」的測試沒過。
調整名稱後的單元測試如下:
// unit
test('verifyPassword, given a failing rule, returns errors', () => {
// scenario
const fakeRule = (input) => ({ passed: false, reason: 'fake reason' });
const errors = verifyPassword('any value', [fakeRule]);
// expected behavior
expect(errors[0]).toContain('fake reason');
});
2.5.4 String comparisons and maintainability
文字是一種介面:它有可視、會隨著版面變形(換行)的特性。測試文字時,盡量使用彈性較高的比對 api 來確認核心內容是否有出現——比如 .toContain() / .toMatch()——避免少量的非重點內容更動都需要調整單元測試。
2.5.5 Using describe()
讓 describe() 描述被測試的單元(unit),讓 test() 描述情境(scenario)與預期結果(expected behavior):
describe('verifyPassword', () => {
test('given a failing rule, returns errors', () => {
const fakeRule = (input) => ({ passed: false, reason: 'fake reason' });
const errors = verifyPassword('any value', [fakeRule]);
expect(errors[0]).toContain('fake reason');
});
});
2.5.6 Structure can imply context
describe() 支援巢狀結構,所以還能進一步將單元測試的情境(scenario)也收納到 describe() 中:
describe('verifyPassword', () => {
describe('with a failing rule', () => {
// arrange, scenario
const fakeRule = (input) => ({ passed: false, reason: 'fake reason' });
test('returns errors', () => {
// act
const errors = verifyPassword('any value', [fakeRule]);
// assert, expected behavior
expect(errors[0]).toContain('fake reason');
});
});
});
如果一個單元有多個退出點(exit point),每一個退出點都該要有一個位於第二層 describe() 的測試。
2.5.7 The it() function
it() 與 test() 在 Jest 中是等價功能。硬要說的話—— it() 會讓測試讀起來比較自然。將本書章節 2.5.6 測試中的 test() 以 it() 取代後,會增加一點 BDD (behavior-driven development) 風味:
describe('verifyPassword', () => {
describe('with a failing rule', () => {
it('returns errors', () => {
const fakeRule = (input) => ({ passed: false, reason: 'fake reason' });
const errors = verifyPassword('any value', [fakeRule]);
expect(errors[0]).toContain('fake reason');
});
});
});
BDD (behavior-driven development)
- 一種軟體設計方法論,鼓勵開發人員與產品經理、使用者等非技術人員合作
- 透過使用者故事(user story)與範例(example)來描述軟體行為
2.5.8 Two Jest Flavors
test() 與 it() 的差異主要是「單元測試讀起來的風格有所不同」。以 it() 開頭會讓測試讀起來更有「是人類在描述功能」的感受。
2.5.9 Refactoring the Production Code
如果我們將章節 2.5 的 verifyPassword 重構為物件導向風味的程式碼:
class PasswordVerifier1 {
constructor() {
this.rules = [];
}
addRule(rule) {
this.rules.push(rule);
}
verify(input) {
const errors = [];
this.rules.forEach((rule) => {
const result = rule(input);
if (result.passed === false) {
errors.push(result.reason);
}
});
return errors;
}
}
會發現第二版的進入點(entry point)變成 addRule()、退出點(exit point)在 verify()——相較於第一版的 verifyPassword 其進入點與退出點都在同一個地方。
而第二版的單元測試會變成這樣:
describe('PasswordVerifier', () => {
describe('with a failing rule', () => {
it('has an error message based on the rule.reason', () => {
const verifier = new PasswordVerifier1();
const fakeRule = (input) => ({ passed: false, reason: 'fake reason' });
verifier.addRule(fakeRule);
const errors = verifier.verify('any value');
expect(errors[0]).toContain('fake reason');
});
});
});
看起來沒什麼問題。
但如果我們想把「驗證錯誤訊息內容」「驗證 errors 陣列長度」拆為兩個單元測試的話,會發現一堆重複的內容(verifier / fakeRule / addRule):
describe('PasswordVerifier', () => {
describe('with a failing rule', () => {
it('has an error message based on the rule.reason', () => {
const verifier = new PasswordVerifier1();
const fakeRule = (input) => ({ passed: false, reason: 'fake reason' });
verifier.addRule(fakeRule);
const errors = verifier.verify('any value');
expect(errors[0]).toContain('fake reason');
});
it('has exactly one error', () => {
const verifier = new PasswordVerifier1();
const fakeRule = (input) => ({ passed: false, reason: 'fake reason' });
verifier.addRule(fakeRule);
const errors = verifier.verify('any value');
expect(errors.length).toBe(1);
});
});
});
這時候可以透過 beforeEach() 抽出共用的部分。
2.6 Trying the beforeEach() route
根據官方文件說明,在 describe() 中的 beforeEach() 會在每一個測試執行前被執行:
Runs a function before each of the tests in this file runs. If
beforeEachis inside adescribeblock, it runs for each test in the describe block.
而每次測試前都要建立的 verifier 實例、透過 .addRule() 注入規則——這些重複的佈局(arrange)工作可以移到 beforeEach() 中:
describe('PasswordVerifier', () => {
describe('with a failing rule', () => {
// arrange
let verifier, fakeRule;
beforeEach(() => {
verifier = new PasswordVerifier1();
fakeRule = (input) => ({ passed: false, reason: 'fake reason' });
verifier.addRule(fakeRule);
});
it('has an error message based on the rule.reason', () => {
// act
const errors = verifier.verify('any value');
// assert
expect(errors[0]).toContain('fake reason');
});
it('has exactly one error', () => {
// act
const errors = verifier.verify('any value');
// assert
expect(errors.length).toBe(1);
});
});
});
但重點來了——須知 Jest 的預設是同時執行多個測試,所以共用狀態(上方的 verified)可能會被意外覆蓋。
第一種解決辦法是在 cli 傳入 -i 選項來線性地進行測試(文件在此)。第二種是透過 2.7 會提到的工廠模式(factory method)來避免使用 beforeEach()。
2.6.1 beforeEach() and scroll fatigue
除了狀態有可能被意外污染之外,把 arrange 放到 beforeEach() 也會導致工程師「沒辦法只看 it() 就知道這個測試的全貌」。一旦單元 PasswordVerifier 的測試越來越多,工程師必須來回捲動滑鼠才能理解整份測試——本書作者稱之為捲動疲勞(scroll fatigue)。
另外,beforeEach() 會在日積月累下變成垃圾桶。工程師會把所有被共享的設定一股腦塞到這裡,也不管這些東西是真的被每一個測試依賴、還是只有部分測試會用到。設定越塞越多,接著就有不確定是否能刪掉的廢 code 出現了。
為了避免垃圾結局,請考慮使用接下來要介紹的工廠模式(factory mode)來取代 beforeEach()。
2.7 Trying the factory method route
工廠模式說穿了就是把重複性的佈局(arrange)部分抽出、變成獨立功能。比如以下範例的 makeVerifierWithPassingRule 與 makeVerifierWithFailedRule:
const makeVerifier = () => new PasswordVerifier1();
const passingRule = (input) => ({ passed: true, reason: '' });
const makeVerifierWithPassingRule = () => {
const verifier = makeVerifier();
verifier.addRule(passingRule);
return verifier;
};
const makeVerifierWithFailedRule = (reason) => {
const verifier = makeVerifier();
const fakeRule = (input) => ({ passed: false, reason: reason });
verifier.addRule(fakeRule);
return verifier;
};
describe('PasswordVerifier', () => {
describe('with a failing rule', () => {
it('has an error message based on the rule.reason', () => {
// arrange
const verifier = makeVerifierWithFailedRule('fake reason');
// act
const errors = verifier.verify('any input');
// assert
expect(errors[0]).toContain('fake reason');
});
it('has exactly one error', () => {
// arrange
const verifier = makeVerifierWithFailedRule('fake reason');
// act
const errors = verifier.verify('any input');
// assert
expect(errors.length).toBe(1);
});
});
describe('with a passing rule', () => {
it('has no errors', () => {
// arrange
const verifier = makeVerifierWithPassingRule();
// act
const errors = verifier.verify('any input');
// assert
expect(errors.length).toBe(0);
});
});
describe('with a failing and a passing rule', () => {
it('has one error', () => {
// arrange
const verifier = makeVerifierWithFailedRule('fake reason');
verifier.addRule(passingRule);
// act
const errors = verifier.verify('any input');
// assert
expect(errors.length).toBe(1);
});
it('error text belongs to failed rule', () => {
// arrange
const verifier = makeVerifierWithFailedRule('fake reason');
verifier.addRule(passingRule);
// act
const errors = verifier.verify('any input');
//assert
expect(errors[0]).toContain('fake reason');
});
});
});
使用工廠模式的優點是:就算只看每一個 it() 的內容,也能從幫忙執行佈局的功能名稱理解該測試的情境是什麼——這讓我們解決了捲動疲勞(scroll fatigue)的問題。
工程師無法從功能名稱知道「情境如何被安排」,但能理解「執行測試時,對應的情境是什麼」。以後也不需要把設定囤積到 beforeEach() 中。
2.8 Going Full Circle to test()
在改用工廠模式來安排測試內容後,其實連 describe() 都可以考慮刪掉,因為每一組 test() 都能說明自己的 Arrange-Act-Assert 與 U.S.E 分別是什麼:
const makeVerifier = () => new PasswordVerifier1();
const passingRule = () => ({ passed: true, reason: '' });
const makeVerifierWithPassingRule = () => {
const verifier = makeVerifier();
verifier.addRule(passingRule);
return verifier;
};
const makeVerifierWithFailedRule = (reason) => {
const verifier = makeVerifier();
const fakeRule = () => ({ passed: false, reason });
verifier.addRule(fakeRule);
return verifier;
};
test('pass verifier, with passing rule, has no errors', () => {
const verifier = makeVerifierWithPassingRule();
const errors = verifier.verify('any input');
expect(errors.length).toBe(0);
});
test('pass verifier, with failed rule, has an error message based on the rule.reason', () => {
const verifier = makeVerifierWithFailedRule('fake reason');
const errors = verifier.verify('any input');
expect(errors[0]).toContain('fake reason');
});
test('pass verifier, with failed rule, has exactly one error', () => {
const verifier = makeVerifierWithFailedRule('fake reason');
const errors = verifier.verify('any input');
expect(errors.length).toBe(1);
});
test('pass verifier, with passing and failing rule, has one error', () => {
const verifier = makeVerifierWithFailedRule('fake reason');
verifier.addRule(passingRule);
const errors = verifier.verify('any input');
expect(errors.length).toBe(1);
});
test('pass verifier, with passing and failing rule, error text belongs to failed rule', () => {
const verifier = makeVerifierWithFailedRule('fake reason');
verifier.addRule(passingRule);
const errors = verifier.verify('any input');
expect(errors[0]).toContain('fake reason');
});
2.9 Refactoring to parameterized tests
假設我們寫了一個帶有參數的功能:
const oneUpperCaseRule = (input) => {
return {
passed: input.toLowerCase() !== input,
reason: 'at least one upper case needed',
};
};
並且我們想確認「不管參數的大寫位在一個單字的哪一個位置,該功能都能正確判斷單字是否符合『至少包含一個大寫字元』」的話,單元測試可以有至少三種寫法。
第一種:一個條件就是一組 test()。條件變化少的時候確實可以這樣玩,但之後 oneUpperCaseRule 如果更改了實作規格,就有一堆測試要調整。
describe('one uppercase rule', function () {
test('given no uppercase, it fails', () => {
const result = oneUpperCaseRule('abc');
expect(result.passed).toEqual(false);
});
test('given one uppercase, it passes', () => {
const result = oneUpperCaseRule('Abc');
expect(result.passed).toEqual(true);
});
test('given a different uppercase, it passes', () => {
const result = oneUpperCaseRule('aBc');
expect(result.passed).toEqual(true);
});
});
第二種:使用 Jest 的 test.each() 一口氣傳入所有情境。相較於第一種測試的撰寫方式,現在少了很多重複的部分。
describe('one uppercase rule', () => {
test('given no uppercase, it fails', () => {
const result = oneUpperCaseRule('abc');
expect(result.passed).toEqual(false);
});
test.each(['Abc', 'aBc'])('given one uppercase, it passes', (input) => {
const result = oneUpperCaseRule(input);
expect(result.passed).toEqual(true);
});
});
describe('one uppercase rule', () => {
test.each([
['Abc', true],
['aBc', true],
['abc', false],
])('given %s, %s ', (input, expected) => {
const result = oneUpperCaseRule(input);
expect(result.passed).toEqual(expected);
});
});
第三種:使用 JavaScript 原生的 Object.entries 來為每一種條件跑測試。
describe('one uppercase rule, with vanilla JS for', () => {
const TEST_CASE = {
Abc: true,
aBc: true,
abc: false,
};
for (const [input, expected] of Object.entries(TEST_CASE)) {
test(`given ${input}, ${expected}`, () => {
const result = oneUpperCaseRule(input);
expect(result.passed).toEqual(expected);
});
}
});
寫測試的方法有很多種,能顧及維護性與可讀性的就是好辦法。
2.10 Checking for expected thrown errors
拉回 PasswordVerifier1 的例子。假設我們在 verify() 追加了檢查 this.rules 的功能:
class PasswordVerifier1 {
constructor() {
this.rules = [];
}
addRule(rule) {
this.rules.push(rule);
}
verify(input) {
// add this line
if (!this.rules.length) {
throw new Error('There are no rules configured');
}
const errors = [];
this.rules.forEach((rule) => {
const result = rule(input);
if (result.passed === false) {
errors.push(result.reason);
}
});
return errors;
}
}
當我們想要確認「沒有設定 this.rules 的話,執行 this.verify() 應該拋出錯誤」時,可以使用 Jest 的 .toThrowError() 來檢查是否有拋錯、錯誤訊息是否符合預期:
test('verify, with no rules, throws exception', () => {
const verifier = makeVerifier();
expect(() => verifier.verify('any input')).toThrowError(
/no rules configured/
);
});
避免使用 .toMatchSnapshot()
使用 .toMatchSnapshot() 無法讓人一眼就看出該測試的預期結果是什麼。如果真的需要為測試保留快照,請考慮使用 .toMatchInlineSnapshot() 明列預期結果:
it('renders correctly', () => {
const tree = renderer
.create(<Link page="https://example.com">Example Site</Link>)
.toJSON();
expect(tree).toMatchInlineSnapshot(`
<a
className="normal"
href="https://example.com"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
Example Site
</a>
`);
});
2.11 Setting Test Categories
Jest 目前並不支援「將測試分類,並執行特定類別(例:單元、整合測試)的測試」。不過工程師能透過以下方式達成「依類別跑測試」的需求:
- 讓不同類別的測試有其命名模式或收納資料夾
- 在
jest.config.js中透過 testRegex 或終端指令--testPathPattern=<regex>來指定執行特定名稱規則的測試