Python 測試模式
使用 pytest、TDD 方法論和最佳實踐的 Python 應用程式全面測試策略。
何時啟動
- 編寫新的 Python 程式碼(遵循 TDD:紅、綠、重構)
- 為 Python 專案設計測試套件
- 檢視 Python 測試覆蓋率
- 設定測試基礎架構
核心測試哲學
測試驅動開發 (TDD)
始終遵循 TDD 循環:
- 紅:為期望的行為撰寫一個會失敗的測試
- 綠:撰寫最簡程式碼使測試通過
- 重構:在保持測試通過的同時改善程式碼
# 步驟 1:撰寫會失敗的測試(紅)
def test_add_numbers():
result = add(2, 3)
assert result == 5
# 步驟 2:撰寫最簡實作(綠)
def add(a, b):
return a + b
# 步驟 3:必要時重構(重構)
覆蓋率要求
- 目標:80% 以上的程式碼覆蓋率
- 關鍵路徑:需要 100% 覆蓋率
- 使用
pytest --cov來測量覆蓋率
pytest --cov=mypackage --cov-report=term-missing --cov-report=html
pytest 基礎
基本測試結構
import pytest
def test_addition():
"""測試基本加法。"""
assert 2 + 2 == 4
def test_string_uppercase():
"""測試字串大寫轉換。"""
text = "hello"
assert text.upper() == "HELLO"
def test_list_append():
"""測試列表附加。"""
items = [1, 2, 3]
items.append(4)
assert 4 in items
assert len(items) == 4
斷言
# 相等性
assert result == expected
# 不相等
assert result != unexpected
# 真值
assert result # 真值
assert not result # 假值
assert result is True # 精確為 True
assert result is False # 精確為 False
assert result is None # 精確為 None
# 成員資格
assert item in collection
assert item not in collection
# 比較
assert result > 0
assert 0 <= result <= 100
# 型別檢查
assert isinstance(result, str)
# 例外測試(首選方法)
with pytest.raises(ValueError):
raise ValueError("錯誤訊息")
# 檢查例外訊息
with pytest.raises(ValueError, match="invalid input"):
raise ValueError("invalid input provided")
# 檢查例外屬性
with pytest.raises(ValueError) as exc_info:
raise ValueError("錯誤訊息")
assert str(exc_info.value) == "錯誤訊息"
Fixture
基本 Fixture 用法
import pytest
@pytest.fixture
def sample_data():
"""提供範例資料的 Fixture。"""
return {"name": "Alice", "age": 30}
def test_sample_data(sample_data):
"""使用 fixture 的測試。"""
assert sample_data["name"] == "Alice"
assert sample_data["age"] == 30
包含設定/清理的 Fixture
@pytest.fixture
def database():
"""包含設定與清理的 Fixture。"""
# 設定
db = Database(":memory:")
db.create_tables()
db.insert_test_data()
yield db # 提供給測試
# 清理
db.close()
def test_database_query(database):
"""測試資料庫操作。"""
result = database.query("SELECT * FROM users")
assert len(result) > 0
Fixture 作用域
# 函式作用域(預設)- 每個測試執行一次
@pytest.fixture
def temp_file():
with open("temp.txt", "w") as f:
yield f
os.remove("temp.txt")
# 模組作用域 - 每個模組執行一次
@pytest.fixture(scope="module")
def module_db():
db = Database(":memory:")
db.create_tables()
yield db
db.close()
# 工作階段作用域 - 每個測試工作階段執行一次
@pytest.fixture(scope="session")
def shared_resource():
resource = ExpensiveResource()
yield resource
resource.cleanup()
參數化 Fixture
@pytest.fixture(params=[1, 2, 3])
def number(request):
"""參數化 Fixture。"""
return request.param
def test_numbers(number):
"""測試執行 3 次,每次使用一個參數。"""
assert number > 0
使用多個 Fixture
@pytest.fixture
def user():
return User(id=1, name="Alice")
@pytest.fixture
def admin():
return User(id=2, name="Admin", role="admin")
def test_user_admin_interaction(user, admin):
"""測試使用多個 fixture。"""
assert admin.can_manage(user)
自動使用 Fixture
@pytest.fixture(autouse=True)
def reset_config():
"""自動在每個測試之前執行。"""
Config.reset()
yield
Config.cleanup()
def test_without_fixture_call():
# reset_config 自動執行
assert Config.get_setting("debug") is False
用於共享 Fixture 的 Conftest.py
# tests/conftest.py
import pytest
@pytest.fixture
def client():
"""所有測試的共享 fixture。"""
app = create_app(testing=True)
with app.test_client() as client:
yield client
@pytest.fixture
def auth_headers(client):
"""為 API 測試產生驗證標頭。"""
response = client.post("/api/login", json={
"username": "test",
"password": "test"
})
token = response.json["token"]
return {"Authorization": f"Bearer {token}"}
參數化
基本參數化
@pytest.mark.parametrize("input,expected", [
("hello", "HELLO"),
("world", "WORLD"),
("PyThOn", "PYTHON"),
])
def test_uppercase(input, expected):
"""測試執行 3 次,每次使用不同的輸入。"""
assert input.upper() == expected
多個參數
@pytest.mark.parametrize("a,b,expected", [
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
])
def test_add(a, b, expected):
"""測試帶有多個輸入的加法。"""
assert add(a, b) == expected
帶有 ID 的參數化
@pytest.mark.parametrize("input,expected", [
("[email protected]", True),
("invalid", False),
("@no-domain.com", False),
], ids=["valid-email", "missing-at", "missing-domain"])
def test_email_validation(input, expected):
"""測試帶有可讀測試 ID 的電子郵件驗證。"""
assert is_valid_email(input) is expected
參數化 Fixture
@pytest.fixture(params=["sqlite", "postgresql", "mysql"])
def db(request):
"""針對多個資料庫後端進行測試。"""
if request.param == "sqlite":
return Database(":memory:")
elif request.param == "postgresql":
return Database("postgresql://localhost/test")
elif request.param == "mysql":
return Database("mysql://localhost/test")
def test_database_operations(db):
"""測試執行 3 次,每次使用一個資料庫。"""
result = db.query("SELECT 1")
assert result is not None
標記與測試選擇
自訂標記
# 標記慢速測試
@pytest.mark.slow
def test_slow_operation():
time.sleep(5)
# 標記整合測試
@pytest.mark.integration
def test_api_integration():
response = requests.get("https://api.example.com")
assert response.status_code == 200
# 標記單元測試
@pytest.mark.unit
def test_unit_logic():
assert calculate(2, 3) == 5
執行特定測試
# 只執行非慢速測試
pytest -m "not slow"
# 只執行整合測試
pytest -m integration
# 執行整合或慢速測試
pytest -m "integration or slow"
# 執行標記為單元但不包含慢速的測試
pytest -m "unit and not slow"
在 pytest.ini 中設定標記
[pytest]
markers =
slow: 標記測試為慢速
integration: 標記測試為整合測試
unit: 標記測試為單元測試
django: 標記測試為需要 Django
Mocking 與 Patching
Mocking 函式
from unittest.mock import patch, Mock
@patch("mypackage.external_api_call")
def test_with_mock(api_call_mock):
"""測試帶有 mock 的外部 API。"""
api_call_mock.return_value = {"status": "success"}
result = my_function()
api_call_mock.assert_called_once()
assert result["status"] == "success"
Mocking 回傳值
@patch("mypackage.Database.connect")
def test_database_connection(connect_mock):
"""測試帶有 mock 的資料庫連線。"""
connect_mock.return_value = MockConnection()
db = Database()
db.connect()
connect_mock.assert_called_once_with("localhost")
Mocking 例外
@patch("mypackage.api_call")
def test_api_error_handling(api_call_mock):
"""測試帶有 mock 例外的錯誤處理。"""
api_call_mock.side_effect = ConnectionError("網路錯誤")
with pytest.raises(ConnectionError):
api_call()
api_call_mock.assert_called_once()
Mocking 內容管理器
@patch("builtins.open", new_callable=mock_open)
def test_file_reading(mock_file):
"""測試帶有 mock 的檔案讀取。"""
mock_file.return_value.read.return_value = "file content"
result = read_file("test.txt")
mock_file.assert_called_once_with("test.txt", "r")
assert result == "file content"
使用 Autospec
@patch("mypackage.DBConnection", autospec=True)
def test_autospec(db_mock):
"""測試使用 autospec 以捕捉 API 誤用。"""
db = db_mock.return_value
db.query("SELECT * FROM users")
# 如果 DBConnection 沒有 query 方法,這將會失敗
db_mock.assert_called_once()
Mock 類別實例
class TestUserService:
@patch("mypackage.UserRepository")
def test_create_user(self, repo_mock):
"""測試帶有 mock repository 的使用者建立。"""
repo_mock.return_value.save.return_value = User(id=1, name="Alice")
service = UserService(repo_mock.return_value)
user = service.create_user(name="Alice")
assert user.name == "Alice"
repo_mock.return_value.save.assert_called_once()
Mock 屬性
@pytest.fixture
def mock_config():
"""建立一個帶有屬性的 mock。"""
config = Mock()
type(config).debug = PropertyMock(return_value=True)
type(config).api_key = PropertyMock(return_value="test-key")
return config
def test_with_mock_config(mock_config):
"""測試帶有 mock 設定的屬性。"""
assert mock_config.debug is True
assert mock_config.api_key == "test-key"
測試非同步程式碼
使用 pytest-asyncio 的非同步測試
import pytest
@pytest.mark.asyncio
async def test_async_function():
"""測試非同步函式。"""
result = await async_add(2, 3)
assert result == 5
@pytest.mark.asyncio
async def test_async_with_fixture(async_client):
"""測試帶有非同步 fixture 的非同步函式。"""
response = await async_client.get("/api/users")
assert response.status_code == 200
非同步 Fixture
@pytest.fixture
async def async_client():
"""提供非同步測試用戶端的非同步 fixture。"""
app = create_app()
async with app.test_client() as client:
yield client
@pytest.mark.asyncio
async def test_api_endpoint(async_client):
"""測試使用非同步 fixture。"""
response = await async_client.get("/api/data")
assert response.status_code == 200
Mocking 非同步函式
@pytest.mark.asyncio
@patch("mypackage.async_api_call")
async def test_async_mock(api_call_mock):
"""測試帶有 mock 的非同步函式。"""
api_call_mock.return_value = {"status": "ok"}
result = await my_async_function()
api_call_mock.assert_awaited_once()
assert result["status"] == "ok"
測試例外
測試預期的例外
def test_divide_by_zero():
"""測試除以零會引發 ZeroDivisionError。"""
with pytest.raises(ZeroDivisionError):
divide(10, 0)
def test_custom_exception():
"""測試帶有訊息的自訂例外。"""
with pytest.raises(ValueError, match="invalid input"):
validate_input("invalid")
測試例外屬性
def test_exception_with_details():
"""測試帶有自訂屬性的例外。"""
with pytest.raises(CustomError) as exc_info:
raise CustomError("error", code=400)
assert exc_info.value.code == 400
assert "error" in str(exc_info.value)
測試副作用
測試檔案操作
import tempfile
import os
def test_file_processing():
"""測試暫存檔案的處理。"""
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f:
f.write("test content")
temp_path = f.name
try:
result = process_file(temp_path)
assert result == "processed: test content"
finally:
os.unlink(temp_path)
使用 pytest 的 tmp_path Fixture
def test_with_tmp_path(tmp_path):
"""測試使用 pytest 內建的暫存路徑 fixture。"""
test_file = tmp_path / "test.txt"
test_file.write_text("hello world")
result = process_file(str(test_file))
assert result == "hello world"
# tmp_path 會自動清理
使用 tmpdir Fixture
def test_with_tmpdir(tmpdir):
"""測試使用 pytest 的 tmpdir fixture。"""
test_file = tmpdir.join("test.txt")
test_file.write("data")
result = process_file(str(test_file))
assert result == "data"
測試組織
目錄結構
tests/
├── conftest.py # 共享 fixture
├── __init__.py
├── unit/ # 單元測試
│ ├── __init__.py
│ ├── test_models.py
│ ├── test_utils.py
│ └── test_services.py
├── integration/ # 整合測試
│ ├── __init__.py
│ ├── test_api.py
│ └── test_database.py
└── e2e/ # 端到端測試
├── __init__.py
└── test_user_flow.py
測試類別
class TestUserService:
"""在一個類別中群組相關測試。"""
@pytest.fixture(autouse=True)
def setup(self):
"""在這個類別中的每個測試之前執行設定。"""
self.service = UserService()
def test_create_user(self):
"""測試使用者建立。"""
user = self.service.create_user("Alice")
assert user.name == "Alice"
def test_delete_user(self):
"""測試使用者刪除。"""
user = self.service.create_user("Bob")
self.service.delete_user(user.id)
assert not self.service.user_exists(user.id)
測試組態
pytest.ini 範例
[pytest]
# 一般設定
minversion = 7.0
addopts = -v --tb=short --strict-markers --strict-config
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
# 標記
markers =
unit: 標記測試為單元測試
integration: 標記測試為整合測試
slow: 標記測試為慢速
asyncio: 標記測試為非同步
# 忽略警告
filterwarnings =
ignore::DeprecationWarning
ignore::PendingDeprecationWarning
pyproject.toml 範例
[tool.pytest.ini_options]
minversion = "7.0"
addopts = "-v --tb=short"
testpaths = ["tests"]
python_files = ["test_*.py"]
markers = [
"unit: 標記測試為單元測試",
"integration: 標記測試為整合測試",
]
filterwarnings = [
"ignore::DeprecationWarning",
]
覆蓋率組態
.coveragerc 範例
[run]
source = mypackage
omit =
*/migrations/*
*/tests/*
*/venv/*
[report]
show_missing = true
fail_under = 80
precision = 2
exclude_lines =
if __name__ == "__main__":
pragma: no cover
def __repr__
if self.debug:
使用 pyproject.toml 設定覆蓋率
[tool.coverage.run]
source = ["mypackage"]
omit = ["*/migrations/*", "*/tests/*"]
[tool.coverage.report]
show_missing = true
fail_under = 80
進階模式
使用 contextlib 的條件式測試
import sys
import pytest
from contextlib import contextmanager
@contextmanager
def not_implemented():
"""如果尚未實作則跳過測試的內容管理器。"""
try:
yield
except NotImplementedError:
pytest.skip("功能尚未實作")
def test_future_feature():
with not_implemented():
result = future_feature()
assert result is not None
測試自訂斷言
class CustomAssertions:
"""為測試類別使用的自訂斷言輔助類別。"""
def assert_valid_user(self, user):
"""斷言使用者物件有效。"""
assert hasattr(user, 'name'), "使用者必須有名稱"
assert hasattr(user, 'email'), "使用者必須有電子郵件"
assert '@' in user.email, "電子郵件必須包含 @"
assert user.age >= 0, "年齡不能為負"
class TestUser(CustomAssertions):
"""使用者相關測試。"""
def test_user_creation(self):
"""測試使用者建立。"""
user = User(name="Alice", email="[email protected]", age=30)
self.assert_valid_user(user)
def test_invalid_user(self):
"""測試無效使用者。"""
with pytest.raises(ValueError, match="無效的電子郵件"):
User(name="Bob", email="invalid")
測試 Performance
import time
class TestPerformance:
"""效能相關測試。"""
def test_function_speed(self):
"""測試函式在時間限制內完成。"""
start = time.perf_counter()
result = slow_function()
elapsed = time.perf_counter() - start
assert elapsed < 2.0, f"函式花費 {elapsed:.2f} 秒(限制 2 秒)"
assert result is not None
@pytest.mark.slow
def test_bulk_operation(self):
"""測試大量資料操作。"""
import tracemalloc
tracemalloc.start()
process_bulk_data(10000)
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
assert peak < 50 * 1024 * 1024, f"記憶體使用量為 {peak / 1024 / 1024:.1f} MB"
測試 Database 交易
import pytest
class TestDatabaseTransactions:
"""資料庫交易測試。"""
@pytest.fixture(autouse=True)
def setup_database(self):
"""設定資料庫連線。"""
self.connection = create_connection()
self.connection.begin()
yield
self.connection.rollback()
self.connection.close()
def test_save_user(self):
"""測試儲存使用者到資料庫。"""
user = User(name="Alice")
self.connection.save(user)
retrieved = self.connection.get(User, user.id)
assert retrieved.name == "Alice"
def test_update_user(self):
"""測試更新使用者。"""
user = self.connection.create(User, name="Bob")
user.name = "Robert"
self.connection.update(user)
updated = self.connection.get(User, user.id)
assert updated.name == "Robert"
測試執行與報告
基本命令
# 執行所有測試
pytest
# 具有詳細資訊
pytest -v
# 在失敗後停止
pytest -x
# 最後一次失敗的測試
pytest --last-failed
# 失敗時進入偵錯模式
pytest --pdb
輸出格式
# 簡短摘要
pytest -q
# 長時間測試的詳細資訊
pytest -v --durations=10
# JUnit XML 輸出(用於 CI)
pytest --junitxml=report.xml
# HTML 報告
pytest --html=report.html
平行執行
# 使用 pytest-xdist
pytest -n auto
pytest -n 4
# 每個 worker 的測試分佈
pytest -n auto --dist loadscope
最佳實踐
測試行為,而非實作
# 好的:測試行為
@patch("mypackage.send_email")
def test_notify_user(mock_send_email):
service = NotificationService()
service.notify_user("[email protected]", "歡迎!")
mock_send_email.assert_called_once_with(
"[email protected]",
"歡迎!"
)
# 不好的:測試實作
@patch("mypackage.NotificationService._format_message")
def test_internal_method(mock_format):
service = NotificationService()
service.notify_user("[email protected]", "嗨")
mock_format.assert_called_once()
保持測試獨立
# 好的:每個測試獨立
def test_register_user():
service = UserService()
user = service.register("[email protected]")
assert user.email == "[email protected]"
def test_login_user():
service = UserService()
service.register("[email protected]")
token = service.login("[email protected]")
assert token is not None
# 不好的:測試依賴
registered_user = None
def test_register():
global registered_user
registered_user = register("[email protected]")
assert registered_user is not None
def test_login():
global registered_user
# 依賴於 test_register 的副作用
token = login(registered_user.email)
assert token is not None
使用描述性測試名稱
# 好的:描述性名稱
def test_send_email_fails_with_invalid_address():
"""測試以無效地址發送電子郵件會引發錯誤。"""
with pytest.raises(ValueError):
send_email("invalid", "test")
# 不好的:不明確的名稱
def test_send():
assert send_email("[email protected]", "content") is True
測試邊界條件
def test_validate_password():
"""測試密碼邊界條件。"""
# 最小長度
assert validate_password("Abc123!")
# 最大長度
assert validate_password("A" * 128 + "b1!")
# 正好最小長度
assert validate_password("A1!")
# 太短
assert not validate_password("A1")
# 太長
assert not validate_password("A" * 129 + "b1!")
# 缺少數字
assert not validate_password("Abcdef!")
# 缺少特殊字元
assert not validate_password("Abcdef1")
在隔離環境中測試
def test_with_isolated_modules():
"""在隔離環境中測試模組。"""
import sys
from unittest.mock import patch
# 模擬環境變數
with patch.dict('os.environ', {'DATABASE_URL': 'sqlite:///test.db'}):
# 重新載入模組以使用模擬的環境
import importlib
import mypackage.config
importlib.reload(mypackage.config)
assert mypackage.config.DATABASE_URL == 'sqlite:///test.db'
常見問題
1. Fixture 作用域衝突
# 問題:不同的作用域需求
@pytest.fixture(scope="session")
def db_connection():
return create_connection()
@pytest.fixture
def test_db(db_connection):
# 需要一個新的資料庫,但不想影響工作階段 fixture
db_connection.execute("CREATE TABLE test...")
yield
db_connection.execute("DROP TABLE test...")
2. 非同步測試中的 Mock 問題
# 問題:非同步函式中的 mock
@pytest.mark.asyncio
@patch("mypackage.async_service")
async def test_async_service(service_mock):
# 對於非同步 mock 使用 AsyncMock
from unittest.mock import AsyncMock
service_mock.return_value = AsyncMock()
await service()
3. 暫時性失敗
import os
@pytest.mark.flaky(reruns=3, reruns_delay=2)
def test_external_api():
"""使用 pytest-rerunfailures 重試暫時性失敗。"""
response = requests.get("https://api.example.com/status")
assert response.status_code == 200
結論
這個全面的測試模式涵蓋了所有 Python pytest 測試的層面,從基本結構到進階模式。透過遵循這些模式,你可以建立可維護、可靠且全面的測試套件,這些套件將在開發過程中為你的應用程式提供信心。
請記住,測試不僅僅是為了捕捉錯誤 —— 它是關於建立對程式碼行為的理解,並建立一個讓你能夠自信地進行重構和擴展的基礎。