← JavaScript Learning Path

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

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 reference

13. 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

15. Further reading

Knowledge check · Mastery

  1. 把重複 boundary tests 改成 table-driven。
  2. 區分 behavioral contract 與 implementation detail。
  3. 完成 reducer suite 與一個 regression test。
  4. 完成頁面下方 Real Test Runner missions。