用 pytest 寫自動化測試
每次改程式,都要手動把每個功能點一遍嗎?這堂課用 pytest 替記帳工具寫一份「會自己跑的驗收清單」,一個指令就知道有沒有東西被改壞。這也是讓 AI Agent 放心動手改程式的安全網。
改了 A,壞了 B
到目前為止,記帳工具已經有了清楚的結構、可以重現的環境、會處理錯誤,也標好了型別。現在你想加一個新功能,「只看本月份的報表」。改完之後,你要怎麼確定原本的功能沒有被改壞?
最直覺的方法是:執行程式,記幾筆,看報表,輸入幾個錯誤的金額……全部手動點一遍。功能少的時候還行,但功能一多,你就會開始偷懶,然後某一天發現:「欸,報表的總計什麼時候開始算錯的?」
如果改程式的是 AI,這件事更重要。Claude Code 一次可能改好幾個檔案,你不可能每次都手動把所有功能試一遍。
自動化測試就是把「手動點一遍」寫成程式。寫一次,之後每次改動,一個指令就能全部重新檢查。
安裝 pytest
pytest 是 Python 最常用的測試工具。它跟上一堂的 mypy 一樣,只有開發時才需要,所以加成開發用套件:
uv add --dev pytest
接著在 pyproject.toml 最下面加三行,告訴 pytest「從專案的最外層開始找 tracker 套件」:
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
pythonpath 讓測試檔可以 import tracker;testpaths 告訴 pytest 測試都放在 tests 資料夾。沒有這兩行,你可能會看到 ModuleNotFoundError: No module named 'tracker'。
第一個測試
在專案最外層建立 tests 資料夾,新增 tests/test_amount.py:
# tests/test_amount.py:測試金額的檢查與轉換
from tracker.amount import parse_amount
def test_plain_number() -> None:
assert parse_amount("85") == 85
def test_spaces_and_commas_are_ok() -> None:
assert parse_amount(" 1,200 ") == 1200
整個測試只有一個新東西:assert,意思是「我斷定……」。assert parse_amount("85") == 85 讀作:「我斷定 parse_amount("85") 的結果等於 85。」如果真的相等,什麼事都不會發生;如果不相等,這個測試就失敗。
pytest 有兩個找測試的規則:檔名要用 test_ 開頭,函式名稱也要用 test_ 開頭。照這個規則命名,它就會自動找到並執行。函式名稱寫長一點沒關係,失敗時你一看名字就知道是哪個情況出問題。
執行:
uv run pytest
============================= test session starts =============================
collected 2 items
tests/test_amount.py .. [100%]
============================== 2 passed in 0.02s ==============================
每一個點 . 代表一個通過的測試。綠色的 2 passed,就是你的第一份自動驗收報告。
測試錯誤的情況
parse_amount 最重要的工作之一,是拒絕不合理的金額。上一堂我們讓它在這些情況丟出 ValueError,現在把這件事也寫成測試:
import pytest
from tracker.amount import parse_amount
@pytest.mark.parametrize("bad", ["abc", "85元", "", "0", "-50", "85.5"])
def test_rejects_bad_input(bad: str) -> None:
with pytest.raises(ValueError):
parse_amount(bad)
兩個新寫法:
with pytest.raises(ValueError):意思是「我斷定底下這行會丟出ValueError」。如果它沒有丟出錯誤,反而算失敗。@pytest.mark.parametrize寫在函式上面,意思是「同一個測試,用這六個值各跑一次」。每次跑的時候,bad會依序是"abc"、"85元"……這樣不用把幾乎一樣的測試複製六遍。
把這段加進 test_amount.py(import pytest 放在檔案最上面),再跑一次,你會看到 8 passed。
測試報表計算
還記得第 1 堂把 show_report 拆成「算」和「印」嗎?現在就是收穫的時候。summarize 只吃資料、回傳結果,不問使用者、不印東西,所以測試起來非常簡單:
# tests/test_report.py:測試報表計算
from tracker.models import Record
from tracker.report import biggest_expense, summarize
RECORDS: list[Record] = [
{"date": "2026-11-07", "category": "餐費", "item": "雞腿便當", "amount": 85},
{"date": "2026-11-07", "category": "交通", "item": "捷運", "amount": 30},
{"date": "2026-11-08", "category": "餐費", "item": "咖啡", "amount": 60},
]
def test_summarize_groups_by_category() -> None:
assert summarize(RECORDS) == {"餐費": 145, "交通": 30}
def test_summarize_empty() -> None:
assert summarize([]) == {}
def test_biggest_expense() -> None:
top = biggest_expense(RECORDS)
assert top is not None
assert top["item"] == "雞腿便當"
def test_biggest_expense_when_empty() -> None:
assert biggest_expense([]) is None
RECORDS 是一份假資料:小小的、算得出答案的、專門給測試用的。85 加 60 是 145,你心算就能確認答案是對的。好的測試資料就是這樣:簡單到不會讓你懷疑「到底是程式錯,還是我的答案錯」。
uv run pytest
============================= test session starts =============================
collected 12 items
tests/test_amount.py ........ [ 66%]
tests/test_report.py .... [100%]
============================= 12 passed in 0.05s ==============================
看測試抓到 bug
測試的價值,在它失敗的時候才看得出來。故意改壞 summarize,把加總那行改成:
by_category[cat] = r["amount"] # 故意寫錯:沒有加上原本的金額
再跑一次 uv run pytest,最後會出現類似這樣的摘要:
FAILED tests/test_report.py::test_summarize_groups_by_category - AssertionError: assert {'餐費': 60, '交通': 30} == {'餐費': 145, '交通': 30}
========================= 1 failed, 11 passed in 0.08s =========================
它告訴你:哪個檔案、哪個測試失敗,實際算出來的是 餐費 60,但應該是 餐費 145。你還沒打開程式,就已經猜得到問題了:只留下最後一筆咖啡的 60 元,前面的便當被蓋掉了。
改回正確的寫法,再跑一次,回到全部通過。這個「改一點、跑測試、確認綠燈」的節奏,就是之後所有改動的基本流程。
測試是安全網:不管是你還是 AI 改的,掉下去都接得住。
測試是你和 AI 之間的合約
學會測試之後,你請 AI 寫程式的方式可以整個升級。以「只看本月份」為例:
- 你先寫測試,把「做對是什麼樣子」講清楚:輸入哪些紀錄、指定哪個月份、應該留下哪幾筆。
- 請 Claude Code 實作,條件是
uv run pytest全部通過。 - 你檢查測試結果和程式,確認它沒有走捷徑。
這種「先寫測試、再寫程式」的做法,有個名字叫測試驅動開發(TDD)。你不需要每次都這樣做,但跟 AI 合作時特別好用:測試就是一份 AI 讀得懂、而且能自己驗收的規格書。
我的記帳專案用 uv 管理,測試用 pytest。我在 tests/test_report.py 新增了 test_filter_month 這個測試(貼在下面),請在 tracker/report.py 實作 filter_month,讓它通過。
規則:
- 執行 uv run pytest,所有測試都通過才算完成。
- 不要修改任何既有的測試。如果你認為測試寫錯了,先停下來告訴我原因。
- 加上型別提示,並執行 uv run mypy . 確認沒有錯誤。
- 完成後,用三句話告訴我你做了什麼。
(貼上測試)
- 用
uv add --dev pytest安裝 pytest,在pyproject.toml加上設定,寫好tests/test_amount.py和tests/test_report.py,跑出12 passed。 - 故意改壞一次
summarize,讀懂失敗訊息,再改回來。 - 為新功能「只看本月份」先寫測試。把下面這段加進
tests/test_report.py(filter_month也加進最上面的 import),先執行一次,確認它失敗(因為函式還不存在):
def test_filter_month_keeps_only_that_month() -> None:
october: Record = {"date": "2026-10-31", "category": "餐費", "item": "宵夜", "amount": 120}
result = filter_month(RECORDS + [october], "2026-11")
assert len(result) == 3
assert october not in result- 用上面的 Prompt 請 Claude(或自己)實作
filter_month,直到全部通過。再想一個邊界情況(例如:那個月沒有任何紀錄),補一個測試。 - 把「改完要跑 uv run pytest、不准改測試」這條規則寫進
CLAUDE.md。
讀完了嗎?
標記完成後,單元與專案的進度會自動更新(只存在這台裝置的瀏覽器)。
留言與討論
卡關了、有心得,或發現內容需要更新?都歡迎留言。留言使用 GitHub 帳號登入。