測試組織得再好、數量再龐大,只要你不信任、不敢維護、讀不懂它們,就一文不值。好測試靠三根支柱撐著——可信賴、可維護、可讀——缺一根,另外兩根也會跟著崩。可讀那根另有專頁談 FIRST 與 AAA,這裡把力氣放在最常被忽略的兩根:怎麼寫出你敢相信結果的測試,以及怎麼寫出改動系統時不會整片變紅的測試。
🧠 Why It Matters
- 可信賴 = 綠燈時不想再開 debugger、紅燈時不會說「本來就會壞」:你相信結果反映程式碼的真實狀態,並能據此行動。要撐起這根柱子,最重要的紀律是測試裡不准有邏輯、一個測試只驗一個關注點、測試自己不能有 bug。
- 測試裡的邏輯是 bug 的溫床:測試該是「一串方法呼叫 + 斷言」,不該出現
if/switch/for/while。有控制流程的測試更難讀、更難重現、更可能自己就測錯東西。尤其別在斷言裡動態算出預期值——那等於把 production code 的邏輯抄一份進測試,production 有 bug,測試也照抄同一個 bug 而照樣通過。 - 測試自己也會有 bug,而且最難抓:因為所有人都預設測試是對的。修測試 bug 要走三步:① 修掉 bug ② 確認它「該紅時會紅」③ 確認它「該綠時會綠」。
- 可維護 = 系統長大時測試不會變成絆腳石:這是多數人寫測試真正的痛點。核心手段:只測公開契約、移除重複、強制測試隔離、避免過度指定。
- 可讀是前兩根的黏著劑:讀得懂才維護得動,也才會在綠燈時信任它。命名、變數名、把動作與斷言分行,都屬這根——細節見 整潔測試:FIRST 與 AAA。
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
一個測試裡出現 if/switch/迴圈,幾乎都是警訊:它多半在做不只一件事,於是難命名、難重現(想想多執行緒或帶隨機數的測試),也更容易自己藏 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
- 好測試靠三支柱:可信賴、可維護、可讀——缺一根,其餘兩根跟著垮。
- 可信賴:測試裡別放邏輯(無
if/迴圈)、預期值寫死別動態算、一個測試只驗一個關注點;測試自己也會有 bug,修完要確認「該紅會紅、該綠會綠」。 - 可維護:別過度指定——不測 private/內部狀態、不把 stub 當 mock 斷言、需要時用「包含」而非「精確相等」,脆弱測試才不會一重構就整片變紅。
- 可維護:強制測試隔離(各自備狀態、獨立實例、不共享殘留),用 factory/helper 收斂重複,優先於塞滿 setup。
- 可讀是黏著劑:讀得懂才維護得動、才信得過——命名與 AAA 細節見 整潔測試:FIRST 與 AAA。
No notes yet — jot your takeaways or Q&A here.