快轉到主要內容
  1. 教學文章/

Python pytest fixtures 進階:conftest、factory 與測試資料管理

·8 分鐘· loading · loading · ·
Python Pytest Fixtures Testing Conftest Monkeypatch Developer-Tools
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 67: 本文

featured

一. 前言:fixture 寫得好,測試才長得大
#

嗨,這裡是拍拍君。 很多人第一次學 pytest,會先覺得它很清爽。 不用 class。 不用 self.assertEqual。 直接寫普通函式和 assert。 快樂。 但測試一多,另一個問題很快就會冒出來: 「為什麼每個測試都在手動建立一樣的資料?」 「為什麼 API client、暫存檔、環境變數到處散落?」 「為什麼某個測試單獨跑會過,整包跑就爆?」 這些通常不是 pytest 不好用。 是 fixture 還沒整理好。 之前拍拍君在 pytest 入門篇 已經介紹過 fixture、parametrize 和 mock 的基本用法。 今天這篇會往更實戰的方向走。 我們不再只問「fixture 怎麼寫」。 而是問:

  • fixture 應該放在哪裡?
  • 測試資料怎麼避免互相污染?
  • 什麼時候該用 factory fixture?
  • tmp_pathmonkeypatch 這些內建 fixture 怎麼用?
  • scope 開大會比較快,但會不會更危險? 如果你正在維護一個有幾十、幾百個測試的 Python 專案,這些問題很快就會變成日常。 拍拍君覺得 fixture 最好的狀態,是讓測試看起來像在描述行為,而不是在展示施工現場。 我們來把施工現場收乾淨。

二. 安裝與範例專案
#

這篇會使用 pytest。 如果你已經有專案,只需要確認 dev dependency 有裝好:

uv add --dev pytest

或是用 pip:

python -m pip install pytest

我們先建立一個小小的範例結構:

pypy-shop/
├── pyproject.toml
├── src/
│   └── pypy_shop/
│       ├── __init__.py
│       ├── cart.py
│       └── settings.py
└── tests/
    ├── conftest.py
    ├── test_cart.py
    └── test_settings.py

假設 cart.py 長這樣:

from dataclasses import dataclass
@dataclass
class Item:
    name: str
    price: int
    quantity: int = 1
class Cart:
    def __init__(self) -> None:
        self.items: list[Item] = []
    def add(self, item: Item) -> None:
        self.items.append(item)
    def total(self) -> int:
        return sum(item.price * item.quantity for item in self.items)
    def has_free_shipping(self) -> bool:
        return self.total() >= 1000

這個例子刻意簡單。 因為 fixture 的重點不是業務邏輯多華麗,而是測試資料怎麼整理。

三. 先從最普通的 fixture 開始
#

最基本的 fixture 是把重複建立資料的程式碼抽出來。

import pytest
from pypy_shop.cart import Cart, Item
@pytest.fixture
def cart() -> Cart:
    return Cart()
@pytest.fixture
def milk_tea() -> Item:
    return Item(name="拍拍奶茶", price=80)

測試就可以直接宣告自己需要什麼:

def test_empty_cart_total_is_zero(cart: Cart) -> None:
    assert cart.total() == 0
def test_add_item_to_cart(cart: Cart, milk_tea: Item) -> None:
    cart.add(milk_tea)
    assert cart.total() == 80

這裡有一個很重要的觀念: fixture 不是「全域變數」。 預設情況下,每個測試函式都會拿到新的 fixture 結果。 所以這兩個測試不會共用同一台 cart。 這也是 pytest fixture 很適合測試資料的原因。 你可以把測試寫得像這樣:

def test_free_shipping(cart: Cart) -> None:
    cart.add(Item(name="拍拍鍵盤", price=1200))
    assert cart.has_free_shipping()

測試裡只保留跟行為有關的資料。 其餘準備工作交給 fixture。

四. conftest.py:fixture 的自動索引
#

如果 fixture 只在單一檔案使用,放在測試檔案上方就好。 但只要多個測試檔案都需要,就可以放進 conftest.py

# tests/conftest.py
import pytest
from pypy_shop.cart import Cart, Item
@pytest.fixture
def cart() -> Cart:
    return Cart()
@pytest.fixture
def sample_item() -> Item:
    return Item(name="拍拍餅乾", price=120)

然後任何同一層或子目錄裡的測試,都可以直接使用:

def test_add_sample_item(cart, sample_item) -> None:
    cart.add(sample_item)
    assert cart.total() == 120

你不需要 import cart fixture。 pytest 會自己找。 這一點很方便,但也容易被濫用。 拍拍君的建議是:

  • 專案共用的 fixture 放在 tests/conftest.py
  • 某個子系統專用的 fixture 放在子目錄的 conftest.py
  • 只有單一測試檔用到的 fixture 放在該測試檔 例如:
tests/
├── conftest.py              # 全專案共用
├── test_cart.py
└── api/
    ├── conftest.py          # 只有 api/ 底下使用
    └── test_checkout_api.py

fixture 放太遠,測試會變得像在猜謎。 fixture 放太近,重複又會增加。 中間那條線,就是測試可維護性的手感。

五. yield fixture:把清理動作綁在資源旁邊
#

很多測試會建立資源。 例如檔案、資料庫連線、背景服務、暫存設定。 這種 fixture 不只要 setup,也要 teardown。 pytest 可以用 yield 來表達:

import pytest
@pytest.fixture
def opened_log_file(tmp_path):
    log_path = tmp_path / "pypy.log"
    handle = log_path.open("w", encoding="utf-8")
    yield handle
    handle.close()

測試使用起來很自然:

def test_write_log(opened_log_file) -> None:
    opened_log_file.write("拍拍君啟動\n")
    assert not opened_log_file.closed

yield 前面是 setup。 yield 後面是 teardown。 就算測試失敗,teardown 也會執行。 更常見的寫法是搭配 context manager:

@pytest.fixture
def seeded_database():
    db = connect_test_database()
    db.insert_user(name="拍拍君")
    yield db
    db.clear()
    db.close()

重點是:清理邏輯要放在建立資源的地方。 不要讓每個測試自己記得清。 人類很容易忘。 CI 不會同情你。

六. fixture scope:快一點,還是乾淨一點?
#

fixture 預設是 function scope。 也就是每個測試函式都重新建立一次。 你可以改成:

@pytest.fixture(scope="module")
def expensive_model():
    return load_model_once()

常見 scope 有幾種:

Scope 生命週期 適合放什麼
function 每個測試一次 大部分測試資料
class 每個測試 class 一次 class 型測試組
module 每個測試檔一次 偏昂貴、可安全共用的資源
package 每個 package 一次 大型整合測試資源
session 整次 pytest 一次 唯讀設定、測試伺服器、模型載入
scope 不是越大越好。 scope 越大,越容易讓測試互相影響。 例如這樣就很危險:
@pytest.fixture(scope="session")
def shared_cart() -> Cart:
    return Cart()

如果某個測試加了商品,下一個測試可能會吃到殘留狀態。 測試會變得跟執行順序有關。 這種 bug 很煩。 拍拍君的經驗法則:

  • 可變資料預設用 function
  • 唯讀設定可以考慮 session
  • 昂貴資源要共用時,提供 reset 或交易 rollback
  • 不確定時,先選乾淨,不要先選快 快的測試很好。 但會互相污染的快測試,只是比較快抵達痛苦。

七. Factory fixture:測試需要變化資料時
#

如果 fixture 永遠回傳同一筆資料,很快就會遇到限制。 例如每個測試都需要不同商品:

def test_discount_item(cart):
    item = Item(name="拍拍貼紙", price=50, quantity=10)
    cart.add(item)
    assert cart.total() == 500

你可以寫很多 fixture。 但那會膨脹得很快。 更好的方式是讓 fixture 回傳一個 factory function:

import pytest
from pypy_shop.cart import Item
@pytest.fixture
def item_factory():
    def make_item(
        name: str = "拍拍餅乾",
        price: int = 100,
        quantity: int = 1,
    ) -> Item:
        return Item(name=name, price=price, quantity=quantity)
    return make_item

測試就可以只覆蓋自己在意的欄位:

def test_bulk_purchase_total(cart, item_factory) -> None:
    cart.add(item_factory(price=80, quantity=3))
    assert cart.total() == 240
def test_free_shipping_boundary(cart, item_factory) -> None:
    cart.add(item_factory(name="拍拍耳機", price=999))
    assert not cart.has_free_shipping()
def test_free_shipping_reaches_threshold(cart, item_factory) -> None:
    cart.add(item_factory(name="拍拍鍵盤", price=1000))
    assert cart.has_free_shipping()

factory fixture 的好處是:

  • 預設值集中管理
  • 測試只描述差異
  • 新欄位加入時比較好改
  • 可以避免一堆 small_itemlarge_itemfree_shipping_item 如果你在測資料模型、API payload、ORM row,factory fixture 通常非常香。

八. tmp_path:不要自己手刻暫存路徑
#

檔案測試很常見。 例如設定檔、匯出報表、快取、上傳處理。 不要這樣寫:

def test_export_report():
    path = "/tmp/report.csv"
    export_report(path)
    assert Path(path).exists()

這會有幾個問題:

  • 平行測試可能撞檔名
  • 上一次失敗留下的檔案可能污染下一次
  • Windows 路徑不一定長一樣
  • CI 環境可能沒有你想像中的權限 pytest 內建 tmp_path fixture。 它會給你一個乾淨的 pathlib.Path 物件。
from pathlib import Path
def export_report(path: Path, rows: list[str]) -> None:
    path.write_text("\n".join(rows), encoding="utf-8")
def test_export_report(tmp_path: Path) -> None:
    report = tmp_path / "report.csv"
    export_report(report, ["name,total", "拍拍君,120"])
    assert report.read_text(encoding="utf-8") == "name,total\n拍拍君,120"

tmp_path 每個測試都不同。 測完 pytest 會幫你管理。 如果你需要在 fixture 裡建立測試檔,也可以直接吃 tmp_path

@pytest.fixture
def config_file(tmp_path: Path) -> Path:
    path = tmp_path / "settings.toml"
    path.write_text('store_name = "Daily Pypy"\n', encoding="utf-8")
    return path

這比自己 mkdir、自己命名、自己清理安全多了。

九. monkeypatch:改環境,但測完自動還原
#

測設定時常常會遇到環境變數。 假設 settings.py 長這樣:

import os
def get_api_url() -> str:
    return os.environ.get("PYPY_API_URL", "https://api.example.com")

你可以用 pytest 內建的 monkeypatch

def test_get_api_url_from_env(monkeypatch) -> None:
    monkeypatch.setenv("PYPY_API_URL", "https://local.test")
    assert get_api_url() == "https://local.test"

測試結束後,pytest 會自動還原環境變數。 不用你手動清。 它也可以刪除環境變數:

def test_get_api_url_default(monkeypatch) -> None:
    monkeypatch.delenv("PYPY_API_URL", raising=False)
    assert get_api_url() == "https://api.example.com"

還可以暫時改 attribute:

def test_checkout_uses_fake_clock(monkeypatch) -> None:
    class FakeClock:
        @staticmethod
        def now() -> str:
            return "2026-05-23T10:13:00"
    monkeypatch.setattr("pypy_shop.checkout.clock", FakeClock)
    assert build_receipt_timestamp() == "2026-05-23T10:13:00"

monkeypatch 很方便,但不要把它當魔法膠帶亂貼。 如果你一直需要 patch 很深的內部細節,可能代表程式本身太難測。 可以考慮把依賴改成參數注入。 測試工具可以救急。 好的設計才是長期解。

十. parametrize 搭配 fixture:資料矩陣不要手寫迴圈
#

fixture 可以和 pytest.mark.parametrize 搭配。 例如免運門檻測試:

import pytest
@pytest.mark.parametrize(
    ("price", "expected"),
    [
        (999, False),
        (1000, True),
        (1200, True),
    ],
)
def test_free_shipping_threshold(cart, item_factory, price, expected) -> None:
    cart.add(item_factory(price=price))
    assert cart.has_free_shipping() is expected

這比在一個測試裡寫 for loop 更好。 因為 pytest 會把每組資料視為獨立 case。 哪一組失敗,報告會清楚列出來。 你也可以替參數命名:

@pytest.mark.parametrize(
    ("quantity", "total"),
    [
        pytest.param(1, 80, id="single item"),
        pytest.param(3, 240, id="bulk item"),
    ],
)
def test_item_quantity(cart, item_factory, quantity, total) -> None:
    cart.add(item_factory(price=80, quantity=quantity))
    assert cart.total() == total

fixture 負責建立物件。 parametrize 負責列出變化。 兩者分工清楚,測試就會很好讀。

十一. 實務整理原則
#

如果你要開始整理一個已經長大的測試套件,可以照這個順序:

  1. 先找重複最多的 setup 程式碼。
  2. 把單一檔案內重複的資料抽成 local fixture。 3. 確認多個檔案真的共用後,再搬到 conftest.py。 4. 對需要變化的資料改用 factory fixture。 5. 對檔案測試改用 tmp_path。 6. 對環境變數和 patch 改用 monkeypatch。 7. 最後才調整 scope 來加速。 不要一開始就把所有東西都丟進最大層的 conftest.py。 那不是架構。 那只是大抽屜。 測試架構和產品架構一樣,都是長出來的。 先讓小地方變乾淨,再往上抽。

結語
#

pytest fixture 是很強的工具。 但它真正的價值,不是讓你少打幾行 setup。 而是讓測試可以保持「每個案例都在講一件清楚的事」。 好的 fixture 會讓測試更像規格。 壞的 fixture 會讓測試更像密室逃脫。 拍拍君自己的偏好很簡單:

  • 資料要乾淨
  • 相依要明確
  • scope 要保守
  • factory 要善用
  • cleanup 要自動 如果你現在的測試檔案已經開始長出一堆複製貼上的 payload、暫存檔路徑、環境變數設定,先不用急著重寫整包測試。 從一個 fixture 開始整理就好。 每天清一點。 測試會慢慢從「不敢碰」變成「可以放心改」。 這才是 pytest 最舒服的地方。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 67: 本文

相關文章

Python hypothesis 實戰:Property-Based Testing 與自動化找 bug 完全攻略
·7 分鐘· loading · loading
Python Hypothesis Testing Pytest Developer-Tools
Python tempfile 實戰:安全建立暫存檔案、目錄與測試資料
·9 分鐘· loading · loading
Python Tempfile Filesystem Testing Standard-Library Developer-Tools
Python SQLAlchemy 2.0 實戰:Typed ORM、Session 與查詢模式
·9 分鐘· loading · loading
Python SQLAlchemy ORM Database SQLite Developer-Tools
FastAPI + Streamlit 實戰:API 後端與互動前端分工
·9 分鐘· loading · loading
Python Fastapi Streamlit Api Frontend Developer-Tools
Python pydantic-settings 實戰:型別安全管理 .env 與設定檔
·6 分鐘· loading · loading
Python Pydantic Pydantic-Settings Dotenv Configuration Developer-Tools
Python pytest:fixture + parametrize + mock 完整指南
·8 分鐘· loading · loading
Python Pytest Testing Fixture Mock Parametrize TDD