JAVASCRIPT V2 · ENGINEERING · 119
Unit Test Patterns:不是多寫 expect,而是讓 Contract Suite 可讀、可擴充
78 建立了 test 的基本 mental model。現在要處理較真實的 suite:同一規則有很多 cases、錯誤也要測、state reducer 要驗 identity、bug 修正要留下 regression,而 test 本身不能脆弱到每次 refactor 都碎掉。
Learning outcomes
- 使用 table-driven tests 表達大量同型 cases。
- 測正常值、boundary、invalid input 與 thrown error。
- 理解 behavioral contract 與 implementation detail 的界線。
- 為 reducer/immutable update 建立 state-transition tests。
- 把 production bug 轉成 regression test。
1. Why now:Test 數量增加後,重複本身也會成為維護成本
test("59", ...);
test("60", ...);
test("61", ...);
test("100", ...);
test("101", ...);
這不一定錯,但當 case 結構完全相同時,table-driven pattern 能更清楚顯示「哪些 input 對應哪些 expected」。
2. Mental model:Suite 是 Contract Matrix
behavior
↓
equivalence partitions
↓
boundaries
↓
invalid/error cases
↓
regressions
不是為了追求 coverage 百分比硬湊 tests,而是用 cases 描述重要行為空間。
3. Worked Example A:Table-Driven Tests
for (const [
score,
expected
] of [
[59, false],
[60, true],
[61, true],
[100, true]
]) {
test(
score + " pass rule",
() => {
expectEqual(
isPassed(score),
expected
);
}
);
}
新增一個 case 不需要複製整個 test body。
4. Error 也是 Contract
function normalizeScore(raw) {
const value =
Number(raw);
if (
!Number.isFinite(value)
|| value < 0
|| value > 100
) {
throw new Error(
"invalid score"
);
}
return value;
}
expectThrows(
() => normalizeScore("abc"),
"invalid score"
);
如果 invalid input 本來就應拒絕,test 應驗證 failure path,而不是只測 valid output。
5. Worked Example B:Pure Reducer Test
const before = {
students: []
};
const after =
reducer(
before,
{
type: "student-added",
student: {
id: 1,
name: "Amy"
}
}
);
expectEqual(
after.students.length,
1
);
Reducer 沒有 DOM/network dependency,因此非常適合 unit test。
6. Identity 也可以是 Observable Contract
expectTruthy(
after !== before
);
expectEqual(
before.students.length,
0
);
如果 immutability 是 architecture contract,就值得驗「old state 未 mutation」。但不要驗 reducer 內部用了 spread 還是 concat。
7. Worked Example C:Regression Test 從 Bug 來
test(
"whitespace score 60 remains passing",
() => {
const score =
normalizeScore(" 60 ");
expectEqual(
isPassed(score),
true
);
}
);
這個 test 的價值來自真實 bug history。修完後它成為防止同問題回來的保護網。
8. One Behavior per Test,不代表只有一個 Assert
test(
"adding student returns new state",
() => {
expectEqual(
after.students.length,
1
);
expectTruthy(
after !== before
);
expectEqual(
before.students.length,
0
);
}
);
三個 assertions 都在描述同一個 transition contract,因此合理。
9. Common mistakes
A. Copy-paste 大量幾乎相同 test
案例容易漏更新;考慮 table-driven。
B. Assert 私有 helper 被呼叫幾次
如果不是外部 contract,這會讓 refactor 變脆弱。
C. Snapshot 當所有 test 的替代品
巨大 snapshot 很容易被機械更新而失去語意。
D. 只補 implementation,不補 regression
同 bug 可能下次再回來。
10. Debug evidence:縮小哪個 Partition 壞掉
PASS: 0
PASS: 59
FAIL: 60
PASS: 61
PASS: 100
這個 pattern 強烈指向 boundary operator,而不是整個 algorithm 都壞。高品質 test report 本身就是 debugging evidence。
11. Guided exercise:Table-Driven Grade Label
[
[59, "Fail"],
[60, "Pass"],
[79, "Pass"],
[80, "B"],
[89, "B"],
[90, "A"],
[100, "A"]
]
用一個 table 驅動所有 gradeLabel cases,再加入 invalid values。
12. Independent exercise:Reducer Contract Suite
替 student-added / score-updated / student-removed / unknown 建 tests。至少驗:
correct data
old state untouched
unknown action
returns same state reference13. Challenge:Test Design Review
你看到一個 test 直接檢查 function source code 是否包含 .reduce(。判斷這是 behavioral contract 還是 implementation detail;改寫成真正有價值的 test。
Project checkpoint:Final Project Test Plan
Pure rules
├─ normalizeScore
├─ isPassed
├─ average
└─ reducer
UI behavior
├─ add form
├─ filter
└─ render summary
Async boundary
└─ save/load failure
119 只負責 pure/unit layer;120 再處理 async dependency。
14. Vocabulary / Summary
- table-driven test:以 data table 驅動同型 test logic。
- behavioral contract:外部可觀察的要求。
- implementation detail:不應成為 public behavior guarantee 的內部做法。
- regression test:鎖住已發生 bug 的修復。
- state transition test:驗 old state + action → new state。
15. Further reading
Knowledge check · Mastery
- 把重複 boundary tests 改成 table-driven。
- 區分 behavioral contract 與 implementation detail。
- 完成 reducer suite 與一個 regression test。
- 完成頁面下方 Real Test Runner missions。