測驗:pytest 實戰整合 – mock、測試組織與 CI 整合
共 5 題,點選答案後會立即顯示結果
1. 為什麼在測試中需要使用 mock?
2. 使用 monkeypatch.setattr 時,應該 mock 哪裡的參照?
3. conftest.py 檔案的主要用途是什麼?
4. 執行 pytest --cov=myproject --cov-fail-under=80 時,什麼情況下測試會失敗?
5. 在 Mock 物件上設定 side_effect = [1, 2, 3] 會產生什麼效果?
本篇是 pytest 教學系列的最後一篇。我們將學習如何使用 mock 隔離外部依賴、組織大型專案的測試結構,並將 pytest 整合到 CI 流程中。
為什麼需要 mock
在真實專案中,程式碼常常會依賴外部服務:
# api_client.py
import requests
def get_user_data(user_id):
response = requests.get(f"https://api.example.com/users/{user_id}")
return response.json()
Code language: PHP (php)直接測試這段程式碼會遇到幾個問題:
- 速度慢:每次測試都要發送網路請求
- 不穩定:網路問題或 API 服務中斷會導致測試失敗
- 難以控制:無法測試錯誤處理(如 API 回傳 500)
- 有副作用:可能真的修改遠端資料
Mock 的作用是用「假的」物件取代真實的依賴,讓我們能夠:
- 控制依賴的行為和回傳值
- 驗證程式碼是否正確呼叫依賴
- 加速測試執行
monkeypatch:pytest 內建的 mock 工具
pytest 提供 monkeypatch fixture,這是最簡單的 mock 方式:
# test_api_client.py
from api_client import get_user_data
def test_get_user_data(monkeypatch):
# 建立假的 response
class FakeResponse:
def json(self):
return {"id": 1, "name": "Alice"}
# 用假的 get 取代 requests.get
def fake_get(url):
return FakeResponse()
monkeypatch.setattr("api_client.requests.get", fake_get)
# 現在 get_user_data 會使用假的 requests.get
result = get_user_data(1)
assert result == {"id": 1, "name": "Alice"}
monkeypatch 常用方法
def test_monkeypatch_examples(monkeypatch):
# 替換屬性
monkeypatch.setattr(obj, "attribute", value)
# 替換字典項目
monkeypatch.setitem(my_dict, "key", "new_value")
# 設定環境變數
monkeypatch.setenv("API_KEY", "test-key")
# 刪除環境變數
monkeypatch.delenv("DEBUG", raising=False)
# 修改 sys.path
monkeypatch.syspath_prepend("/custom/path")
Code language: PHP (php)讀懂 setattr 的路徑
monkeypatch.setattr 的第一個參數是「從測試檔案看過去的路徑」:
# 檔案結構
# myproject/
# api_client.py <- 裡面 import requests
# tests/
# test_api.py
# test_api.py 中
# 錯誤:這會去 mock requests 模組本身
monkeypatch.setattr("requests.get", fake_get)
# 正確:mock api_client 模組裡的 requests.get
monkeypatch.setattr("api_client.requests.get", fake_get)
Code language: PHP (php)重點:mock 的是「使用端」的參照,不是「來源端」。
unittest.mock 與 pytest-mock
Python 標準庫的 unittest.mock 提供更強大的 mock 功能:
from unittest.mock import Mock, patch
def test_with_mock():
# 建立 Mock 物件
mock_response = Mock()
mock_response.json.return_value = {"id": 1, "name": "Alice"}
mock_response.status_code = 200
# 使用 patch 裝飾器
with patch("api_client.requests.get", return_value=mock_response) as mock_get:
result = get_user_data(1)
# 驗證 mock 被正確呼叫
mock_get.assert_called_once_with("https://api.example.com/users/1")
assert result == {"id": 1, "name": "Alice"}
Code language: PHP (php)pytest-mock 插件
pytest-mock 將 unittest.mock 包裝成 fixture,使用更方便:
pip install pytest-mock
def test_with_mocker(mocker):
# mocker 是 pytest-mock 提供的 fixture
mock_response = mocker.Mock()
mock_response.json.return_value = {"id": 1, "name": "Alice"}
# 使用 mocker.patch
mock_get = mocker.patch("api_client.requests.get", return_value=mock_response)
result = get_user_data(1)
mock_get.assert_called_once()
assert result["name"] == "Alice"
Code language: PHP (php)Mock 物件的常用屬性
def test_mock_attributes(mocker):
mock_func = mocker.Mock()
# 設定回傳值
mock_func.return_value = 42
assert mock_func() == 42
# 設定多次呼叫的回傳值
mock_func.side_effect = [1, 2, 3]
assert mock_func() == 1
assert mock_func() == 2
assert mock_func() == 3
# 讓 mock 拋出例外
mock_func.side_effect = ValueError("error")
with pytest.raises(ValueError):
mock_func()
Code language: PHP (php)驗證 mock 呼叫
def test_mock_assertions(mocker):
mock_func = mocker.Mock()
mock_func("arg1", key="value")
mock_func("arg2")
# 驗證呼叫次數
assert mock_func.call_count == 2
# 驗證最後一次呼叫
mock_func.assert_called_with("arg2")
# 驗證曾經被這樣呼叫過
mock_func.assert_any_call("arg1", key="value")
# 取得所有呼叫記錄
print(mock_func.call_args_list)
# [call('arg1', key='value'), call('arg2')]
Code language: PHP (php)測試目錄結構建議
小型專案可以把測試放在同一目錄:
myproject/
calculator.py
test_calculator.py
中大型專案建議使用獨立的 tests 目錄:
myproject/
src/
myproject/
__init__.py
calculator.py
api/
__init__.py
client.py
tests/
__init__.py
conftest.py # 共用 fixtures
test_calculator.py
api/
__init__.py
test_client.py
Code language: PHP (php)conftest.py 的作用
conftest.py 是 pytest 的特殊檔案,用於定義共用的 fixtures:
# tests/conftest.py
import pytest
@pytest.fixture
def api_client():
"""所有測試都可以使用這個 fixture"""
return APIClient(base_url="https://test.example.com")
@pytest.fixture
def sample_user():
return {"id": 1, "name": "Test User", "email": "[email protected]"}
Code language: PHP (php)conftest.py 的特點:
- 自動載入:不需要 import,pytest 會自動找到
- 作用範圍:同目錄及子目錄的測試都能使用
- 可以有多個:不同目錄可以有自己的 conftest.py
pytest.ini / pyproject.toml 設定
pytest.ini
# pytest.ini
[pytest]
# 測試檔案的目錄
testpaths = tests
# 測試檔案的命名模式
python_files = test_*.py
python_classes = Test*
python_functions = test_*
# 預設的命令列選項
addopts = -v --tb=short
# 忽略特定目錄
norecursedirs = .git .tox venv
# 設定 markers
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks tests as integration tests
Code language: PHP (php)pyproject.toml(推薦)
現代 Python 專案傾向使用 pyproject.toml 統一管理設定:
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
addopts = "-v --tb=short"
markers = [
"slow: marks tests as slow",
"integration: marks tests as integration tests",
]
Code language: PHP (php)pytest-cov:測試覆蓋率報告
測試覆蓋率告訴你「多少程式碼被測試執行過」:
pip install pytest-cov
基本使用
# 產生覆蓋率報告
pytest --cov=myproject tests/
# 輸出範例
# ---------- coverage: platform linux, python 3.11.0 ----------
# Name Stmts Miss Cover
# -------------------------------------------
# myproject/__init__.py 2 0 100%
# myproject/calculator.py 15 3 80%
# myproject/api/client.py 28 10 64%
# -------------------------------------------
# TOTAL 45 13 71%
Code language: PHP (php)常用選項
# 顯示哪些行沒被覆蓋
pytest --cov=myproject --cov-report=term-missing
# 產生 HTML 報告
pytest --cov=myproject --cov-report=html
# 設定最低覆蓋率門檻(低於則失敗)
pytest --cov=myproject --cov-fail-under=80
Code language: PHP (php)讀懂覆蓋率報告
Name Stmts Miss Cover Missing
-----------------------------------------------------
myproject/calculator.py 15 3 80% 12-14
- Stmts:總共有多少行可執行程式碼
- Miss:有多少行沒被執行
- Cover:覆蓋率百分比
- Missing:哪些行號沒被覆蓋
排除不需要測試的程式碼
# 使用 pragma: no cover 註解
if __name__ == "__main__": # pragma: no cover
main()
Code language: PHP (php)或在設定檔中排除:
# pyproject.toml
[tool.coverage.run]
omit = [
"*/tests/*",
"*/__main__.py",
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if __name__ == .__main__.:",
"raise NotImplementedError",
]
Code language: PHP (php)GitHub Actions 整合範例
將 pytest 整合到 CI,每次 push 都自動執行測試:
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-cov
- name: Run tests
run: |
pytest --cov=myproject --cov-report=xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
Code language: PHP (php)讀懂這個設定檔
on:
push:
branches: [main]
pull_request:
branches: [main]
Code language: CSS (css)觸發條件:push 到 main 分支,或建立 PR 到 main 分支時執行。
steps:
- uses: actions/checkout@v4
使用官方的 checkout action 取得程式碼。
- name: Run tests
run: |
pytest --cov=myproject --cov-report=xml
執行 pytest 並產生 XML 格式的覆蓋率報告(給 Codecov 用)。
多 Python 版本測試
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12']
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install and test
run: |
pip install -r requirements.txt
pytest
Code language: JavaScript (javascript)實戰範例:完整的測試專案結構
myproject/
pyproject.toml
src/
myproject/
__init__.py
calculator.py
api/
__init__.py
client.py
tests/
__init__.py
conftest.py
test_calculator.py
api/
__init__.py
conftest.py
test_client.py
.github/
workflows/
test.yml
# pyproject.toml
[project]
name = "myproject"
version = "1.0.0"
requires-python = ">=3.9"
dependencies = [
"requests>=2.28.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"pytest-mock>=3.10.0",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short --cov=myproject --cov-report=term-missing"
markers = [
"slow: marks tests as slow",
"integration: marks tests as integration tests",
]
[tool.coverage.run]
source = ["src/myproject"]
omit = ["*/tests/*"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if __name__ == .__main__.:",
]
Code language: PHP (php)總結
本篇涵蓋了 pytest 實戰中的進階技巧:
| 主題 | 重點 |
|---|---|
| Mock | 隔離外部依賴,控制測試環境 |
| monkeypatch | pytest 內建的簡單 mock 工具 |
| pytest-mock | 更強大的 mock 功能 |
| 測試組織 | conftest.py 共用 fixtures |
| 設定檔 | pyproject.toml 統一管理 |
| pytest-cov | 測試覆蓋率報告 |
| GitHub Actions | CI 自動化測試 |
系列回顧
經過這五篇教學,你已經學會:
- 基礎篇:pytest 安裝與第一個測試
- 斷言篇:各種斷言方法與例外測試
- Fixture 篇:測試前置作業與資源管理
- 參數化篇:用參數化減少重複測試
- 實戰篇:mock、測試組織與 CI 整合
現在你已經具備在真實專案中使用 pytest 的能力。記住,好的測試不只是追求覆蓋率,更重要的是測試有意義的行為和邊界條件。
Happy testing!
進階測驗:pytest 實戰整合 – mock、測試組織與 CI 整合
共 5 題,包含情境題與錯誤診斷題。