好測試的三支柱:可信賴與可維護 (Pillars of Good Unit Tests)

★★★★Draft

FromThe Art of Unit Testing

📚 From the Books

好測試的三支柱:可信賴與可維護 (Pillars of Good Unit Tests)Testing

測試組織得再好、數量再龐大,只要你不信任、不敢維護、讀不懂它們,就一文不值。好測試靠三根支柱撐著——可信賴、可維護、可讀——缺一根,另外兩根也會跟著崩。可讀那根另有專頁談 FIRST 與 AAA,這裡把力氣放在最常被忽略的兩根:怎麼寫出你敢相信結果的測試,以及怎麼寫出改動系統時不會整片變紅的測試。

🧠 Why It Matters

NOTE

三根支柱是連動的:可讀性與可維護性、可信賴性相輔相成。 能讀懂你測試的人就維護得動它們,維護得動就會在它通過時相信它——於是當程式碼需要改動時,你會知道什麼時候真的出了問題。

⚖️ 可信賴:測試裡別放邏輯

反例 — 在斷言裡動態計算預期值
// 預期值是「算」出來的:把 production 的組字串邏輯抄進了測試
test("build message from user and greeting", () => {
  const user = "USER";
  const greeting = "GREETING";
  const actual = MessageBuilder.build(user, greeting);
  expect(actual).toBe(user + " " + greeting); // ← 若 build() 的拼接有 bug,這裡也會用同樣的錯法算,照樣綠燈
});

測試複製了待測程式的邏輯。萬一 build() 的拼接規則寫錯,這個測試會用「同一種錯」去對答案,於是永遠抓不到 bug。

正例 — 寫死預期值,讓測試當「獨立的第二意見」
test("build message from user and greeting", () => {
  const actual = MessageBuilder.build("user", "greeting");
  expect(actual).toBe("user greeting"); // ← 寫死的預期值,不參與任何計算
});

預期值是人腦手算、直接寫死的常數。測試不再重複 production 的邏輯,才能真正扮演「另一雙眼睛」。若工具方法裡真的躲不掉複雜邏輯,那就連工具方法也要寫測試

WARNING

一個測試裡出現 ifswitch/迴圈,幾乎都是警訊:它多半在做不只一件事,於是難命名、難重現(想想多執行緒或帶隨機數的測試),也更容易自己藏 bug。把分支拆成多個各自命名清楚的測試,勝過一個聰明的迴圈。

⚖️ 可信賴:一個測試只驗一個關注點

一個 concern(關注點)是一個工作單元的單一最終結果——一個回傳值、一次狀態變更、或一次對第三方的呼叫。同一個測試裡對多個物件斷言、或同時檢查回傳值又檢查狀態,通常就是塞了多個關注點。

反例 — 兩個關注點擠在一起,第一個紅了就看不到第二個
test("isValid returns true and remembers the last call", () => {
  const analyzer = makeDefaultAnalyzer();
  expect(analyzer.isValid("abc")).toBe(true); // 關注點 A:回傳值
  expect(analyzer.wasLastCallValid).toBe(true); // 關注點 B:狀態變更 —— A 一失敗,這行永遠不會跑
});

多數框架在第一個斷言失敗時就拋例外,後面的斷言根本不執行——B 到底是好是壞,你毫無資訊。而且這種測試也很難取一個準確的名字。

正例 — 拆成兩個,各自命名,各自能單獨紅綠
test("isValid returns true for a valid name", () => {
  const analyzer = makeDefaultAnalyzer();
  expect(analyzer.isValid("abc")).toBe(true);
});

test("isValid remembers that the last call was valid", () => {
  const analyzer = makeDefaultAnalyzer();
  analyzer.isValid("abc");
  expect(analyzer.wasLastCallValid).toBe(true);
});

判斷準則很簡單:如果第一個斷言失敗了,你還在乎第二個嗎?在乎,就該拆。 拆開後每個測試名字就是一句規格,紅燈時直接告訴你哪個關注點壞了。

NOTE

注意「多重斷言」的分界:驗證不同關注點才要拆;若只是在驗同一個物件的多個屬性(name、age…),多個斷言沒問題——那是同一關注點的不同面向。更好的做法是建一個 expected 物件、用單一斷言整體比較(需要物件正確實作相等性)。

⚖️ 可維護:別過度指定,別測內部

過度指定(overspecification) 的測試對「內部該怎麼實作」做了假設,而不只驗「最終行為對不對」。這種測試極度脆弱(brittle)——內部一重構就整片變紅,即使功能完全正確。

反例 — 測內部狀態、又把 stub 當 mock 斷言
// 問題一:斷言內部私有狀態(分隔符),那是隨時會變的實作細節
test("initialize sets the default delimiter to tab", () => {
  const log = new LogAnalyzer();
  expect(log.getInternalDefaultDelimiter()).toBe(null);
  log.initialize();
  expect(log.getInternalDefaultDelimiter()).toBe("\t"); // ← 測的是內部契約,不是公開功能
});

// 問題二:用 stub 餵假資料,卻又斷言 stub「被呼叫過」
test("analyze looks up an unknown user", () => {
  const fakeData = { getUserByName: () => null }; // 本意只是餵輸入(stub)
  const analyzer = new LogAnalyzer(fakeData);
  analyzer.analyze("UserNameThatDoesNotExist");
  expect(fakeData.getUserByName).toHaveBeenCalled(); // ← 把 stub 當 mock 用,鎖死了內部演算法
});

兩者都在鎖定「內部怎麼做」:內部分隔符、內部有沒有呼叫某方法。只要重構換個實作、功能不變,測試就無辜地紅了。stub 只該餵輸入、不該被斷言(見 測試替身:Stub vs. Mock)。

正例 — 只驗公開的最終結果,用部分匹配放鬆非本質的約束
// 只透過 public API 驗最終行為,不碰內部狀態
test("analyze parses tab-delimited fields", () => {
  const log = new LogAnalyzer();
  const output = log.analyze("10:05\tOpen\tRoy");
  expect(output.fields).toEqual(["10:05", "Open", "Roy"]); // 公開結果,實作可自由改
});

// 只需要「包含」時就別要求「完全相等」或「在特定位置」
test("summary mentions the user name", () => {
  const summary = report.buildSummary("Roy");
  expect(summary).toContain("Roy"); // contains,而非 equals 整段固定字串
});

只要最終值對,就不該過問內部是否呼叫了誰。需要部分匹配時用 contains/集合的「包含」檢查,而不是強求精確、順序、位置——少一分不必要的約束,就少一分無謂的脆弱。

IMPORTANT

別測 private/protected 方法。 它們存在就是為了藏實作細節、讓實作能自由改。測 private 等於測「內部契約」,而內部契約會隨重構變動。沒有哪個 private 是憑空存在的——總有 public 方法會呼叫到它,透過 public API 去驗它的效果。真的值得獨立測,就把它升為 public、或抽成獨立類別,而不是硬鑽進去測。

⚖️ 可維護:移除重複、強制隔離

反例 — 共享狀態損壞:Test2 被 Test1 的殘留污染
const person = new Person(); // ← 兩個測試共用同一個實例(共享狀態)

test("finds a phone number starting with 055", () => {
  person.addNumber("055-4556684");
  expect(person.findPhoneStartingWith("055")).toBe("055-4556684");
});

test("returns null when no number exists", () => {
  expect(person.findPhoneStartingWith("0")).toBe(null); // ← 會紅:Test1 已經加了號碼進去
});

第二個測試的成敗被第一個測試綁架,甚至取決於執行順序。多數框架不保證測試順序,於是這種測試時好時壞——隔離性一破,信賴性也跟著沒了。

正例 — 每個測試在自己的小世界,用 factory 收斂重複
// helper / factory method:集中建立與初始化,語意一變只改這一處
function makeAnalyzer() {
  const analyzer = new LogAnalyzer();
  analyzer.initialize();
  return analyzer;
}

test("finds a phone number starting with 055", () => {
  const person = new Person(); // 各自全新實例,互不干擾
  person.addNumber("055-4556684");
  expect(person.findPhoneStartingWith("055")).toBe("055-4556684");
});

test("returns null when no number exists", () => {
  const person = new Person();
  expect(person.findPhoneStartingWith("0")).toBe(null);
});

每個測試自備狀態、用完不留殘渣。重複的建構邏輯抽進 factory/helper(而不是塞進共享的 setup),這樣讀者在測試本體就能看到完整情境,語意改變時也只改一處。

TIP

測試裡的重複和 production 的重複一樣毒(甚至更毒)——DRY 同樣適用。但作者的建議是:優先用 factory 與 helper method,而不是把東西都堆進 setup setup 很容易被塞進「只有部分測試會用」的初始化,逼讀者在 setup 與測試之間來回跳;尤其別在 setup 裡建 mock/stub,讓每個測試自己呼叫 helper 建立,情境才看得見。測試若長得都很像,考慮改用 parameterized tests。

WARNING

隔離被破壞的幾種「測試壞味道」:受限的測試順序(依賴特定執行順序)、隱藏的測試呼叫(一個測試直接叫另一個測試)、共享狀態損壞(改了共享的記憶體/外部資源卻不還原,或沒先設好初始狀態)。解法一律是:測試前設好自己要的狀態、盡量用獨立實例、共用邏輯抽到第三個 helper 而非互相呼叫。

🔑 Takeaways

✍️ My Notes

No notes yet — jot your takeaways or Q&A here.

📖 Further Reading