pytest 完整指南 2026:fixture、parametrize 與平行化實測

用一個可執行的電商折扣專案建立 pytest 9 測試套件:parametrize 測邊界、fixture 三種作用域的實際建立次數、monkeypatch 與 tmp_path、pytest-asyncio 非同步測試、coverage 覆蓋率,以及 xdist 平行化實測(6.02 秒降到 2.23 秒),最後整理五個實際踩到的坑與 GitHub Actions 設定。

  • Dennis
  • 7 分鐘閱讀
pytest 完整指南 2026:fixture、parametrize 與平行化實測

pytest 是 Python 目前最主流的測試框架:用純 assert 寫斷言、用 fixture 管理測試資源、用 parametrize 把一張資料表變成十幾個測試,再加 pytest-cov 算覆蓋率、pytest-xdist 平行化。這篇用一個真實可執行的電商折扣專案,把這五件事全部跑過一次,並附上實測數字與五個實際踩到的坑。

為什麼不用 unittest

標準庫的 unittest 沒有問題,但日常寫起來摩擦很大:要繼承 TestCase、斷言要用 self.assertEqual 這類方法、每個測試檔要自己想辦法共用資料。pytest 把這些都拿掉,代價只是多一個開發依賴。

項目unittestpytest
斷言self.assertEqual(a, b)assert a == b
測試探索需繼承 TestCase 類別函式名 test_* 就夠
共用資源setUp/tearDownfixture(可組合、有作用域)
參數化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 次(每支測試各一次)會被測試改到的可變狀態
module1 次(整個檔案共用)讀檔後的解析結果、類別層級資源
session1 次(整輪測試共用)資料庫連線池、啟動很貴的服務

這個機制最實用的地方是「貴的東西只做一次,便宜的東西每次重做」。把 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 s1.00×
-n 23.34 s1.80×
-n 42.23 s2.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
簡短 tracebackpytest --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。真正該問的問題不是「幾 %」,而是「如果這段程式壞了,哪支測試會紅」。

站內延伸閱讀

原始來源

📬 訂閱 most.tw 電子報

每週精選 AI 工具教學與技術乾貨,直接送到你的信箱。免費、隨時可退訂。

訂閱即表示同意收到 most.tw 電子報,隨時可一鍵退訂。

💬 有問題想討論?加 LINE 聯絡我

歡迎透過 LINE 官方帳號直接留言,我會盡快回覆你的問題。

加入 LINE 好友