核心觀點#

本章是一段完整的 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 類別只是一個資料容器,沒有有意義的行為
  • FrameScore 需要知道後續 Frame 的資訊(spare/strike 的獎勵球)
  • 這導致了 FrameGame 之間的循環依賴

注意: 預先的 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; }
}

階段三:逐步加入複雜度#

依序加入測試案例,複雜度逐漸增加:

  1. TestTwoThrowsNoMark——兩球無特殊標記
  2. TestFourThrowsNoMark——引入 ScoreForFrame(int frame) 的需求
  3. TestSimpleSpare——處理 spare 邏輯
  4. TestSimpleStrike——處理 strike 邏輯
  5. TestPerfectGame——完美比賽(300 分)
  6. TestSampleGame——用真實計分卡驗證
  7. TestHeartBreak——11 個 strike 後最後一球 9 分
  8. TestTenthFrameSpare——第 10 frame 的 spare

過程中不斷出現錯誤和意外,例如:

  • Score 屬性在 spare 情況下回傳錯誤結果(因為它只是簡單累加)
  • CurrentFrame 的計算反覆出錯,因為「當前 frame」的定義不夠明確
  • 完美比賽測試得到 330 分,因為 currentFrame 沒有上限

技巧: 過程中的每個錯誤都透過新的測試案例被捕獲和修正。這就是 TDD 的價值——錯誤在非常小的範圍內被發現和解決。

階段四:大規模重構#

所有測試通過後,ScoreForFrame 方法變得相當複雜。兩人開始系統性地重構:

提取局部變數為成員變數,使得可以提取方法:

private int ball;
private int firstThrow;
private int secondThrow;

提取有意義的函式和屬性

  • Strike()——判斷是否為 strike
  • Spare()——判斷是否為 spare
  • NextTwoBallsForStrike——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 --> L
public 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 推進 frame
  • Scorer:記錄投球序列、計算每個 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);
}

關鍵的設計發現#

整個過程中最重要的發現是:預先設計的 FrameThrow 類別根本不需要

  • Throw 只是一個 int,不需要獨立的類別
  • Frame 的概念被 throws 陣列上的索引取代
  • 最終的設計幾乎沒有物件導向設計——Scorer 類別只是簡單的邏輯分離,甚至不算真正的 OOD

補充: 發表後有人認為應該有 Frame 類別。有人真的寫了包含 Frame 類別的版本,結果比本章的版本大得多也複雜得多。

關於 UML 圖的反思#

  • 畫圖探索想法沒有問題
  • 不應該假設圖就是最佳設計,然後照著實作
  • 最佳設計可能會在你以微小步驟、先寫測試的過程中自然演化出來
  • 如果當初照著那個 Game-Frame-Throw 的 UML 圖實作,會得到一個不必要的複雜程式

結論#

重點: 作者引用 Eisenhower 的話作結:「在準備戰鬥時,我總是發現計畫是無用的,但規劃是不可或缺的。」(Plans are useless, but planning is indispensable.)畫圖思考有價值,但不要把圖當作必須遵循的藍圖。讓測試和程式碼來告訴你真正需要的設計。