所有入門單元免費開放 · 課程內容持續更新中

用 pytest 寫自動化測試

每次改程式,都要手動把每個功能點一遍嗎?這堂課用 pytest 替記帳工具寫一份「會自己跑的驗收清單」,一個指令就知道有沒有東西被改壞。這也是讓 AI Agent 放心動手改程式的安全網。

第 3.5 堂約 16 分鐘

改了 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 元,前面的便當被蓋掉了。

改回正確的寫法,再跑一次,回到全部通過。這個「改一點、跑測試、確認綠燈」的節奏,就是之後所有改動的基本流程。

循環圖:修改程式後執行 uv run pytest;全部通過就是綠燈,可以安心保存;有測試失敗就是紅燈,看失敗訊息修正後再跑一次,直到全部通過

測試是安全網:不管是你還是 AI 改的,掉下去都接得住。

測試是你和 AI 之間的合約

學會測試之後,你請 AI 寫程式的方式可以整個升級。以「只看本月份」為例:

  1. 你先寫測試,把「做對是什麼樣子」講清楚:輸入哪些紀錄、指定哪個月份、應該留下哪幾筆。
  2. 請 Claude Code 實作,條件是 uv run pytest 全部通過。
  3. 你檢查測試結果和程式,確認它沒有走捷徑。

這種「先寫測試、再寫程式」的做法,有個名字叫測試驅動開發(TDD)。你不需要每次都這樣做,但跟 AI 合作時特別好用:測試就是一份 AI 讀得懂、而且能自己驗收的規格書。

合約示意圖:你負責寫測試,定義做對是什麼樣子;AI 負責寫程式;uv run pytest 當公正的裁判,判定是否完成。另外標註紅線:AI 不可以為了過關修改測試

請 Claude 照著測試實作

我的記帳專案用 uv 管理,測試用 pytest。我在 tests/test_report.py 新增了 test_filter_month 這個測試(貼在下面),請在 tracker/report.py 實作 filter_month,讓它通過。

規則:

  1. 執行 uv run pytest,所有測試都通過才算完成。
  2. 不要修改任何既有的測試。如果你認為測試寫錯了,先停下來告訴我原因。
  3. 加上型別提示,並執行 uv run mypy . 確認沒有錯誤。
  4. 完成後,用三句話告訴我你做了什麼。

(貼上測試)

行動練習
  1. 用 uv add --dev pytest 安裝 pytest,在 pyproject.toml 加上設定,寫好 tests/test_amount.py 和 tests/test_report.py,跑出 12 passed。
  2. 故意改壞一次 summarize,讀懂失敗訊息,再改回來。
  3. 為新功能「只看本月份」先寫測試。把下面這段加進 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
  1. 用上面的 Prompt 請 Claude(或自己)實作 filter_month,直到全部通過。再想一個邊界情況(例如:那個月沒有任何紀錄),補一個測試。
  2. 把「改完要跑 uv run pytest、不准改測試」這條規則寫進 CLAUDE.md。

讀完了嗎?

標記完成後,單元與專案的進度會自動更新(只存在這台裝置的瀏覽器)。

卡住了?讓 AI 當你的家教複製這段提問範本,貼到 Claude 或其他 AI 工具,再補上你卡住的地方。

留言與討論

卡關了、有心得,或發現內容需要更新?都歡迎留言。留言使用 GitHub 帳號登入。

留言區即將開放。現在可以先到 GitHub 討論區 發問,或看看 交流頁 的其他管道。