折扣函式改了一版:滿 10 件打 85 折、滿 5 件打 95 折。改完,手動丟了三筆資料進去看結果——3 件、7 件、12 件,三筆算出來都對,直接推上線。
上線第二天客服來問:「為什麼有一張『買 0 件』的訂單,金額算出來是 0 元,但系統還是把它當正常訂單處理了?」追下去才發現,那張訂單本來就不該存在——是另一個模組傳資料過來時,數量欄位沒填、預設值是 0。折扣函式沒有做任何檢查,0 乘上任何單價都是 0,程式沒有報錯,只是安安靜靜地算出一個錯誤的答案,讓一張根本不該成立的訂單混進了報表。
問題不是這次改壞了什麼,是手動測試永遠只測得到你想得到的情況。你會很自然地丟 3、7、12 這種「看起來正常」的數字,但不會主動想到 0、負數、空清單這些邊界——偏偏這些邊界正是最容易被忽略、也最容易出包的地方。而且下次改這個函式的時候,你還得重新手動測一次那三筆數字,才敢確定自己沒改壞。
這篇要做的事,是把「手動跑一次看有沒有壞」換成「寫測試,讓程式自己確認有沒有壞」——用 pytest。
為什麼是 pytest
Python 內建的測試工具是 unittest,但寫起來要繼承一個 class、每個測試方法都要用 self.assertEqual(...) 這類方法。pytest 是另一套更常用的第三方測試框架,寫起來就是普通函式配上 Python 原生的 assert,不用繼承任何東西,門檻低很多。這篇就直接用 pytest。
安裝與檔案結構
先裝套件(還不熟悉 pip 和虛擬環境的話,可以先看《Python 安裝套件與虛擬環境入門》):
pip install pytest裝完確認版本:
pytest --versionpytest 認測試靠的是檔名與函式名稱的慣例,不用另外設定:
- 測試檔案要叫
test_*.py或*_test.py(例如test_sales.py) - 測試函式要叫
test_*開頭(例如def test_calc_discount_...) - 通常放在跟原始程式同一層,或另外開一個
tests/資料夾,這篇為了簡單,兩個檔案放同一層
專案結構長這樣:
sales_project/
├── sales.py
└── test_sales.py在 sales_project/ 這一層執行:
pytest # 跑完只印簡短結果
pytest -v # -v 是 verbose,逐一列出每個測試的名稱與結果,平常開發建議用這個第一個測試:只要 assert 就好
先寫 sales.py,放一個折扣計算函式:
def calc_discount(quantity: int, unit_price: float) -> float:
"""計算折扣後的訂單金額。數量 10 件以上打 85 折、5 件以上打 95 折,其餘原價。"""
subtotal = quantity * unit_price
if quantity >= 10:
return round(subtotal * 0.85, 2)
if quantity >= 5:
return round(subtotal * 0.95, 2)
return round(subtotal, 2)再寫 test_sales.py,測試函式就是「呼叫函式,用 assert 檢查結果對不對」:
from sales import calc_discount
def test_calc_discount_no_discount_below_five():
assert calc_discount(3, 100) == 300
def test_calc_discount_ten_or_more_gets_85_percent():
assert calc_discount(10, 100) == 850沒有繼承、沒有 self,就是把「手動驗算」寫成一個會自動比對的函式。執行 pytest -v:
============================= test session starts =============================
platform win32 -- Python 3.14.3, pytest-9.1.1, pluggy-1.6.0
rootdir: C:\Users\jerry\sales_project
collected 2 items
test_sales.py::test_calc_discount_no_discount_below_five PASSED [ 50%]
test_sales.py::test_calc_discount_ten_or_more_gets_85_percent PASSED [100%]
============================== 2 passed in 0.02s ==============================每一行測試後面的 PASSED 就是一個綠燈。以後只要跑 pytest,這兩筆手動驗算過的資料就永遠有人幫你重新算一次,不用再自己動手。
pytest 真正好用的地方在測試失敗的時候。故意把 85 折的係數改錯,從 0.85 改成 0.8:
if quantity >= 10:
return round(subtotal * 0.8, 2) # 打錯成 0.8再跑一次 pytest -v:
test_sales.py::test_calc_discount_no_discount_below_five PASSED [ 50%]
test_sales.py::test_calc_discount_ten_or_more_gets_85_percent FAILED [100%]
================================== FAILURES ===================================
_______________ test_calc_discount_ten_or_more_gets_85_percent ________________
def test_calc_discount_ten_or_more_gets_85_percent():
> assert calc_discount(10, 100) == 850
E assert 800.0 == 850
E + where 800.0 = calc_discount(10, 100)
test_sales.py:9: AssertionError
=========================== short test summary info ===========================
FAILED test_sales.py::test_calc_discount_ten_or_more_gets_85_percent
========================= 1 failed, 1 passed in 0.04s =========================這就是 pytest 最值得學的地方:assert calc_discount(10, 100) == 850 這一行,pytest 自動把等號兩邊實際算出來的值印出來——assert 800.0 == 850,下面還補一行 where 800.0 = calc_discount(10, 100),直接告訴你是哪個呼叫算出了這個錯誤的值。你不用自己加 print() 除錯,光看這幾行就知道問題出在哪。把係數改回 0.85 就會恢復全綠。
邊界條件:測那些你不會主動手動測的情況
回到開場的事故。折扣函式現在完全沒防「數量是 0」這種情況,先幫它補上檢查:
def calc_discount(quantity: int, unit_price: float) -> float:
"""計算折扣後的訂單金額。quantity 為 0 或負數會丟出 ValueError。"""
if quantity <= 0:
raise ValueError(f"購買數量必須大於 0,收到 {quantity}")
subtotal = quantity * unit_price
if quantity >= 10:
return round(subtotal * 0.85, 2)
if quantity >= 5:
return round(subtotal * 0.95, 2)
return round(subtotal, 2)現在要測的不是「算出正確的數字」,是「該丟例外的時候有沒有丟」——這正好呼應《Python try/except 入門》那篇:那篇教你怎麼接住例外,這裡則是反過來,確認你的函式有沒有在該丟例外的時候,真的丟出來。pytest 提供 pytest.raises 專門做這件事:
import pytest
from sales import calc_discount
def test_calc_discount_zero_quantity_raises():
with pytest.raises(ValueError):
calc_discount(0, 100)
def test_calc_discount_negative_quantity_raises():
with pytest.raises(ValueError, match="必須大於 0"):
calc_discount(-3, 100)with pytest.raises(ValueError): 底下這段程式碼,pytest 會盯著它執行——如果真的丟出 ValueError,測試算通過;如果沒丟出來,測試算失敗,這跟一般的 assert 邏輯是反過來的。第二個測試多加了 match 參數,用正規表示式比對例外訊息的內容,確認丟出來的不只是隨便一個 ValueError,訊息也要對得上。跑起來兩個都過。
這個測試的價值要等到有人「改壞」guard 才看得出來。假設有天有人手滑把 if quantity <= 0 改成 if quantity < 0(少了等於),漏放行了數量 0:
test_sales.py::test_calc_discount_zero_quantity_raises FAILED [ 50%]
test_sales.py::test_calc_discount_negative_quantity_raises PASSED [100%]
================================== FAILURES ===================================
___________________ test_calc_discount_zero_quantity_raises ___________________
def test_calc_discount_zero_quantity_raises():
> with pytest.raises(ValueError):
^^^^^^^^^^^^^^^^^^^^^^^^^
E Failed: DID NOT RAISE ValueError
test_sales.py:7: Failed
=========================== short test summary info ===========================
FAILED test_sales.py::test_calc_discount_zero_quantity_raises - Failed...
========================= 1 failed, 1 passed in 0.04s =========================DID NOT RAISE ValueError——白話講就是「我在等你丟出 ValueError,結果你什麼都沒丟」。開場那個「買 0 件」的事故,如果當時有這個測試,這行紅字會在你 push 之前就先擋下來,而不是等客服來問。
@pytest.mark.parametrize:一次測多組輸入,不用複製貼上
折扣函式有兩個門檻:5 件、10 件。門檻附近的邊界值最容易寫錯(例如把 >= 手滑成 >),值得逐一測:4 件(門檻前)、5 件(門檻上)、9 件(門檻前)、10 件(門檻上),外加一組不同單價的組合。如果一個一個寫測試函式,會變成五個長得幾乎一樣的函式,只有數字不同。@pytest.mark.parametrize 讓你把「輸入」跟「預期結果」列成一份清單,一次餵給同一個測試函式跑完:
import pytest
from sales import calc_discount
@pytest.mark.parametrize(
"quantity, unit_price, expected",
[
(4, 100, 400), # 5 件門檻之前,原價
(5, 100, 475), # 剛好 5 件,開始打 95 折
(9, 100, 855), # 9 件,還是 95 折
(10, 100, 850), # 剛好 10 件,變成 85 折
(20, 50, 850), # 數量多、單價低,一樣算 85 折
],
)
def test_calc_discount_tiers(quantity, unit_price, expected):
assert calc_discount(quantity, unit_price) == expected第一個字串參數是欄位名稱,逗號分隔要對應到測試函式的參數;後面的清單每一個 tuple 就是一組完整的輸入加預期值。跑 pytest -v:
test_sales.py::test_calc_discount_tiers[4-100-400] PASSED [ 20%]
test_sales.py::test_calc_discount_tiers[5-100-475] PASSED [ 40%]
test_sales.py::test_calc_discount_tiers[9-100-855] PASSED [ 60%]
test_sales.py::test_calc_discount_tiers[10-100-850] PASSED [ 80%]
test_sales.py::test_calc_discount_tiers[20-50-850] PASSED [100%]
============================== 5 passed in 0.02s ==============================同一個測試函式跑了 5 次,pytest 自動用參數值幫每一次組出獨立的名稱,例如 test_calc_discount_tiers[5-100-475]。如果之後某一組壞了,測試結果會直接指名是哪一組輸入出的問題,不用自己再回頭猜。
fixture:準備測試共用的資料
sales.py 再加一個統計函式,把一批訂單的折扣後金額加總起來:
def total_revenue(orders: list[tuple[int, float]]) -> float:
"""加總一批訂單折扣後的總營收。orders 是 (數量, 單價) 的清單。"""
return round(sum(calc_discount(q, p) for q, p in orders), 2)測試這個函式需要先準備一份「訂單清單」當輸入。如果之後還有別的測試也要用同一份資料,每個測試函式裡都各自寫一次清單,資料改一次就要到處改。fixture 就是用來解決這件事的:把「準備測試資料」寫成一個獨立函式,加上 @pytest.fixture,測試函式只要把它的名稱寫進參數,pytest 就會自動呼叫、把回傳值傳進來:
@pytest.fixture
def sample_orders():
"""三筆訂單:(數量, 單價),涵蓋原價、95 折、85 折三種情況"""
return [(3, 100), (5, 100), (10, 100)]
def test_total_revenue_sums_discounted_orders(sample_orders):
assert total_revenue(sample_orders) == 1625sample_orders 這個名字在測試函式的參數列裡出現,pytest 就會去找同名的 fixture 函式,執行它,把回傳的清單傳進來,測試函式裡直接當一般參數用。fixture 目前先知道這個基本用法就夠——它還有像「多個測試檔共用」這類進階設定,之後遇到再學也不遲。
別忘了補一個邊界條件:空清單。
def test_total_revenue_empty_list_is_zero():
assert total_revenue([]) == 0沒有訂單,加總結果應該是 0,而不是報錯或回傳 None——這也是「手動測」很容易漏掉的情況,因為你手動驗算時通常不會拿一份空清單去測。
再多一句:先寫測試再寫實作
前面都是「先有函式,再補測試」。有一種做法是反過來,先把測試寫好(這時候函式甚至還不存在,跑起來一定是紅的),再照著測試的期待去寫實作,寫到測試轉綠為止——這個做法叫「測試驅動開發」(TDD)。這篇不深入展開,先知道這個做法存在,等你對寫測試更熟之後再研究。
完整成品:sales.py + test_sales.py
把前面所有片段組起來,這是完整的兩個檔案。
sales.py:
"""銷售資料整理腳本 —— 折扣計算與統計"""
def calc_discount(quantity: int, unit_price: float) -> float:
"""計算折扣後的訂單金額。
數量 10 件以上打 85 折、5 件以上打 95 折,其餘原價。
quantity 為 0 或負數會丟出 ValueError(不是合法的訂單)。
"""
if quantity <= 0:
raise ValueError(f"購買數量必須大於 0,收到 {quantity}")
subtotal = quantity * unit_price
if quantity >= 10:
return round(subtotal * 0.85, 2)
if quantity >= 5:
return round(subtotal * 0.95, 2)
return round(subtotal, 2)
def total_revenue(orders: list[tuple[int, float]]) -> float:
"""加總一批訂單折扣後的總營收。orders 是 (數量, 單價) 的清單。"""
return round(sum(calc_discount(q, p) for q, p in orders), 2)test_sales.py:
import pytest
from sales import calc_discount, total_revenue
def test_calc_discount_no_discount_below_five():
assert calc_discount(3, 100) == 300
def test_calc_discount_ten_or_more_gets_85_percent():
assert calc_discount(10, 100) == 850
@pytest.mark.parametrize(
"quantity, unit_price, expected",
[
(4, 100, 400), # 5 件門檻之前,原價
(5, 100, 475), # 剛好 5 件,開始打 95 折
(9, 100, 855), # 9 件,還是 95 折
(10, 100, 850), # 剛好 10 件,變成 85 折
(20, 50, 850), # 數量多、單價低,一樣算 85 折
],
)
def test_calc_discount_tiers(quantity, unit_price, expected):
assert calc_discount(quantity, unit_price) == expected
def test_calc_discount_zero_quantity_raises():
with pytest.raises(ValueError):
calc_discount(0, 100)
def test_calc_discount_negative_quantity_raises():
with pytest.raises(ValueError, match="必須大於 0"):
calc_discount(-3, 100)
@pytest.fixture
def sample_orders():
"""三筆訂單:(數量, 單價),涵蓋原價、95 折、85 折三種情況"""
return [(3, 100), (5, 100), (10, 100)]
def test_total_revenue_sums_discounted_orders(sample_orders):
assert total_revenue(sample_orders) == 1625
def test_total_revenue_empty_list_is_zero():
assert total_revenue([]) == 0全部跑一次 pytest -v:
============================= test session starts =============================
platform win32 -- Python 3.14.3, pytest-9.1.1, pluggy-1.6.0
rootdir: C:\Users\jerry\sales_project
collected 11 items
test_sales.py::test_calc_discount_no_discount_below_five PASSED [ 9%]
test_sales.py::test_calc_discount_ten_or_more_gets_85_percent PASSED [ 18%]
test_sales.py::test_calc_discount_tiers[4-100-400] PASSED [ 27%]
test_sales.py::test_calc_discount_tiers[5-100-475] PASSED [ 36%]
test_sales.py::test_calc_discount_tiers[9-100-855] PASSED [ 45%]
test_sales.py::test_calc_discount_tiers[10-100-850] PASSED [ 54%]
test_sales.py::test_calc_discount_tiers[20-50-850] PASSED [ 63%]
test_sales.py::test_calc_discount_zero_quantity_raises PASSED [ 72%]
test_sales.py::test_calc_discount_negative_quantity_raises PASSED [ 81%]
test_sales.py::test_total_revenue_sums_discounted_orders PASSED [ 90%]
test_sales.py::test_total_revenue_empty_list_is_zero PASSED [100%]
============================= 11 passed in 0.03s ==============================11 個綠燈,涵蓋了正常情況、兩個折扣門檻的邊界、該丟例外的邊界,還有加總的邊界(空清單)。這份清單就是你以後改這支腳本的安全網——不管改的是折扣邏輯、加總邏輯,還是哪個門檻的數字,改完跑一次 pytest,11 個綠燈全過才敢說沒改壞。
最後示範一次「安全網真的接住東西」的樣子。假設有人手滑把 95 折的係數從 0.95 改成 0.9:
if quantity >= 5:
return round(subtotal * 0.9, 2) # 手滑改錯再跑一次 pytest -v:
test_sales.py::test_calc_discount_tiers[4-100-400] PASSED [ 27%]
test_sales.py::test_calc_discount_tiers[5-100-475] FAILED [ 36%]
test_sales.py::test_calc_discount_tiers[9-100-855] FAILED [ 45%]
test_sales.py::test_calc_discount_tiers[10-100-850] PASSED [ 54%]
...
test_sales.py::test_total_revenue_sums_discounted_orders FAILED [ 90%]
...
========================= 3 failed, 8 passed in 0.05s =========================一個小數點改錯,直接炸出 3 個失敗:兩個門檻測試(5 件、9 件),加上用到同一個函式的 total_revenue 測試也跟著壞掉。這就是測試的複利效果——你只寫了 11 個測試,但它們會在你意想不到的地方(例如另一個函式間接用到壞掉的邏輯)幫你多抓一層。
這篇也是「把入門 Python 寫成能維護的程式」系列的最後一篇。從用 pathlib 讀檔案、collections 做統計、dataclass 表示訂單、型別註解讓 mypy 幫你抓型別錯誤,到這篇用 pytest 幫函式寫測試——這五步走完,你手上的腳本已經不只是「跑得動」,而是改了也有把握還是對的。這些內容在「Python 基礎入門」課程裡也有對應的練習可以動手寫。
參考資料: - pytest — Get Started - pytest — How to write and report assertions in tests - pytest — How to parametrize fixtures and test functions - pytest — How to use fixtures
*本文程式碼與輸出實際以 Python 3.14.3、pytest 9.1.1(2026 年 6 月發布的目前最新版)跑過確認。*