核心觀點#
本章是一段完整的 pair programming 實況紀錄。Bob Koss(RSK)和 Robert C. Martin(RCM)以 TDD 方式開發一個保齡球計分程式,展示了敏捷實踐中測試驅動開發、持續重構、以及設計演化的真實過程。
重點: 過程是混亂的,就像所有人類的過程一樣。但從如此混亂的過程中產出的秩序令人驚嘆。這正是 TDD 的力量——透過極小的步驟,讓設計自然浮現。
保齡球規則簡述#
- 一場比賽 10 個 frame,每個 frame 最多投兩球
- Spare:兩球打倒全部 10 瓶,該 frame 得分 = 10 + 下一球擊倒數
- Strike:第一球就打倒全部 10 瓶,該 frame 得分 = 10 + 下兩球擊倒數
- 第 10 個 frame 有特殊規則(spare 或 strike 可額外投球)
開發過程#
階段一:初始設計——UML 圖的陷阱#
兩人先畫了一個 UML 類別圖,看起來很直觀:

Figure 6.2: UML diagram of bowling scorecard
Game 包含 10 個 Frame,每個 Frame 包含 1~3 個 Throw。
然而,當他們開始按照這個設計寫測試時,立刻遇到了問題:
Throw類別只是一個資料容器,沒有有意義的行為Frame的Score需要知道後續Frame的資訊(spare/strike 的獎勵球)- 這導致了
Frame和Game之間的循環依賴
注意: 預先的 UML 設計看起來合理,但在測試的驅動下卻證明是過度設計。三個小方框畫在餐巾紙背面,結果卻太複雜、完全不對。
階段二:拋棄預設計,讓測試驅動#
兩人轉向直接從 Game 類別開始,用最簡單的測試推進:
[Test]
public void TestOneThrow()
{
Game game = new Game();
game.Add(5);
Assert.AreEqual(5, game.Score);
}每次只寫剛好讓測試通過的程式碼。Game 一開始只是簡單地累加分數:
public class Game
{
private int score;
public int Score { get { return score; } }
public void Add(int pins) { score += pins; }
}階段三:逐步加入複雜度#
依序加入測試案例,複雜度逐漸增加:
- TestTwoThrowsNoMark——兩球無特殊標記
- TestFourThrowsNoMark——引入
ScoreForFrame(int frame)的需求 - TestSimpleSpare——處理 spare 邏輯
- TestSimpleStrike——處理 strike 邏輯
- TestPerfectGame——完美比賽(300 分)
- TestSampleGame——用真實計分卡驗證
- TestHeartBreak——11 個 strike 後最後一球 9 分
- TestTenthFrameSpare——第 10 frame 的 spare
過程中不斷出現錯誤和意外,例如:
Score屬性在 spare 情況下回傳錯誤結果(因為它只是簡單累加)CurrentFrame的計算反覆出錯,因為「當前 frame」的定義不夠明確- 完美比賽測試得到 330 分,因為
currentFrame沒有上限
技巧: 過程中的每個錯誤都透過新的測試案例被捕獲和修正。這就是 TDD 的價值——錯誤在非常小的範圍內被發現和解決。
階段四:大規模重構#
所有測試通過後,ScoreForFrame 方法變得相當複雜。兩人開始系統性地重構:
提取局部變數為成員變數,使得可以提取方法:
private int ball;
private int firstThrow;
private int secondThrow;提取有意義的函式和屬性:
Strike()——判斷是否為 strikeSpare()——判斷是否為 spareNextTwoBallsForStrike——strike 的獎勵球分數NextBallForSpare——spare 的獎勵球分數TwoBallsInFrame——一般 frame 的兩球分數
最終 ScoreForFrame 讀起來幾乎就是保齡球的規則:
flowchart TD
S([開始計分]) --> L{還有 Frame?}
L -->|否| R([回傳總分])
L -->|是| K{Strike?}
K -->|是| ST["分數 += 10 + 下兩球<br/>前進 1 球"]
K -->|否| SP{Spare?}
SP -->|是| SPT["分數 += 10 + 下一球<br/>前進 2 球"]
SP -->|否| N["分數 += 兩球擊倒數<br/>前進 2 球"]
ST --> L
SPT --> L
N --> Lpublic int ScoreForFrame(int theFrame)
{
ball = 0;
int score = 0;
for (int currentFrame = 0;
currentFrame < theFrame;
currentFrame++)
{
if(Strike())
{
score += 10 + NextTwoBallsForStrike;
ball++;
}
else if ( Spare() )
{
score += 10 + NextBallForSpare;
ball += 2;
}
else
{
score += TwoBallsInFrame;
ball += 2;
}
}
return score;
}階段五:分離職責——提取 Scorer 類別#
RSK 指出 Game 違反了 Single Responsibility Principle(SRP):它同時追蹤投球和計算分數。
最終將計分邏輯提取到獨立的 Scorer 類別:
Game:追蹤 frame 進度、判斷 strike/spare 推進 frameScorer:記錄投球序列、計算每個 frame 的分數
public class Game
{
private int currentFrame = 0;
private bool isFirstThrow = true;
private Scorer scorer = new Scorer();
public int Score
{
get { return ScoreForFrame(currentFrame); }
}
public void Add(int pins)
{
scorer.AddThrow(pins);
AdjustCurrentFrame(pins);
}
public int ScoreForFrame(int theFrame)
{
return scorer.ScoreForFrame(theFrame);
}
// ...
}Game 中的 AdjustCurrentFrame 也經過多次精煉,最終變得非常清晰:
private void AdjustCurrentFrame(int pins)
{
if (LastBallInFrame(pins))
AdvanceFrame();
else
isFirstThrow = false;
}
private bool LastBallInFrame(int pins)
{
return Strike(pins) || (!isFirstThrow);
}關鍵的設計發現#
整個過程中最重要的發現是:預先設計的 Frame 和 Throw 類別根本不需要。
Throw只是一個int,不需要獨立的類別Frame的概念被throws陣列上的索引取代- 最終的設計幾乎沒有物件導向設計——
Scorer類別只是簡單的邏輯分離,甚至不算真正的 OOD
補充: 發表後有人認為應該有
Frame類別。有人真的寫了包含Frame類別的版本,結果比本章的版本大得多也複雜得多。
關於 UML 圖的反思#
- 畫圖探索想法沒有問題
- 但不應該假設圖就是最佳設計,然後照著實作
- 最佳設計可能會在你以微小步驟、先寫測試的過程中自然演化出來
- 如果當初照著那個
Game-Frame-Throw的 UML 圖實作,會得到一個不必要的複雜程式
結論#
重點: 作者引用 Eisenhower 的話作結:「在準備戰鬥時,我總是發現計畫是無用的,但規劃是不可或缺的。」(Plans are useless, but planning is indispensable.)畫圖思考有價值,但不要把圖當作必須遵循的藍圖。讓測試和程式碼來告訴你真正需要的設計。