一. 前言:fixture 寫得好,測試才長得大 #
嗨,這裡是拍拍君。 很多人第一次學 pytest,會先覺得它很清爽。 不用 class。 不用 self.assertEqual。 直接寫普通函式和 assert。 快樂。
但測試一多,另一個問題很快就會冒出來: 「為什麼每個測試都在手動建立一樣的資料?」 「為什麼 API client、暫存檔、環境變數到處散落?」 「為什麼某個測試單獨跑會過,整包跑就爆?」 這些通常不是 pytest
不好用。 是 fixture 還沒整理好。 之前拍拍君在 pytest 入門篇 已經介紹過 fixture、parametrize 和 mock 的基本用法。
今天這篇會往更實戰的方向走。 我們不再只問「fixture 怎麼寫」。 而是問:
- fixture 應該放在哪裡?
- 測試資料怎麼避免互相污染?
- 什麼時候該用 factory fixture?
tmp_path和monkeypatch這些內建 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_item、large_item、free_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_pathfixture。 它會給你一個乾淨的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 負責列出變化。 兩者分工清楚,測試就會很好讀。
十一. 實務整理原則 #
如果你要開始整理一個已經長大的測試套件,可以照這個順序:
- 先找重複最多的 setup 程式碼。
- 把單一檔案內重複的資料抽成 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 最舒服的地方。