TDD 實戰(TDD in Practice)

是什麼?

String Calculator Kata 是 Roy Osherove 提出的經典 TDD 練習題。規則簡單但迭代豐富,完美展示 Red → Green → Refactor 的節奏。

ℹ️Kata 是什麼?

Kata 來自武術的「型」,指反覆練習同一套動作直到內化。TDD Kata 的重點不是最終程式碼,而是練習 TDD 的節奏和紀律

需求規格

String Calculator 的 Add 方法接收一個字串參數,回傳數字的總和:

  1. 空字串回傳 0
  2. 一個數字回傳該數字
  3. 兩個數字用逗號分隔,回傳總和
  4. 支援任意數量的數字
  5. 支援換行符 \n 作為分隔符
  6. 負數拋出例外,訊息包含所有負數

TDD 迭代演練

1

迭代 1:空字串

最簡單的案例,建立骨架

2

迭代 2:單一數字

處理一個數字的輸入

3

迭代 3:兩個數字

加入逗號分隔邏輯

4

迭代 4:任意數量

泛化處理 N 個數字

5

迭代 5:換行分隔

支援多種分隔符

6

迭代 6:負數例外

加入驗證邏輯

C# (xUnit) 完整演練

csharp
// ============ 迭代 1:空字串 → 回傳 0 ============
 
// RED: 寫一個失敗的測試
[Fact]
public void Add_EmptyString_ReturnsZero()
{
    Assert.Equal(0, StringCalculator.Add(""));
}
 
// GREEN: 用最簡單的方式通過
public static class StringCalculator
{
    public static int Add(string numbers)
    {
        return 0;
    }
}
 
// REFACTOR: 目前夠簡單,不需要重構
 
// ============ 迭代 2:單一數字 ============
 
// RED
[Fact]
public void Add_SingleNumber_ReturnsThatNumber()
{
    Assert.Equal(1, StringCalculator.Add("1"));
}
 
// GREEN
public static int Add(string numbers)
{
    if (string.IsNullOrEmpty(numbers)) return 0;
    return int.Parse(numbers);
}
 
// ============ 迭代 3:兩個數字 ============
 
// RED
[Fact]
public void Add_TwoNumbers_ReturnsSum()
{
    Assert.Equal(3, StringCalculator.Add("1,2"));
}
 
// GREEN
public static int Add(string numbers)
{
    if (string.IsNullOrEmpty(numbers)) return 0;
    var parts = numbers.Split(',');
    return parts.Sum(p => int.Parse(p));
}
 
// REFACTOR: Split + Sum 已經自然處理了單一數字的情況!
 
// ============ 迭代 4:任意數量 ============
 
// RED
[Fact]
public void Add_MultipleNumbers_ReturnsSum()
{
    Assert.Equal(10, StringCalculator.Add("1,2,3,4"));
}
 
// GREEN: 已經通過了!因為迭代 3 的 Split + Sum 天生支援 N 個數字
// 這就是 TDD 的美妙之處——好的設計自然長出來
 
// ============ 迭代 5:換行符分隔 ============
 
// RED
[Fact]
public void Add_NewlineDelimiter_ReturnsSum()
{
    Assert.Equal(6, StringCalculator.Add("1\n2,3"));
}
 
// GREEN
public static int Add(string numbers)
{
    if (string.IsNullOrEmpty(numbers)) return 0;
    var parts = numbers.Split(new[] { ',', '\n' });
    return parts.Sum(p => int.Parse(p));
}
 
// ============ 迭代 6:負數拋出例外 ============
 
// RED
[Fact]
public void Add_NegativeNumbers_ThrowsException()
{
    var ex = Assert.Throws<ArgumentException>(() =>
        StringCalculator.Add("1,-2,-3"));
    Assert.Contains("-2", ex.Message);
    Assert.Contains("-3", ex.Message);
}
 
// GREEN: 最終版本
public static class StringCalculator
{
    public static int Add(string numbers)
    {
        if (string.IsNullOrEmpty(numbers)) return 0;
 
        var parts = numbers.Split(new[] { ',', '\n' });
        var values = parts.Select(p => int.Parse(p)).ToList();
 
        var negatives = values.Where(v => v < 0).ToList();
        if (negatives.Any())
            throw new ArgumentException(
                $"Negatives not allowed: {string.Join(", ", negatives)}");
 
        return values.Sum();
    }
}

TypeScript (Jest) 完整演練

typescript
// ============ 迭代 1:空字串 ============
 
// RED
test('empty string returns 0', () => {
  expect(add('')).toBe(0);
});
 
// GREEN
function add(numbers: string): number {
  return 0;
}
 
// ============ 迭代 2:單一數字 ============
 
// RED
test('single number returns that number', () => {
  expect(add('5')).toBe(5);
});
 
// GREEN
function add(numbers: string): number {
  if (!numbers) return 0;
  return Number(numbers);
}
 
// ============ 迭代 3:兩個數字 ============
 
// RED
test('two numbers returns sum', () => {
  expect(add('1,2')).toBe(3);
});
 
// GREEN
function add(numbers: string): number {
  if (!numbers) return 0;
  return numbers.split(',').reduce((sum, n) => sum + Number(n), 0);
}
 
// ============ 迭代 4:已自動支援 ============
 
test('multiple numbers returns sum', () => {
  expect(add('1,2,3,4')).toBe(10); // 直接通過
});
 
// ============ 迭代 5:換行分隔 ============
 
// RED
test('newline delimiter returns sum', () => {
  expect(add('1\n2,3')).toBe(6);
});
 
// GREEN
function add(numbers: string): number {
  if (!numbers) return 0;
  return numbers.split(/[,\n]/).reduce((sum, n) => sum + Number(n), 0);
}
 
// ============ 迭代 6:負數例外 ============
 
// RED
test('negative numbers throw error', () => {
  expect(() => add('1,-2,-3')).toThrow('Negatives not allowed: -2, -3');
});
 
// GREEN: 最終版本
function add(numbers: string): number {
  if (!numbers) return 0;
 
  const values = numbers.split(/[,\n]/).map(Number);
  const negatives = values.filter(v => v < 0);
 
  if (negatives.length > 0) {
    throw new Error(`Negatives not allowed: ${negatives.join(', ')}`);
  }
 
  return values.reduce((sum, n) => sum + n, 0);
}

Python (pytest) 完整演練

python
# ============ 迭代 1:空字串 ============
 
# RED
def test_empty_string_returns_zero():
    assert add("") == 0
 
# GREEN
def add(numbers: str) -> int:
    return 0
 
# ============ 迭代 2:單一數字 ============
 
# RED
def test_single_number():
    assert add("5") == 5
 
# GREEN
def add(numbers: str) -> int:
    if not numbers:
        return 0
    return int(numbers)
 
# ============ 迭代 3:兩個數字 ============
 
# RED
def test_two_numbers():
    assert add("1,2") == 3
 
# GREEN
def add(numbers: str) -> int:
    if not numbers:
        return 0
    return sum(int(n) for n in numbers.split(","))
 
# ============ 迭代 4:已自動支援 ============
 
def test_multiple_numbers():
    assert add("1,2,3,4") == 10  # 直接通過
 
# ============ 迭代 5:換行分隔 ============
 
# RED
def test_newline_delimiter():
    assert add("1\n2,3") == 6
 
# GREEN
import re
 
def add(numbers: str) -> int:
    if not numbers:
        return 0
    parts = re.split(r"[,\n]", numbers)
    return sum(int(n) for n in parts)
 
# ============ 迭代 6:負數例外 ============
 
# RED
def test_negative_numbers():
    with pytest.raises(ValueError, match="-2, -3"):
        add("1,-2,-3")
 
# GREEN: 最終版本
import re
 
def add(numbers: str) -> int:
    if not numbers:
        return 0
 
    parts = re.split(r"[,\n]", numbers)
    values = [int(n) for n in parts]
 
    negatives = [v for v in values if v < 0]
    if negatives:
        raise ValueError(
            f"Negatives not allowed: {', '.join(str(n) for n in negatives)}"
        )
 
    return sum(values)

實戰補充

ℹ️資深開發者心得

注意迭代 4 的測試直接就通過了。這不是巧合,而是 TDD 的核心價值:好的設計在 Red-Green-Refactor 循環中自然浮現。你不需要預先設計完美架構,只要跟著測試走,程式碼會告訴你下一步該怎麼做。這就是 Kent Beck 說的「Emergent Design(湧現式設計)」。

⚠️常見錯誤

新手最常犯的錯是在 Green 階段「多做」——明明只需要讓當前測試通過,卻忍不住把後面的功能一起寫了。這會破壞 TDD 的回饋循環,讓你失去「每一步都有測試保護」的安全感。自我檢查方法:Green 階段寫完後問自己「我有沒有寫任何目前測試不需要的程式碼?」如果有,刪掉。

💡Kata 練習建議

同一個 Kata 練 3 次以上,每次用不同語言或不同設計決策。重點不是寫出程式碼,而是讓 Red-Green-Refactor 的節奏變成肌肉記憶。

推薦的 Kata 練習路線:

  1. String Calculator(本課)——入門,練習基本節奏
  2. Bowling Game——中級,練習狀態管理和規則累積
  3. Roman Numerals——中級,練習 Transform Priority Premise
  4. Gilded Rose——進階,練習 Legacy Code 的 Characterization Test + 重構

面試常見問題

Q:TDD 中的 Refactor 階段可以加新功能嗎?

A:不行。Refactor 階段只改結構不改行為——測試必須保持綠燈。如果需要新功能,回到 Red 階段寫新的失敗測試。判斷標準:Refactor 結束後跑測試,如果有任何測試從綠變紅,代表你不小心改了行為。

Q:什麼是 Transformation Priority Premise(TPP)?

A:Robert C. Martin 提出的指導原則,描述 Green 階段應該用什麼「變換」來讓測試通過。優先順序(由簡到繁):回傳常數 → 回傳變數 → 加條件判斷 → 加迴圈 → 加遞迴。每次只用優先順序最高的(最簡單的)變換,自然就會走出 Baby Steps。

理解測驗

🤔 在迭代 4(支援任意數量數字)時,為什麼測試直接通過了?

🤔 TDD 的 Green 階段,正確的做法是?

🤔 Kata 練習的核心目的是什麼?

重點整理

💡一句話記住

口訣:「TDD 不是讀會的,是練會的——同一個 Kata 練三遍,節奏自然刻進骨子裡」。

| 迭代 | 輸入 | 預期輸出 | 學到的事 | |------|------|----------|----------| | 1 | "" | 0 | 從最簡單的案例開始 | | 2 | "5" | 5 | 最小修改讓測試通過 | | 3 | "1,2" | 3 | Split + Sum 引入泛化 | | 4 | "1,2,3,4" | 10 | 好設計自然支援擴展 | | 5 | "1\n2,3" | 6 | 增量加入新功能 | | 6 | "1,-2,-3" | Exception | 驗證邏輯最後加 |

你可能也想看

TDD 反模式回到目錄 →

按 ← → 鍵切換課程