pytest 是 Python 目前最主流的測試框架:用純 assert 寫斷言、用 fixture 管理測試資源、用 parametrize 把一張資料表變成十幾個測試,再加 pytest-cov 算覆蓋率、pytest-xdist 平行化。這篇用一個真實可執行的電商折扣專案,把這五件事全部跑過一次,並附上實測數字與五個實際踩到的坑。
為什麼不用 unittest
標準庫的 unittest 沒有問題,但日常寫起來摩擦很大:要繼承 TestCase、斷言要用 self.assertEqual 這類方法、每個測試檔要自己想辦法共用資料。pytest 把這些都拿掉,代價只是多一個開發依賴。
| 項目 | unittest | pytest |
|---|---|---|
| 斷言 | self.assertEqual(a, b) | assert a == b |
| 測試探索 | 需繼承 TestCase 類別 | 函式名 test_* 就夠 |
| 共用資源 | setUp/tearDown | fixture(可組合、有作用域) |
| 參數化 | subTest 迴圈 | @pytest.mark.parametrize |
| 失敗訊息 | 需自己補說明 | 自動展開運算式中間值 |
| 生態 | 內建 | xdist、cov、asyncio、mock 一整套外掛 |
本文的實測環境:Python 3.12.3、pytest 9.2.0、pytest-xdist 3.8.0、pytest-cov 7.1.0、pytest-asyncio 1.5.0,2 vCPU 的 Linux 主機。
先把專案立起來
uv venv .venv
uv pip install --python .venv/bin/python pytest pytest-cov pytest-xdist pytest-asyncio
.venv/bin/python -m pytest --version
目錄結構刻意拆成「被測程式」與「測試」兩邊,之後 CI 與 coverage 都好設定:
demo/
├── app/
│ ├── __init__.py
│ └── pricing.py # 被測邏輯:折扣、運費、稅
├── tests/
│ ├── conftest.py # 共用 fixture
│ ├── test_pricing.py # 17 個測試(含 parametrize 展開)
│ ├── test_scope.py # fixture 作用域驗證
│ ├── test_async.py # 非同步測試
│ └── test_slow.py # 12 個慢速測試,用來量平行化
└── pytest.ini
pytest.ini 只有五行,但每一行都在後面實測中發揮作用:
[pytest]
testpaths = tests
addopts = -ra --strict-markers
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
markers =
slow: 慢速測試,可用 -m "not slow" 跳過
--strict-markers 讓打錯的 marker 立刻報錯(而不是默默被忽略),asyncio_mode = auto 讓 async def test_* 不用每支都掛裝飾器。
被測程式:折扣計算
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
TW_VAT_RATE = Decimal("0.05")
class InvalidCoupon(ValueError):
"""折扣碼不合法時丟出。"""
@dataclass(frozen=True)
class Coupon:
code: str
kind: str # "percent" 或 "fixed"
value: Decimal
min_subtotal: Decimal = Decimal("0")
def apply_coupon(subtotal: Decimal, coupon: Coupon | None) -> Decimal:
if coupon is None:
return subtotal
if coupon.min_subtotal and subtotal < coupon.min_subtotal:
raise InvalidCoupon(f"{coupon.code} 需滿 {coupon.min_subtotal} 元")
if coupon.kind == "percent":
if not (0 < coupon.value <= 100):
raise InvalidCoupon("百分比折扣需介於 1-100")
return (subtotal * (1 - coupon.value / 100)).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
if coupon.kind == "fixed":
if coupon.value <= 0:
raise InvalidCoupon("固定折扣需大於 0")
return max(Decimal("0"), subtotal - coupon.value)
raise InvalidCoupon(f"未知折扣類型:{coupon.kind}")
金額一律用 Decimal,最後才 quantize 成整數——用 float 寫折扣,遲早會遇到 0.1 + 0.2 != 0.3 的發票金額對不上。
parametrize:一張表測完所有情境
折扣邏輯最容易出錯的地方不是「有沒有折」,而是邊界:折完變負數、剛好等於門檻、百分比超過 100。這些用 parametrize 一行一個案例,失敗時 pytest 會單獨標出是哪一組:
@pytest.mark.parametrize(
"subtotal,coupon,expected",
[
(Decimal("1000"), None, Decimal("1000")),
(Decimal("1000"), Coupon("TEN", "percent", Decimal("10")), Decimal("900")),
(Decimal("999"), Coupon("TEN", "percent", Decimal("10")), Decimal("899")),
(Decimal("1000"), Coupon("MINUS100", "fixed", Decimal("100")), Decimal("900")),
(Decimal("50"), Coupon("MINUS100", "fixed", Decimal("100")), Decimal("0")),
(Decimal("100"), Coupon("BIG", "fixed", Decimal("100")), Decimal("0")),
],
)
def test_apply_coupon(subtotal, coupon, expected):
assert apply_coupon(subtotal, coupon) == expected
負向測試則用 pytest.raises,順便驗證錯誤訊息內容:
@pytest.mark.parametrize("bad", [
Coupon("P200", "percent", Decimal("200")),
Coupon("P0", "percent", Decimal("0")),
Coupon("ZERO", "fixed", Decimal("0")),
Coupon("WTF", "bogus", Decimal("5")),
])
def test_invalid_coupon_raises(bad):
with pytest.raises(InvalidCoupon):
apply_coupon(Decimal("1000"), bad)
def test_min_subtotal_not_reached():
c = Coupon("VIP500", "fixed", Decimal("500"), min_subtotal=Decimal("5000"))
with pytest.raises(InvalidCoupon, match="需滿 5000"):
apply_coupon(Decimal("1000"), c)
match= 吃的是正規表達式,只寫關鍵片段就好,不要整句比對。
fixture 作用域:實測「到底建立幾次」
fixture 是 pytest 最容易被誤解的部分。文件說 function 每支測試一次、module 每個檔案一次、session 整輪一次,但很少人真的量過。做法是做一個計數器 fixture,讓三次建立都留下痕跡:
# tests/conftest.py
import pytest
class CallCounter:
def __init__(self) -> None:
self.counts = {"function": 0, "module": 0, "session": 0}
def bump(self, name: str) -> int:
self.counts[name] += 1
return self.counts[name]
@pytest.fixture(scope="session")
def counter() -> CallCounter:
return CallCounter()
@pytest.fixture
def fn_fixture(counter):
return counter.bump("function")
@pytest.fixture(scope="module")
def module_fixture(counter):
return counter.bump("module")
@pytest.fixture(scope="session")
def session_fixture(counter):
return counter.bump("session")
接著四個測試依序斷言拿到的值:
def test_a(session_fixture, module_fixture, fn_fixture):
assert (session_fixture, module_fixture, fn_fixture) == (1, 1, 1)
def test_b(session_fixture, module_fixture, fn_fixture):
assert (session_fixture, module_fixture, fn_fixture) == (1, 1, 2)
def test_c(session_fixture, module_fixture, fn_fixture):
assert (session_fixture, module_fixture, fn_fixture) == (1, 1, 3)
def test_zz_final_counters(counter):
assert counter.counts == {"function": 3, "module": 1, "session": 1}
跑起來四支全過,實測結果如下:
| 作用域 | 建立的次數 | 適合放什麼 |
|---|---|---|
function(預設) | 3 次(每支測試各一次) | 會被測試改到的可變狀態 |
module | 1 次(整個檔案共用) | 讀檔後的解析結果、類別層級資源 |
session | 1 次(整輪測試共用) | 資料庫連線池、啟動很貴的服務 |
這個機制最實用的地方是「貴的東西只做一次,便宜的東西每次重做」。把 DB 連線放 session、把交易(transaction)放 function 並在 teardown 回滾,是實務上最常見的組合。
tmp_path 與 monkeypatch:不碰真實環境
tmp_path 給每支測試一個獨立的暫存目錄,monkeypatch 則負責暫時改掉環境變數、屬性、或替換函式。兩者都會在測試結束後自動還原,不需要自己寫清理:
@pytest.fixture
def catalog(tmp_path):
p = tmp_path / "catalog.json"
p.write_text(json.dumps([
{"sku": "A001", "name": "機械鍵盤", "price": 3290},
{"sku": "A002", "name": "滑鼠墊", "price": 390, "qty": 2},
], ensure_ascii=False), encoding="utf-8")
return load_catalog(p)
def test_shipping_threshold_from_env(monkeypatch):
monkeypatch.setenv("FREE_SHIPPING_THRESHOLD", "2000")
assert shipping_fee(Decimal("1500")) == Decimal("80")
assert shipping_fee(Decimal("2000")) == Decimal("0")
被測程式用 os.environ.get("FREE_SHIPPING_THRESHOLD", "1000") 讀設定,測試用 monkeypatch.setenv 改值。這比在測試裡真的去改 os.environ 安全得多,尤其在平行執行時——改全域環境會直接污染其他 worker。
非同步測試:pytest-asyncio
有 asyncio_mode = auto 之後,async def test_* 直接就能跑。要測的東西盡量用手寫的假物件,不要在單元測試裡真的發 HTTP 請求:
class FakeRateClient:
def __init__(self, rate: str):
self.rate = Decimal(rate)
self.calls = 0
async def get_discount_rate(self, code: str) -> Decimal:
self.calls += 1
await asyncio.sleep(0.01)
return self.rate if code == "TEN" else Decimal("0")
async def test_async_coupon_flow():
client = FakeRateClient("10")
rate = await client.get_discount_rate("TEN")
assert apply_coupon(Decimal("1000"), Coupon("TEN", "percent", rate)) == Decimal("900")
assert client.calls == 1
client.calls == 1 這一行常被忽略,但它才是「有沒有重複打 API」的證據。沒有這行,測試只能證明算得對,不能證明沒多打。
覆蓋率:50 行、100%
.venv/bin/python -m pytest -m "not slow" --cov=app --cov-report=term-missing
實測輸出:
Name Stmts Miss Cover Missing
-----------------------------------------------
app/__init__.py 0 0 100%
app/pricing.py 50 0 100%
-----------------------------------------------
TOTAL 50 0 100%
23 passed, 12 deselected in 0.12s
term-missing 會列出沒被執行到的行號,這是補測試最快的線索。但要記得覆蓋率是「有沒有跑到」而不是「有沒有驗對」——一行 return subtotal 被跑到不代表結果正確,斷言才是。
平行化:2 核主機上 6.02 秒變 2.23 秒
pytest-xdist 用 -n 指定 worker 數量。為了量出真實加速比,準備 12 支各睡 0.5 秒的測試(模擬 I/O 等待),總工作量固定 6 秒:
@pytest.mark.slow
@pytest.mark.parametrize("i", range(12))
def test_sleepy(i):
time.sleep(0.5)
assert i >= 0
三種跑法各跑三次取最佳值(2 vCPU 主機):
| 跑法 | 測試回報時間 | 相對序列加速 |
|---|---|---|
| 序列(無 xdist) | 6.02 s | 1.00× |
-n 2 | 3.34 s | 1.80× |
-n 4 | 2.23 s | 2.70× |
兩個值得注意的現象。
第一,-n 4 在 2 核心機器上比 -n 2 快,這不是量測錯誤。這批測試全部是 sleep(I/O 等待),不佔 CPU,worker 多開就能重疊等待時間。反過來說,如果測試是純 CPU 計算,開超過核心數只會互相搶資源。
第二,加速比永遠不會等於 worker 數。-n 4 理論上最快 1.5 秒(6 秒 ÷ 4),實測 2.23 秒,差額是 worker 啟動、測試分派與結果彙整的固定成本。測試數量越少、每支越快,這個固定成本佔比越高——只有 23 支 0.1 秒的測試時,-n 4 反而可能比序列更慢。
實務建議:先用 --durations=10 找出慢測試,只對「慢又彼此獨立」的部分開 xdist,其餘照常。CI 上可以直接用 -n auto。
常用指令速查
| 目的 | 指令 |
|---|---|
| 只跑檔名含 shipping 的測試 | pytest -k shipping |
| 跳過慢速測試 | pytest -m "not slow" |
| 只重跑上次失敗的 | pytest --lf |
| 第一支失敗就停 | pytest -x |
| 最多失敗 3 支就停 | pytest --maxfail=3 |
| 列出最慢的 10 支 | pytest --durations=10 |
| 顯示測試名稱與結果 | pytest -v |
| 簡短 traceback | pytest --tb=short |
| 只收集不執行(檢查語法) | pytest --collect-only -q |
五個實際踩到的坑
| 坑 | 症狀 | 解法 |
|---|---|---|
| marker 打錯字 | 整輪測試在「收集階段」就中斷,一支都沒跑 | --strict-markers 是好事,但要記得打錯不是「忽略」而是「中止」 |
| 從 conftest 直接 import 變數 | 計數器永遠是 0,assert 全掛 | 測試檔用 from tests.conftest import X 會建立另一個模組實例,與 pytest 載入的 conftest 不是同一個物件;改用 fixture 傳遞狀態 |
| asyncio fixture 迴圈作用域未設 | 每次執行都噴 PytestDeprecationWarning | 在 pytest.ini 加 asyncio_default_fixture_loop_scope = function,警告消失 |
| xdist 下 session fixture 不共用 | 把 -n 4 加上去,session fixture 的計數從 1 變成 4 | 每個 worker 是獨立行程,session 的範圍只在「該 worker 內」;跨 worker 共用狀態要用外部資源(DB、Redis) |
| 用 float 算錢 | 折扣後金額出現 0.000000001 的尾差,assert 掛在奇怪的地方 | 全程 Decimal,最後才 quantize 成整數 |
第三個坑值得多說一句。這個警告不是錯誤,測試照樣會過,所以很多人就放著。但 pytest-asyncio 已經明說未來版本會改變預設值,等到真的改的那天,測試才會突然壞掉——現在補上這一行,等於先買保險。
接到 CI
GitHub Actions 上跑同一套指令,並行與覆蓋率都能一起用:
name: tests
on: [push, pull_request]
jobs:
pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e ".[dev]"
- run: pytest -m "not slow" --cov=app --cov-report=xml -n auto
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage
path: coverage.xml
if: always() 讓測試失敗時也把覆蓋率報告留下來,排查時很有用。
什麼時候該寫測試、什麼時候別浪費時間
三種情況一定要寫:金額與稅務計算、權限判斷、對外 API 的輸入驗證。這三類錯誤的代價是「賠錢或出事」,而且邏輯單純、測試寫起來便宜。
三種情況可以先跳過:一次性的資料遷移腳本、還沒定案的 UI 排版、外部服務本身就不穩定的整合(先寫契約測試或 mock,不要寫成 flaky 的整合測試)。
至於覆蓋率數字,從 0 到 60% 很容易,從 90% 到 95% 往往要寫一堆 mock。真正該問的問題不是「幾 %」,而是「如果這段程式壞了,哪支測試會紅」。
站內延伸閱讀
- Python 自動化測試:pytest 入門與 TDD 實踐
- FastAPI 完整指南 2026:路由、驗證、測試與部署
- Ruff 完整指南:取代 Flake8、Black、isort 的 Python 工具鏈
- GitHub Actions 完整指南 2026
