一、前言 #
嗨,這裡是拍拍君!🧪
之前拍拍君在 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
mocker 是 pytest-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替換外部依賴,讓測試快速、穩定、不碰真實服務
有了這三招,你寫的測試會更乾淨、更全面、跑得更快。
拍拍君自己的經驗是:好的測試不是寫完功能之後的義務,而是幫你快速開發的工具。 當你有完整的測試覆蓋,重構程式碼時就不用戰戰兢兢,大膽改就對了!
下次見啦!🧪✨