走過一輪 Potter Kata:把 TDD 當成一套思考順序
週末去五倍上了 Cash 哥的 Classic TDD Workshop,重新走了一輪經典的 Potter TDD Kata。
雖然多年前我也上過 91 哥的 TDD 課程,但這次跟著 Cash 哥的節奏重做一次, 除了複習 TDD 的基本精神與流程之外,最大的收穫重新理清 TDD 在開發時的思考順序,以及現今 AI 時代下 TDD 帶來的價值。
Kata 題目本身不難,這篇文章想把 Red、Green、Refactor 完整走過一遍之後,記錄一下 TDD 在我腦中留下了什麼。

題目的需求很單純:哈利波特一套五本,單本 100 元,2 本不同 95 折、3 本 9 折、4 本 8 折、5 本 75 折。要實作 add(cart, book) 跟 calculatePrice(cart) 兩個 function:add 把書加進購物車並回傳新的 cart,calculatePrice 根據購物車的內容算出最便宜的總價。
不只是「先寫測試」
如果是不理解 TDD 的朋友,我想大家對 TDD 的印象可能大多停在表面的「先寫測試」。 所以我覺得如果只把 Red-Green-Refactor 三步當流程記下來,這篇筆記就很容易流於表面。
但 TDD 真正的價值,是它逼出一個開發時的思考順序。「TDD 的順序 = 你對問題的理解順序。」測試先寫成什麼樣,反映的就是你對需求理解到什麼程度。
1. 先回答需求是什麼
Potter Kata 表面在算價格,但需求其實有兩層:購物車要能加入書,價格計算要符合折扣規則,而且要找出最便宜的分組方式。這兩層如果沒先拆開,很容易把題目看成「套折扣公式」,最後寫出一個只能處理單純情境的版本。
所以 TDD 第一個帶來的價值,其實是逼自己先回答:這題到底在解什麼問題?哪些行為是外部真的看得見的?哪些只是我腦中以為合理、但需求其實沒有保證的事?
2. 把抽象需求拆成可驗證的例子
需求一抽象就容易漏邊界。第二步先別急著寫程式,把情境列出來:空購物車怎麼算、一本書怎麼算、多本不同書怎麼套折扣、有重複書怎麼拆組、哪些案例會揭露錯誤的演算法。情境列完之後,這份清單本身就是需求,測試只是把它寫成跑得動的形式。
3. 測試的順序本身在推動設計
Cash 的 commit 順序看起來瑣碎,但每一個 [red] 都只往前推一小步。先測 1 本書、再測 2 本同書、再測 2 本不同、3 本不同、4 本不同、5 本不同,最後才放重複書的 case。這樣排的好處是,每一次失敗都只告訴你一個新的訊息,不會一次把整題複雜度全部灌進來。
也呼應 TDD 一個我覺得很重要的精神:把問題切到夠小,小到每次只需要做一個決定。
4. 先寫失敗的測試,再寫剛好通過的程式
「先寫測試」這句話的重點我認為是控制開發的衝動。
如果先寫程式碼,很容易直接跳進自己熟悉的解法,事後再補測試替它背書。 但如果先讓測試失敗,思考重點會變成:我現在要保證的是哪個行為?我希望下一個失敗訊息告訴我什麼?我是不是正在為還沒被要求的東西提早設計?
而「剛好通過」是另一道煞車。它在提醒自己不要超前設計:忍住不要一次把所有情境都寫完,忍住不要在還沒需要時就先抽象化,忍住不要把重構和解題混在同一步。
Cash 在課堂上講得更直接:「學會忍住,工程師的通病就是會忍不住一口氣做完,剛開始學 TDD 的人會很不習慣,因為太慢了。」TDD 的節奏感很大一部分就是來自這種克制。
看起來慢,其實正是 TDD 的設計。每一步只走一格雖然看起來很瑣碎,但換來的是任何一刻測試紅燈都能精準回退。把這個工程師通病先壓下去,這套節奏才跑得起來。
跟著 Cash 的步驟走一遍
Cash 哥在課堂上提供的示範 repo 從 init 到完成總共 80 多個 commit, 每一個都標 [red]、[green] 或 [refactor],每一個都只動很少的程式碼。
如果是不熟 TDD 的朋友,第一次看這串 commit 應該會覺得:「這也太瑣碎了吧。」 但自己跟著重做一次之後,就會懂這個粒度的意義。
而且 Cash 在課堂上特別強調過:每一個紅燈、綠燈、重構都應該各自 commit。除了版本管控的好處之外,更實際的影響是每一步變更都被獨立記錄下來,出問題時可以乾淨地回到上一個綠燈,重構壞掉時也能精準看出是哪一個小步驟動到了行為。這在團隊協作時會更明顯,其他人從 commit 流就能讀出你解題的節奏,而不是只看到一個塞滿邏輯的大 PR。
這個習慣聽起來很瑣碎,但走完一輪就會發現它跟 TDD 的安全感是綁在一起的。
起手式:Fake It
第一個 [red] 時,calculatePrice 還只是空殼,add 則先用最直接的 immutable 寫法回傳新購物車:
export function add(cart, book) {
return [...cart, book];
}
export function calculatePrice(cart) {
return undefined;
}對應的測試是「一本書應該回傳 100」。Red 之後緊接著 [green],但這一步沒去湊真正的計算邏輯,只把 return 改成:
export function calculatePrice(cart) {
return 100;
}這就是 Kent Beck 講的 Fake It:第一次綠燈不需要通用,只需要通過當前測試。對我這種拿到題目就想直接寫對的人來說,這一步特別有教育意義,它逼你先確認「測試真的能抓到正確答案」、「pipeline 真的能跑完一輪」,這兩件事比演算法早。接著才是 refactor,把 100 抽成 BOOK_PRICE 常數。
一個 commit 一件事,乾淨俐落。
Triangulation:用第二個案例逼出泛化
下一個 [red] 加了「同一本書買兩次應該回 200」的測試。[green] 是這樣的:
function calculatePrice(cart) {
if (cart.length === 2) return 100 * 2;
return BOOK_PRICE;
}還是 hardcode,但已經出現分支。緊接著的 refactor 才是亮點,分兩步走:
// step 1: [refactor] - duplicate code
if (cart.length === 2) return BOOK_PRICE * cart.length;
return BOOK_PRICE * cart.length;
// step 2: [refactor] - remove duplicate
return BOOK_PRICE * cart.length;第一步先讓兩個分支「故意長得一樣」,第二步才把 if 砍掉。這個技巧叫 duplicate-then-remove,看起來多此一舉,但好處是每個 commit 都維持綠燈。如果直接把 if 跟分支內容一起改,重構過程中萬一壞掉,就找不出是哪一步出的事。
這就是 Triangulation 的精神:用兩個案例同時夾住正確答案,把 hardcode 的個案逼成通用解。
原本我習慣的是「想出通用解再寫」,TDD 想要的是「先寫個案、再被測試逼出通用解」。
從 if 鏈演化成 lookup table
到了三本不同書 (price 1, 2, 3),[green] 又是一個分支:
if (cart[0] === 1 && cart[1] === 2 && cart[2] === 3) return BOOK_PRICE * cart.length * 0.9;
if (cart[0] === 1 && cart[1] === 2) return BOOK_PRICE * cart.length * 0.95;
return BOOK_PRICE * cart.length;接下來 7 個 refactor commit,每一個都只改一點點,逐步把 if 鏈轉成 discountLookup 對照表 + new Set(cart).size 的查表寫法:
const discountLookup = { 1: 1, 2: 0.95, 3: 0.9 };
const distinctBooks = new Set(cart);
return BOOK_PRICE * cart.length * discountLookup[distinctBooks.size];到這一步之後,4 本和 5 本的 case 只要在 discountLookup 多加兩行就直接綠燈,連 calculatePrice 本體都不用改。 如果不是用 TDD 的方式,我可能會直接就寫成 lookup 形式,但沒體會到的差別是:這個 lookup 是被多個 case 慢慢逼出來的形狀,先有需求,才有對應的結構。

Cash 在這段特別提醒過一句我印象很深的話:「想一下每一個重構的流程,為什麼要這樣做、重構的順序很重要。」
這幾個 commit 的順序回頭看完全不是隨手排的:先把 discountLookup 跟原本的 if 分支並存放著,再一個分支一個分支地改用 lookup,接著導入 new Set(cart) 把判斷依據從 hardcode 的 index 換成 distinctBooks.size,最後把 lookup 補齊到 1 本書的分支也能套用,這時候三個 if 分支才終於長得一模一樣,砍 if 才安全。
如果中間隨便調換一步,例如先用 distinctBooks.size 當查表 key、但 lookup 裡還沒有對應的條目,馬上就會踩到 undefined 把測試打紅。
重構真正在做的事情是「在保持綠燈的前提下、一步一格把結構往前推」,至於程式變不變漂亮,是順帶的結果,這個前提決定了你能用什麼順序。
卡關時的勇氣:Suspend Test
接下來 [red] price 1, 2, 1(買兩次第 1 本加一次第 2 本,期望 290)就把整個 distinct-set 解法打爆了。distinctSet.size 是 2,套錯折扣,得到 285,差 5 元。
這時候很多人(包括我自己)會直接動 calculatePrice,硬塞分支去通過測試。但 Cash 做的事是 [red] price 1, 2, 1 - comment,把這個測試先註解起來。
這個動作叫 Suspend Test。如果當前測試對現在的程式碼來說「跨度太大」,與其硬塞,不如把它擱著,drill down 去開更小的 helper 函式,等基礎建好再回來。這個動作的核心是承認自己現在還沒有足夠的工具,先去打造工具、再回頭解這個 case。
Drill Down:用 TDD 開 helper 函式
接下來 30 多個 commit 全部在開兩個輔助函式:countBooks 跟 groupBook,而且每個 helper 自己也是完整的 red-green-refactor 循環。
countBooks 從 {1:1} 的 hardcode 開始,經過 fake it、用 cart[0]、改 for of、加分支處理重複,最後演化成:
function countBooks(cart) {
const counts = {};
for (const book of cart) {
counts[book] = (counts[book] || 0) + 1;
}
return counts;
}countBooks 解的是「每一本出現幾次」,groupBook 解的是「根據這些次數,先切出一組組可計價的書」。前者把資料整理成可推理的形狀,後者才開始處理折扣分組。
groupBook 也是一樣,從 [[1]] 的 hardcode 開始,逐步加進「拿 distinct 書」、「按最大重複數開 N 組」、「把每本書平均分配到各組」,演化成:
function groupBook(cart) {
const counts = countBooks(cart);
const volumes = Object.keys(counts);
const groups = [];
const maxGroupCount = Math.max(...Object.values(counts));
for (let i = 0; i < maxGroupCount; i++) groups.push([]);
for (const volume of volumes) {
for (let i = 0; i < counts[volume]; i++) {
groups[i].push(+volume);
}
}
return groups;
}這個分組策略目前的巧妙之處,是它直接照「最多重複幾本書」開 N 組,再把每本書水平攤到各組。[1,2,1,2] 自然變成 [[1,2],[1,2]],每組都吃滿 5% 折扣。沒有窮舉、沒有遞迴,純粹是被一連串小 case 逼出來的形狀。不過這個策略還不是 Potter Kata 的終點,後面有更難的最佳分組案例會再暴露它的限制。
回到主測試
helper 都建好之後,[red] price 1, 2, 1 - uncomment 把原本擱著的測試恢復,[green] 用 groupBook 重寫 calculatePrice:
function calculatePrice(cart) {
const groups = groupBook(cart);
let price = 0;
for (const group of groups) {
price += BOOK_PRICE * group.length * discountLookup[group.length];
}
return price;
}到這裡所有測試都通過。再加一個 [1,2,1,2] 的 case,發現不用動任何程式碼也直接綠燈,演化出來的解法天然支援它。這個瞬間我覺得是最爽的:你會發現自己一路小步走下來的程式,已經默默支援了新的需求,根本沒有刻意去「設計」可重用性。
「TDD 是浮現設計(Emergent Design)的方式,讓設計從解決問題的過程中浮現出來。」一路上每個小決定都只解決當下的測試,但這些決定累加起來,就是後面那個剛好接得住新 case 的形狀。
從這趟重做學到的 TDD 技法
回頭整理 Cash 示範用到的招式,每一個或許我們在 Kent Beck 的《TDD by Example》書裡都聽過名字, 但真正走過一遍之後,才會體會到它們在實際操作中的意義。
Fake It Till You Make It。第一次綠燈直接 hardcode,先確認測試 pipeline 跑得起來,演算法之後再說。
Triangulation。等第二、第三個 case 出現,才把 hardcode 推往泛化。設計是被一個個 case 逼出來的形狀,自己事先想破頭也想不到那麼貼切。
Duplicate-then-Remove。要刪 if 分支前,先把每個分支變成一樣的程式碼,再砍 if。每個中間 commit 都還能跑,壞掉就回得去。
Suspend Test。當前 case 對現在的程式碼跨度太大,先註解起來,drill down 開更小的 helper。擱著聽起來像放棄,其實是承認手上還缺工具,先去打造它。
Child Test。被 suspend 的測試底下,每個 helper 函式自己也跑完整的 red-green-refactor。整個過程是巢狀展開的 TDD,會在 helper 裡再開一輪完整循環。
Merciless Refactoring。每個小重構都是獨立 commit,連抽變數、改 for 迴圈都各自一筆。不混在實作裡,因為混了之後就分不清哪一步壞掉。而且每一步的順序本身就是設計,隨手換個順序,中間某一步就會打破綠燈,安全網就破了。
延伸的挑戰:最佳分組的 case
老實說,Cash 示範的 commit 序列收在 [1,2,1,2] → 380 這個 case,演化出來的「按最大重複數平均拆組」解法剛好能處理它。
但我自己在練習時其實還加了一個更難的 case: ['A','A','B','B','C','C','D','E'] → 640 ,結果發現這才是真正麻煩的地方。 XD
這個 case 會打爆任何「平均拆組」的策略,因為最佳解是 ABCD + ABCE(兩組四本不同 = 640)而不是 ABCDE + ABC(五本加三本 = 645)。
Cash 在課堂上提過一句很關鍵的話:「你的演算法會影響到你對 TDD 的設計,因為你的每一步都很小,所以當你發現改不下去的時候,就應該要停下來調整。」面對 640 這個 case,「平均拆組」就是改不下去的時刻,這個訊號要說的是該回頭重新看演算法,硬撐著繼續塞分支只會越改越亂。
我自己第一次寫的時候是直接跳到 bitmask 窮舉 + memoization 把它一次解掉。事後想其實方向是對的(真的需要換演算法),但跳得太大,整個 TDD 的演化過程被自己折掉了。
比較理想的做法是把這個 case suspend 起來,drill down 到一個更通用的 split 函式,用 baby steps 把它推回去。不然就算最後寫出來了,過程中也會一直打紅燈,完全失去 TDD 的安全感。
回頭看這整段卡關,「TDD 可以幫你發現問題,但不完全幫你解決演算法。」紅燈會告訴你「現在的解法不夠用」,但要換成什麼演算法、要怎麼換,這一步還是得靠開發者自己想。
TDD 的節奏感
做完這次練習之後,比起記住術語,我更想留住的是這個順序,或者說節奏感:先用紅燈建立規格,再用綠燈完成行為,最後靠測試保護重構,而且這三步是「每一個 case」都跑一輪,不是整個專案只跑一輪。
對應到實際操作的順序大概是這樣:先搞懂需求、不急著寫解法;先把需求拆成情境、不急著寫完整架構;先安排測試順序、讓每一步只增加一點複雜度;先讓測試失敗、確認真的有抓到缺口;先寫剛好通過的程式、不偷跑;最後才重構,讓測試當安全網。
平常自己寫程式時,這套順序最容易破功的地方就是「忍住不超前」這一步。
常常是寫一寫覺得「反正等下也要寫到」就提前抽象、提前寫成通用解,最後測試已經被實作牽著走,而不是反過來規範實作。Potter Kata 是個剛剛好的尺寸,可以把這個順序硬性走完一遍,提醒自己:先 TDD 才能把這題拆得簡單,題目本身並沒有比較簡單。
TDD 的價值:讓 AI 快跑的安全網
雖然現在已經是 AI Coding 時代,自己一行一行手刻 code 的機會其實越來越少。但走完這趟之後我反而更覺得,TDD 真正留下的東西,是它逼你在動手前先把需求、行為、邊界想清楚的那套思考順序。「寫測試」這個動作本身只是表象。
當 AI 寫 code 的速度快到一個程度,「先寫測試」反而從手刻時代被嫌慢的瓶頸,變成了唯一還跟得上 AI 速度的 spec 形式。 手刻時代沒先寫測試,頂多事後補救比較痛;AI 時代沒先寫測試,你根本不知道 AI 生出來的那一坨東西到底對不對。
以前 TDD 那種「忍住不超前」的克制看起來像在拖慢進度,但到了 AI 時代它反而成了讓 AI 可以放心快跑的安全網。
而最近也常被拿來討論的 SDD(Spec-Driven Development)剛好補上另一塊:先把需求寫成結構化的 spec 文件,AI 再照著 spec 生 code。 SDD 把人腦中模糊的需求外化成 AI 讀得懂的文字,TDD 則把同一份意圖翻譯成可執行的測試邊界。一個給 AI 看「要做什麼」,一個在跑完後驗證「有沒有做對」。
SDD 定義任務需求,TDD 用測試框住行為邊界,AI 負責在這個範圍內填滿實作。 三件事各司其職,1 + 1 才能大於 2。