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

Python pytest:fixture + parametrize + mock 完整指南

·8 分鐘· loading · loading · ·
Python Pytest Testing Fixture Mock Parametrize TDD
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 35: 本文

一、前言
#

嗨,這裡是拍拍君!🧪

之前拍拍君在 unittest 入門篇 帶大家寫過第一個測試。但老實說,寫了一陣子之後,你有沒有覺得 unittest 有點……囉嗦?

每次都要繼承 TestCase、用 self.assertEqual、setUp / tearDown 一堆 boilerplate。更痛苦的是,想用同一組測試邏輯跑不同參數,還得自己寫迴圈或 subTest

pytest 就是來解決這些痛點的。它是 Python 生態系中最受歡迎的測試框架,語法簡潔、功能強大,而且——它用的就是普通的 assert,不需要記一堆 self.assertXxx

今天拍拍君要帶你學 pytest 的三大核心武器:

  • 🔧 Fixture:共用測試設定,乾淨又優雅
  • 🔄 Parametrize:一組邏輯、多組資料,批量測試
  • 🎭 Mock:隔離外部依賴,讓測試不再脆弱

準備好了嗎?Let’s go!

二、安裝與第一個測試
#

安裝
#

# 用 uv(推薦)
uv pip install pytest

# 或用 pip
pip install pytest

確認安裝成功:

pytest --version
# pytest 8.x.x

第一個測試
#

建立 test_basic.py

# test_basic.py

def add(a, b):
    return a + b

def test_add():
    assert add(1, 2) == 3
    assert add(-1, 1) == 0
    assert add(0, 0) == 0

執行:

pytest test_basic.py -v
test_basic.py::test_add PASSED

就這樣!不需要 class、不需要 self.assertEqual,一個 assert 搞定。而且當測試失敗時,pytest 會自動展示詳細的比較結果:

def test_fail_example():
    result = {"name": "拍拍君", "level": 5}
    assert result == {"name": "拍拍君", "level": 10}
E       AssertionError: assert {'level': 5, 'name': '拍拍君'} == {'level': 10, 'name': '拍拍君'}
E         Differing items:
E         {'level': 5} != {'level': 10}

pytest 會自動幫你 diff,不用再手動 print debug 了。

三、Fixture:測試的共用設定
#

問題:重複的設定程式碼
#

寫測試時,很多測試函式需要相同的前置準備。比如你在測一個使用者系統:

# ❌ 每個測試都要重複建立資料
def test_user_fullname():
    user = {"first": "拍拍", "last": "君", "email": "pypy@dailypypy.org"}
    assert f'{user["first"]}{user["last"]}' == "拍拍君"

def test_user_email():
    user = {"first": "拍拍", "last": "君", "email": "pypy@dailypypy.org"}
    assert "@" in user["email"]

這還只是兩個測試,如果有二十個呢?每次都複製貼上同樣的 user 字典?

解法:用 fixture 注入
#

import pytest

@pytest.fixture
def sample_user():
    """建立一個測試用的使用者。"""
    return {
        "first": "拍拍",
        "last": "君",
        "email": "pypy@dailypypy.org",
        "level": 5,
    }

def test_user_fullname(sample_user):
    assert f'{sample_user["first"]}{sample_user["last"]}' == "拍拍君"

def test_user_email(sample_user):
    assert "@" in sample_user["email"]

def test_user_level(sample_user):
    assert sample_user["level"] >= 1

把 fixture 名字寫在測試函式的參數裡,pytest 就會自動注入。每個測試都拿到全新的一份資料,互不干擾。

fixture 的 scope:控制生命週期
#

預設的 fixture 每個測試函式跑一次。但有些昂貴的操作(比如建立資料庫連線),你希望整個模組只跑一次:

@pytest.fixture(scope="module")
def db_connection():
    """整個測試模組共用一個資料庫連線。"""
    conn = create_db_connection()
    yield conn          # yield 之前 = setup,之後 = teardown
    conn.close()

@pytest.fixture(scope="session")
def config():
    """整個測試 session 只讀一次設定檔。"""
    return load_config("test_config.toml")

scope 有四種等級(由小到大):

Scope 說明 適用場景
function 每個測試函式一次(預設) 輕量資料、避免測試間互相影響
class 每個測試 class 一次 一組相關測試共用資料
module 每個 .py 檔一次 資料庫連線、重量級資源
session 整個 pytest 執行一次 設定檔、全域服務啟動

yield fixture:優雅的 setup + teardown
#

yield 是 fixture 的殺手級特性——yield 之前的程式碼是 setup,之後是 teardown:

import tempfile
import os

@pytest.fixture
def temp_dir():
    """建立暫存目錄,測試結束後自動清理。"""
    path = tempfile.mkdtemp()
    yield path
    # teardown:刪除暫存目錄
    import shutil
    shutil.rmtree(path)

def test_write_file(temp_dir):
    filepath = os.path.join(temp_dir, "test.txt")
    with open(filepath, "w") as f:
        f.write("Hello from 拍拍君!")
    assert os.path.exists(filepath)

不管測試成功或失敗,yield 後面的清理程式碼一定會執行。比 unittest 的 setUp / tearDown 直覺多了。

conftest.py:共享 fixture
#

如果多個測試檔案需要同一個 fixture,不用到處 import。把 fixture 放在 conftest.py 裡,pytest 會自動找到:

tests/
├── conftest.py          # 這裡定義的 fixture 所有測試都能用
├── test_user.py
├── test_order.py
└── api/
    ├── conftest.py      # 這裡的 fixture 只有 api/ 底下能用
    └── test_endpoint.py
# tests/conftest.py

@pytest.fixture
def api_client():
    """所有測試共用的 API client。"""
    from myapp import create_app
    app = create_app(testing=True)
    return app.test_client()

四、Parametrize:一組邏輯、多組資料
#

問題:相似的測試重複寫
#

假設你要測一個密碼驗證函式:

# ❌ 每組測資都要寫一個測試函式
def test_password_too_short():
    assert not is_valid_password("abc")

def test_password_no_digit():
    assert not is_valid_password("abcdefgh")

def test_password_valid():
    assert is_valid_password("abc12345")

三個測試做的事情幾乎一模一樣,只是輸入和預期結果不同。

解法:@pytest.mark.parametrize
#

import pytest

def is_valid_password(pw: str) -> bool:
    """密碼至少 8 字元,且包含數字。"""
    return len(pw) >= 8 and any(c.isdigit() for c in pw)

@pytest.mark.parametrize("password, expected", [
    ("abc",        False),   # 太短
    ("abcdefgh",   False),   # 沒有數字
    ("12345678",   True),    # 純數字,但夠長且有數字
    ("abc12345",   True),    # 合格
    ("a1b2c3d4",   True),    # 混合
    ("短",          False),   # 中文也算,但只有一個字
])
def test_is_valid_password(password, expected):
    assert is_valid_password(password) == expected

執行結果:

test_password.py::test_is_valid_password[abc-False]        PASSED
test_password.py::test_is_valid_password[abcdefgh-False]   PASSED
test_password.py::test_is_valid_password[12345678-True]    PASSED
test_password.py::test_is_valid_password[abc12345-True]    PASSED
test_password.py::test_is_valid_password[a1b2c3d4-True]    PASSED
test_password.py::test_is_valid_password[\u77ed-False]        PASSED

每組參數都是獨立的測試案例,一個失敗不影響其他的。超清楚!

進階:多個 parametrize 的組合
#

如果你想測所有參數的排列組合,可以疊加多個 @pytest.mark.parametrize

@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply(x, y):
    result = x * y
    assert result == x * y  # 會產生 3 x 2 = 6 個測試
test_multiply[10-1] PASSED
test_multiply[10-2] PASSED
test_multiply[10-3] PASSED
test_multiply[20-1] PASSED
test_multiply[20-2] PASSED
test_multiply[20-3] PASSED

幫參數加上 id
#

預設的參數 id 有時候不太好讀,可以用 pytest.param 加上自訂 id:

@pytest.mark.parametrize("input_data, expected", [
    pytest.param({"name": "拍拍君"}, True, id="valid-user"),
    pytest.param({}, False, id="empty-dict"),
    pytest.param({"name": ""}, False, id="empty-name"),
])
def test_validate_user(input_data, expected):
    assert validate_user(input_data) == expected
test_user.py::test_validate_user[valid-user]  PASSED
test_user.py::test_validate_user[empty-dict]  PASSED
test_user.py::test_validate_user[empty-name]  PASSED

fixture + parametrize 組合技
#

fixture 也能被 parametrize!這在測試多種資料庫、多種設定時超實用:

@pytest.fixture(params=["sqlite", "postgres"])
def database(request):
    """用不同的資料庫後端跑同一組測試。"""
    if request.param == "sqlite":
        db = connect_sqlite(":memory:")
    else:
        db = connect_postgres("test_db")
    yield db
    db.close()

def test_insert_and_query(database):
    database.execute("INSERT INTO users VALUES ('拍拍君')")
    result = database.query("SELECT * FROM users")
    assert len(result) == 1

pytest 會自動用 sqlite 和 postgres 各跑一次這個測試。

五、Mock:隔離外部依賴
#

問題:測試碰到外部服務
#

你的程式碼會呼叫外部 API、讀資料庫、發 email。如果測試時真的去打這些服務:

  • ❌ 測試速度慢(網路延遲)
  • ❌ 測試不穩定(API 掛了就失敗)
  • ❌ 可能產生副作用(真的發 email、真的寫資料庫)

解法:unittest.mock(pytest 也用它)
#

pytest 本身不帶 mock 模組,但它跟標準庫的 unittest.mock 無縫搭配。拍拍君推薦搭配 pytest-mock 套件,語法更簡潔。

uv pip install pytest-mock

基本 mock:替換外部呼叫
#

假設有個函式會打 API:

# weather.py
import httpx

def get_temperature(city: str) -> float:
    """呼叫天氣 API,回傳氣溫。"""
    resp = httpx.get(f"https://api.weather.example/v1/{city}")
    resp.raise_for_status()
    return resp.json()["temperature"]

測試時我們不想真的打 API:

# test_weather.py
from weather import get_temperature

def test_get_temperature(mocker):
    # mock httpx.get,讓它回傳假資料
    mock_response = mocker.Mock()
    mock_response.json.return_value = {"temperature": 22.5}
    mock_response.raise_for_status = mocker.Mock()

    mocker.patch("weather.httpx.get", return_value=mock_response)

    result = get_temperature("taipei")
    assert result == 22.5

mockerpytest-mock 提供的 fixture,背後就是 unittest.mock.patch,但自動幫你在測試結束後清理。

重要觀念:mock 的位置
#

mock 要在被測模組看到的路徑上打 patch,而不是在定義的地方:

# ✅ 正確:mock weather 模組裡看到的 httpx.get
mocker.patch("weather.httpx.get", ...)

# ❌ 錯誤:mock httpx 模組本身的 get
mocker.patch("httpx.get", ...)

這是 mock 最常見的陷阱。記住這個口訣:「patch where it’s looked up, not where it’s defined」

驗證呼叫行為
#

mock 不只能替換回傳值,還能驗證函式是否被正確呼叫:

def test_api_called_correctly(mocker):
    mock_get = mocker.patch("weather.httpx.get")
    mock_get.return_value.json.return_value = {"temperature": 18.0}
    mock_get.return_value.raise_for_status = mocker.Mock()

    get_temperature("tokyo")

    # 驗證 httpx.get 被呼叫了一次,且參數正確
    mock_get.assert_called_once_with("https://api.weather.example/v1/tokyo")

常用的驗證方法:

mock_obj.assert_called_once()              # 剛好被呼叫一次
mock_obj.assert_called_with(arg1, arg2)    # 最後一次呼叫的參數
mock_obj.assert_not_called()               # 完全沒被呼叫
assert mock_obj.call_count == 3            # 被呼叫了 3 次

用 side_effect 模擬例外
#

def test_api_error_handling(mocker):
    mocker.patch(
        "weather.httpx.get",
        side_effect=httpx.HTTPStatusError(
            "503 Service Unavailable",
            request=mocker.Mock(),
            response=mocker.Mock(status_code=503),
        ),
    )

    with pytest.raises(httpx.HTTPStatusError):
        get_temperature("mars")  # 火星沒有天氣站 🪐

不用 pytest-mock 也行
#

如果你不想多裝一個套件,標準庫的 unittest.mock 也可以直接用:

from unittest.mock import patch, MagicMock

def test_with_stdlib_mock():
    with patch("weather.httpx.get") as mock_get:
        mock_get.return_value = MagicMock()
        mock_get.return_value.json.return_value = {"temperature": 25.0}

        result = get_temperature("osaka")
        assert result == 25.0

差別在於 mocker fixture 會自動 cleanup,patch context manager 也會,但 mocker 寫起來更 pytest-native。

六、實戰:三大技巧組合
#

來看一個完整的例子,把 fixture + parametrize + mock 全部組合起來:

# notification.py
import httpx

class NotificationService:
    def __init__(self, api_url: str):
        self.api_url = api_url

    def send(self, user_id: str, message: str) -> bool:
        """發送通知給使用者。"""
        resp = httpx.post(
            f"{self.api_url}/notify",
            json={"user_id": user_id, "message": message},
        )
        return resp.status_code == 200
# test_notification.py
import pytest
import httpx
from unittest.mock import MagicMock
from notification import NotificationService

@pytest.fixture
def notifier():
    """建立測試用的 NotificationService。"""
    return NotificationService(api_url="https://api.example.com")

@pytest.mark.parametrize("status_code, expected", [
    pytest.param(200, True,  id="success"),
    pytest.param(500, False, id="server-error"),
    pytest.param(403, False, id="forbidden"),
    pytest.param(429, False, id="rate-limited"),
])
def test_send_notification(mocker, notifier, status_code, expected):
    # Mock HTTP POST
    mock_response = mocker.Mock()
    mock_response.status_code = status_code
    mocker.patch("notification.httpx.post", return_value=mock_response)

    result = notifier.send("拍拍君", "你有新訊息!")

    assert result == expected

def test_send_notification_network_error(mocker, notifier):
    """網路斷線時應該拋出例外。"""
    mocker.patch(
        "notification.httpx.post",
        side_effect=httpx.ConnectError("Connection refused"),
    )

    with pytest.raises(httpx.ConnectError):
        notifier.send("拍拍君", "這則會失敗")

跑起來:

test_notification.py::test_send_notification[success]       PASSED
test_notification.py::test_send_notification[server-error]  PASSED
test_notification.py::test_send_notification[forbidden]     PASSED
test_notification.py::test_send_notification[rate-limited]  PASSED
test_notification.py::test_send_notification_network_error  PASSED

五個測試,零網路呼叫,跑完不到 0.01 秒。這就是 pytest 的威力 💪

七、常用 pytest 指令與技巧
#

# 跑所有測試
pytest

# 詳細模式(顯示每個測試名稱)
pytest -v

# 只跑包含 "user" 的測試
pytest -k "user"

# 只跑特定檔案
pytest tests/test_user.py

# 只跑特定測試函式
pytest tests/test_user.py::test_user_fullname

# 遇到第一個失敗就停止
pytest -x

# 顯示最慢的 10 個測試
pytest --durations=10

# 顯示 print 輸出(預設會被隱藏)
pytest -s

# 平行執行(需安裝 pytest-xdist)
pip install pytest-xdist
pytest -n auto  # 自動偵測 CPU 核心數

pytest.ini / pyproject.toml 設定
#

# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short"
markers = [
    "slow: marks tests as slow (deselect with '-m \"not slow\"')",
    "integration: integration tests that need external services",
]

使用自訂 marker:

@pytest.mark.slow
def test_heavy_computation():
    # 跑很久的測試
    ...

@pytest.mark.integration
def test_real_database():
    # 需要真實資料庫的測試
    ...
# 跳過慢的測試
pytest -m "not slow"

# 只跑整合測試
pytest -m integration

結語
#

今天拍拍君帶你走過了 pytest 的三大核心技巧:

  • 🔧 Fixture:用 @pytest.fixture + yield 管理測試的 setup/teardown,搭配 conftest.py 全域共享
  • 🔄 Parametrize:用 @pytest.mark.parametrize 把一個測試函式變成多組測試案例,再也不用複製貼上
  • 🎭 Mock:用 mocker.patch 替換外部依賴,讓測試快速、穩定、不碰真實服務

有了這三招,你寫的測試會更乾淨、更全面、跑得更快。

拍拍君自己的經驗是:好的測試不是寫完功能之後的義務,而是幫你快速開發的工具。 當你有完整的測試覆蓋,重構程式碼時就不用戰戰兢兢,大膽改就對了!

下次見啦!🧪✨

延伸閱讀
#

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

相關文章

開發的好習慣 Unit Test
·5 分鐘· loading · loading
Python Pytest Ci Unittest
Python threading:多執行緒並行的正確打開方式
·8 分鐘· loading · loading
Python Threading Concurrency 並行 GIL
Python Profiling:cProfile + line_profiler 效能分析完全指南
·8 分鐘· loading · loading
Python Profiling CProfile Line_profiler Performance Optimization
Python subprocess:外部命令執行與管道串接完全指南
·8 分鐘· loading · loading
Python Subprocess Shell Automation Cli
Python 裝飾器:讓你的函式穿上超能力外套
·7 分鐘· loading · loading
Python Decorator 裝飾器 進階語法 設計模式
MLX 入門教學:在 Apple Silicon 上跑機器學習
·4 分鐘· loading · loading
Python Mlx Apple-Silicon Machine-Learning Deep-Learning