iT邦幫忙

2025 iThome 鐵人賽

DAY 14
0
Software Development

Python pytest TDD 實戰:從零開始的測試驅動開發系列 第 14

Day 14 - 完整範圍實作(1-3999) 🏛️

  • 分享至 

  • xImage
  •  

想像一下,你正在開發一個歷史文件數位化系統,需要處理從古羅馬到現代的所有年份標記。今天我們要用 TDD 完成最後一塊拼圖,讓轉換器能夠處理完整的羅馬數字範圍:1-3999!

本日學習目標 🎯

今天我們將專注於:

  • 用 TDD 方法擴展到 1-3999 完整範圍
  • 學習羅馬數字的千位表示法
  • 深入理解查找表演算法的威力
  • 掌握 pytest 測試框架的進階技巧

昨天的映射表設計展現了強大擴展能力,今天只需加入四個新符號就能擴展到 1-3999!

為什麼是 3999?

羅馬數字傳統上止於 3999 的原因:

  1. MMMCMXCIX = 3999,最大的四位數羅馬數字
  2. 4000 以上需要新記號:傳統系統沒有直接表示方法
  3. 重複限制:基本符號最多重複三次,MMM 已是 M 的極限
  4. 系統完整性:1-3999 涵蓋所有基本符號組合

新增符號

要處理 1-3999,需要加入:

  • D = 500:五百的基本符號
  • CD = 400:四百的減法表示(500-100)
  • CM = 900:九百的減法表示(1000-100)
  • M = 1000:千位的基本符號

TDD 實戰:處理新符號

步驟 1:測試四百的轉換

建立 tests/day14/test_roman_complete.py

from src.roman.converter import to_roman

def test_convert_400():
    assert to_roman(400) == "CD"

紅燈 🔴!更新 src/roman/converter.py

def to_roman(number: int) -> str:
    if number <= 0:
        raise ValueError("Number must be positive")
    
    mappings = [
        (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
        (100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
        (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I")
    ]
    
    result = ""
    for value, symbol in mappings:
        while number >= value:
            result += symbol
            number -= value
    
    return result

步驟 2:測試其他新符號

繼續新增其他符號的測試:

def test_convert_500():
    assert to_roman(500) == "D"

def test_convert_900():
    assert to_roman(900) == "CM"

def test_convert_1000():
    assert to_roman(1000) == "M"

綠燈 🟢!一次性加入所有符號讓相關測試都通過了。

關鍵測試:複雜數字組合

里程碑數字測試

現在測試一些重要的里程碑數字,驗證我們的實作在複雜組合下的正確性:

def test_convert_1994():
    """測試歷史年份 1994
    1994 = M(1000) + CM(900) + XC(90) + IV(4) = MCMXCIV
    這是一個包含多種減法組合的複雜例子
    """
    assert to_roman(1994) == "MCMXCIV"

def test_convert_2024():
    """測試現代年份 2024
    2024 = MM(2000) + XX(20) + IV(4) = MMXXIV
    測試現代年份的轉換
    """
    assert to_roman(2024) == "MMXXIV"

def test_convert_3999():
    """測試最大值 3999
    3999 = MMM(3000) + CM(900) + XC(90) + IX(9) = MMMCMXCIX
    這是羅馬數字系統的理論上限
    """
    assert to_roman(3999) == "MMMCMXCIX"

def test_convert_edge_cases_with_all_subtraction_rules():
    """測試包含所有減法規則的數字"""
    assert to_roman(3949) == "MMMCMXLIX"  # 3000 + 900 + 40 + 9
    assert to_roman(1444) == "MCDXLIV"    # 1000 + 400 + 40 + 4
    assert to_roman(2999) == "MMCMXCIX"   # 2000 + 900 + 90 + 9

執行測試,綠燈 🟢!我們的映射表方法成功處理了所有複雜組合。

理解複雜數字的分解過程

讓我們手動追蹤 1994 的轉換過程:

  1. 1994 >= 1000 → 加入 "M",剩餘 994
  2. 994 >= 900 → 加入 "CM",剩餘 94
  3. 94 >= 90 → 加入 "XC",剩餘 4
  4. 4 >= 4 → 加入 "IV",剩餘 0
  5. 結果:"MCMXCIV"

這個過程展示了貪婪算法在羅馬數字轉換中的完美應用。

羅馬數字系統分析 📊

符號系統架構

羅馬數字使用七個基本符號,展現 1-5-10 的週期性模式:

符號 數值 類型 用途
I 1 基數 個位數表示
V 5 半位 五的表示
X 10 基數 十位數表示
L 50 半位 五十的表示
C 100 基數 百位數表示
D 500 半位 五百的表示
M 1000 基數 千位數表示

減法組合規則

減法規則只有 I、X、C 可用於減法,形成六種特殊組合:

  • IV(4), IX(9), XL(40), XC(90), CD(400), CM(900)

演算法設計的深度分析

複雜度分析

我們的實作達到 O(1) 時間複雜度O(1) 空間複雜度

  • 查找表固定 13 個元素
  • 最多 15 次迭代(3999 = MMM + CM + XC + IX)
  • 結果字串最長 15 字符

貪婪算法正確性

貪婪算法的三個正確性保證:

  1. 最優子結構:1994 = 1000最優解 + 994最優解
  2. 貪婪選擇:總是選擇最大可用符號
  3. 唯一性:每個數字只有一種表示方式

錯誤處理與邊界條件

實作邊界檢查

用 TDD 添加錯誤處理:

import pytest
from src.roman.converter import to_roman

def test_invalid_input_zero():
    """測試零的輸入"""
    with pytest.raises(ValueError, match="Number must be positive"):
        to_roman(0)

def test_invalid_input_negative():
    """測試負數輸入"""
    with pytest.raises(ValueError, match="Number must be positive"):
        to_roman(-1)

def test_invalid_input_too_large():
    """測試超出範圍的輸入"""
    with pytest.raises(ValueError, match="Number must be <= 3999"):
        to_roman(4000)

實作更新:

def to_roman(number: int) -> str:
    if number <= 0:
        raise ValueError("Number must be positive")
    if number > 3999:
        raise ValueError("Number must be <= 3999")
    
    # ... 現有邏輯

實戰小技巧 💡

當測試案例增多時,善用 pytest 的功能來組織測試。效能優化上,可以重複使用轉換器實例、使用參數化測試,以及利用 pytest 的平行測試執行功能。

參數化測試

import pytest

@pytest.mark.parametrize("input_num,expected", [
    (1994, "MCMXCIV"), (2024, "MMXXIV"), (3999, "MMMCMXCIX"),
    (444, "CDXLIV"), (888, "DCCCLXXXVIII"),
    (1, "I"), (999, "CMXCIX"), (1001, "MI"),
])
def test_convert_various_numbers(input_num, expected):
    assert to_roman(input_num) == expected

跟著做練習

試試看這些特殊數字組合:

# 建立 tests/day14/test_special_cases.py
def test_repeating_symbols():
    assert to_roman(3333) == "MMMCCCXXXIII"  # 各位都是3
    assert to_roman(2222) == "MMCCXXII"      # 各位都是2

def test_all_subtraction_in_one():
    assert to_roman(3949) == "MMMCMXLIX"  # CM, XL, IX
    assert to_roman(1494) == "MCDXCIV"   # CD, XC, IV

重要觀念複習

透過 Roman Numeral Kata,我們掌握了 pytest 的核心功能:

  1. 基本斷言assert 語句的簡潔威力
  2. 異常測試pytest.raises 處理錯誤情況
  3. 參數化測試@pytest.mark.parametrize 減少重複

完整實作

完整實作 src/roman/converter.py

def to_roman(number: int) -> str:
    if number <= 0:
        raise ValueError("Number must be positive")
    if number > 3999:
        raise ValueError("Number must be <= 3999")
    
    mappings = [
        (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
        (100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
        (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I")
    ]
    
    result = ""
    for value, symbol in mappings:
        while number >= value:
            result += symbol
            number -= value
    
    return result

完整測試 tests/day14/test_roman_complete.py

import pytest
from src.roman.converter import to_roman

def test_convert_400():
    assert to_roman(400) == "CD"

def test_convert_500():
    assert to_roman(500) == "D"

def test_convert_900():
    assert to_roman(900) == "CM"

def test_convert_1000():
    assert to_roman(1000) == "M"

def test_convert_1994():
    assert to_roman(1994) == "MCMXCIV"

def test_convert_2024():
    assert to_roman(2024) == "MMXXIV"

def test_convert_3999():
    assert to_roman(3999) == "MMMCMXCIX"

def test_convert_edge_cases_with_all_subtraction_rules():
    assert to_roman(3949) == "MMMCMXLIX"
    assert to_roman(1444) == "MCDXLIV"
    assert to_roman(2999) == "MMCMXCIX"

def test_invalid_input_zero():
    with pytest.raises(ValueError, match="Number must be positive"):
        to_roman(0)

def test_invalid_input_negative():
    with pytest.raises(ValueError, match="Number must be positive"):
        to_roman(-1)

def test_invalid_input_too_large():
    with pytest.raises(ValueError, match="Number must be <= 3999"):
        to_roman(4000)

@pytest.mark.parametrize("input_num,expected", [
    (1, "I"), (4, "IV"), (5, "V"), (9, "IX"), (10, "X"),
    (40, "XL"), (50, "L"), (90, "XC"), (100, "C"),
    (400, "CD"), (500, "D"), (900, "CM"), (1000, "M"),
])
def test_all_special_numbers(input_num, expected):
    assert to_roman(input_num) == expected

今日成就與回顧 🎊

  • ✅ 擴展到完整範圍(1-3999)
  • ✅ 新符號導入(D、CD、CM、M)
  • ✅ 複雜測試驗證(1994、3999、2024)
  • ✅ 錯誤處理與邊界測試完善
  • ✅ Python 類型提示設計
  • ✅ pytest 現代測試語法應用

學習成果總結

技術成就:完整羅馬數字系統(1-3999)、pytest 框架熟練、O(1) 高效實作

開發素養:TDD 紅綠重構、漸進式開發、測試組織、邊界處理

關鍵學習點回顧

  1. 查找表的威力:僅需 13 個映射就能處理 3999 個數字
  2. 貪婪算法應用:在羅馬數字中總是選擇最大符號
  3. 測試驅動開發:每個新功能都從失敗測試開始
  4. 邊界條件重要性:良好的錯誤處理讓程式更穩健

恭喜你完成了 Roman Numeral Kata!這個經典練習為我們的 Python TDD 之旅奠定了堅實基礎。明天我們將開始探索新的挑戰!🚀


上一篇
Day 13 - 擴展到百位數(11-100) 💯
下一篇
Day 15 - 羅馬數字反向轉換 🔄
系列文
Python pytest TDD 實戰:從零開始的測試驅動開發15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言