Mocking 技術
基本概念
Mocking 是一種測試技術,通過創建模擬對象來替代真實的依賴項,使測試能夠在隔離的環境中進行。在 FastAPI 應用程序的測試中,Mocking 技術尤為重要,因為它可以幫助我們測試複雜的系統,而無需依賴外部服務或資源。
概念 | 說明 |
---|---|
Mock 對象 | 模擬真實對象行為的假對象,可以預設返回值和記錄調用 |
Stub | 提供預定義響應的簡單實現,不記錄調用信息 |
Spy | 記錄調用信息但保留原始行為的對象 |
Fake | 具有實際工作實現但不適合生產的簡化版本 |
Dummy | 傳遞但不實際使用的對象 |
Python 中的 Mocking 工具
Python 的標準庫提供了強大的 Mocking 工具,主要通過 unittest.mock
模塊實現。
主要的 Mock 類型
Mock 類型 | 用途 |
---|---|
Mock | 基本的模擬對象,可以設置返回值和副作用 |
MagicMock | 增強版的 Mock,預先實現了許多魔術方法 |
AsyncMock | 用於模擬非同步函數和方法的特殊 Mock |
PropertyMock | 用於模擬屬性的特殊 Mock |
patch | 用於臨時替換模塊和類的裝飾器和上下文管理器 |
基本的 Mocking 技術
創建和配置 Mock 對象
# tests/test_basic_mocking.py
from unittest.mock import Mock
def test_basic_mock():
# 創建一個基本的 Mock 對象
mock_object = Mock()
# 配置返回值
mock_object.method.return_value = "mocked result"
# 調用方法
result = mock_object.method()
# 驗證結果
assert result == "mocked result"
# 驗證調用
mock_object.method.assert_called_once()
使用 patch 裝飾器
# tests/test_patch_decorator.py
from unittest.mock import patch
import requests
from app.services.user_service import get_user_data
@patch("app.services.user_service.requests.get")
def test_get_user_data(mock_get):
# 配置模擬響應
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"id": 1, "name": "Test User"}
mock_get.return_value = mock_response
# 調用被測試的函數
user = get_user_data(1)
# 驗證結果
assert user["name"] == "Test User"
# 驗證 requests.get 被正確調用
mock_get.assert_called_once_with("https://api.example.com/users/1")
使用 patch 上下文管理器
# tests/test_patch_context_manager.py
from unittest.mock import patch, Mock
from app.services.email_service import send_welcome_email
def test_send_welcome_email():
# 創建一個用戶對象
user = {"email": "user@example.com", "name": "Test User"}
# 使用上下文管理器模擬 smtp 庫
with patch("app.services.email_service.smtplib.SMTP") as mock_smtp:
# 配置模擬對象
mock_smtp_instance = Mock()
mock_smtp.return_value = mock_smtp_instance
# 調用被測試的函數
result = send_welcome_email(user)
# 驗證結果
assert result is True
# 驗證 SMTP 方法被正確調用
mock_smtp.assert_called_once_with("smtp.example.com", 587)
mock_smtp_instance.send_message.assert_called_once()
模擬 FastAPI 依賴項
FastAPI 的依賴注入系統是其核心特性之一,測試時需要有效地模擬這些依賴項。
使用 app.dependency_overrides
FastAPI 提供了一種覆蓋依賴項的機制,這在測試中非常有用:
# tests/api/test_dependency_override.py
from fastapi.testclient import TestClient
from app.main import app
from app.dependencies.auth import get_current_user
client = TestClient(app)
def test_protected_endpoint_with_override():
# 創建一個替代依賴
def override_get_current_user():
return {"id": 1, "username": "testuser"}
# 覆蓋依賴
app.dependency_overrides[get_current_user] = override_get_current_user
try:
# 發送請求
response = client.get("/items/me")
# 驗證響應
assert response.status_code == 200
assert response.json() == {"owner_id": 1, "items": []}
finally:
# 清理依賴覆蓋
app.dependency_overrides = {}
模擬數據庫操作
測試數據庫操作時,我們通常希望避免實際的數據庫交互,可以通過模擬 ORM 層或使用內存數據庫來實現。
模擬 SQLAlchemy 會話
# tests/repositories/test_user_repository.py
from unittest.mock import Mock
from app.repositories.user_repository import UserRepository
from app.models.user import User
def test_get_user_by_id():
# 創建模擬的 SQLAlchemy 會話
mock_session = Mock()
# 配置模擬查詢結果
mock_user = Mock(spec=User)
mock_user.id = 1
mock_user.username = "testuser"
mock_user.email = "test@example.com"
# 配置模擬查詢鏈
mock_session.query.return_value.filter.return_value.first.return_value = mock_user
# 創建存儲庫並注入模擬會話
repo = UserRepository(mock_session)
# 調用方法
user = repo.get_by_id(1)
# 驗證結果
assert user.id == 1
assert user.username == "testuser"
assert user.email == "test@example.com"
# 驗證查詢鏈
mock_session.query.assert_called_once_with(User)
模擬非同步 SQLAlchemy 會話
# tests/repositories/test_async_user_repository.py
import pytest
from unittest.mock import AsyncMock, Mock
from sqlalchemy.ext.asyncio import AsyncSession
from app.repositories.async_user_repository import AsyncUserRepository
from app.models.user import User
@pytest.mark.asyncio
async def test_async_get_user_by_id():
# 創建模擬的非同步 SQLAlchemy 會話
mock_session = AsyncMock(spec=AsyncSession)
# 配置模擬查詢結果
mock_user = Mock(spec=User)
mock_user.id = 1
mock_user.username = "testuser"
mock_user.email = "test@example.com"
# 配置模擬執行結果
mock_result = Mock()
mock_result.scalars.return_value.first.return_value = mock_user
mock_session.execute.return_value = mock_result
# 創建存儲庫並注入模擬會話
repo = AsyncUserRepository(mock_session)
# 調用方法
user = await repo.get_by_id(1)
# 驗證結果
assert user.id == 1
assert user.username == "testuser"
assert user.email == "test@example.com"
模擬外部 API 和服務
在測試中,我們通常需要模擬外部 API 和服務的響應,以避免實際的網絡請求。
模擬 HTTP 請求
# tests/services/test_external_api_service.py
from unittest.mock import patch, Mock
from app.services.weather_service import get_current_weather
def test_get_current_weather():
# 模擬成功的 API 響應
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"location": "New York",
"temperature": 22,
"conditions": "Sunny"
}
# 使用 patch 模擬 requests.get
with patch("app.services.weather_service.requests.get", return_value=mock_response) as mock_get:
# 調用服務
weather = get_current_weather("New York")
# 驗證結果
assert weather["temperature"] == 22
assert weather["conditions"] == "Sunny"
# 驗證 API 調用
mock_get.assert_called_once_with(
"https://api.weatherservice.com/current",
params={"city": "New York", "units": "metric"}
)
模擬非同步 HTTP 請求
# tests/services/test_async_external_api_service.py
import pytest
from unittest.mock import patch, AsyncMock
from app.services.async_weather_service import get_current_weather_async
@pytest.mark.asyncio
async def test_get_current_weather_async():
# 創建模擬的非同步響應
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"location": "New York",
"temperature": 22,
"conditions": "Sunny"
}
# 模擬 httpx.AsyncClient.get 方法
with patch("httpx.AsyncClient.get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
# 調用非同步服務
weather = await get_current_weather_async("New York")
# 驗證結果
assert weather["temperature"] == 22
assert weather["conditions"] == "Sunny"
模擬文件系統操作
測試涉及文件系統操作的代碼時,我們可以模擬文件系統來避免實際的文件操作。
使用 pyfakefs 模擬文件系統
# tests/utils/test_file_utils.py
import pytest
from pyfakefs.fake_filesystem_unittest import Patcher
from app.utils.file_utils import save_user_avatar
def test_save_user_avatar():
# 創建模擬的文件數據
avatar_data = b"fake image data"
user_id = 123
# 使用 pyfakefs 模擬文件系統
with Patcher() as patcher:
# 創建必要的目錄結構
patcher.fs.create_dir("/app/uploads/avatars")
# 調用被測試的函數
file_path = save_user_avatar(user_id, avatar_data)
# 驗證文件路徑
assert file_path == f"/app/uploads/avatars/{user_id}.png"
# 驗證文件內容
with open(file_path, "rb") as f:
saved_data = f.read()
assert saved_data == avatar_data
模擬時間和日期
測試涉及時間和日期的代碼時,我們需要能夠控制當前時間,以便測試各種情況。
使用 freezegun 模擬時間
# tests/utils/test_time_utils.py
from datetime import datetime, timedelta
from freezegun import freeze_time
from app.utils.time_utils import is_token_expired, get_token_expiration
def test_is_token_expired():
# 創建一個過期的令牌時間(1小時前)
expired_time = datetime.utcnow() - timedelta(hours=1)
# 測試過期的令牌
assert is_token_expired(expired_time) is True
# 創建一個未過期的令牌時間(1小時後)
valid_time = datetime.utcnow() + timedelta(hours=1)
# 測試未過期的令牌
assert is_token_expired(valid_time) is False
@freeze_time("2023-01-01 12:00:00")
def test_get_token_expiration():
# 在凍結的時間點調用函數
expiration = get_token_expiration()
# 驗證結果 - 應該是凍結時間加上 24 小時
expected = datetime(2023, 1, 2, 12, 0, 0)
assert expiration == expected
模擬環境變量
測試依賴於環境變量的代碼時,我們需要能夠控制這些變量的值。
使用 monkeypatch 模擬環境變量
# tests/config/test_settings.py
from app.config import get_settings
def test_get_settings_development(monkeypatch):
# 設置環境變量
monkeypatch.setenv("ENVIRONMENT", "development")
monkeypatch.setenv("DATABASE_URL", "postgresql://dev:dev@localhost/dev_db")
monkeypatch.setenv("API_KEY", "dev_api_key")
# 獲取設置
settings = get_settings()
# 驗證設置
assert settings.environment == "development"
assert settings.database_url == "postgresql://dev:dev@localhost/dev_db"
assert settings.api_key == "dev_api_key"
assert settings.debug is True # 開發環境默認啟用調試
高級 Mocking 技術
模擬異常和副作用
# tests/services/test_exception_handling.py
from unittest.mock import patch
import pytest
import requests
from app.services.user_service import get_user_data
def test_get_user_data_connection_error():
# 模擬 requests.get 拋出連接錯誤
with patch("app.services.user_service.requests.get", side_effect=requests.ConnectionError("Connection failed")):
# 調用函數並驗證它處理異常
with pytest.raises(ValueError) as exc_info:
get_user_data(1)
# 驗證錯誤消息
assert "Failed to connect to API" in str(exc_info.value)
使用 side_effect 模擬複雜行為
# tests/services/test_side_effects.py
from unittest.mock import Mock, patch
def test_retry_mechanism():
# 創建一個計數器來跟踪調用次數
call_count = 0
# 定義一個 side_effect 函數,前兩次拋出異常,第三次返回成功
def side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count < 3:
raise ConnectionError("Connection failed")
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": "success"}
return mock_response
# 模擬 requests.get 使用我們的 side_effect
with patch("app.services.resilient_service.requests.get", side_effect=side_effect):
from app.services.resilient_service import fetch_with_retry
# 調用具有重試機制的函數
result = fetch_with_retry("https://api.example.com/data")
# 驗證結果
assert result == {"data": "success"}
assert call_count == 3 # 驗證函數被調用了 3 次
模擬 FastAPI 請求和響應對象
測試 FastAPI 的依賴項和中間件時,我們需要模擬 FastAPI 的請求和響應對象。
模擬 FastAPI 請求
# tests/middleware/test_request_logging.py
from unittest.mock import Mock, patch
from fastapi import Request
from app.middleware.request_logging import log_request
async def test_log_request_middleware():
# 創建模擬的請求對象
mock_request = Mock(spec=Request)
mock_request.method = "GET"
mock_request.url.path = "/api/users"
mock_request.headers = {"User-Agent": "Test Client"}
mock_request.client.host = "127.0.0.1"
# 創建模擬的 call_next 函數
async def mock_call_next(request):
return Mock(status_code=200)
# 模擬日誌記錄器
with patch("app.middleware.request_logging.logger") as mock_logger:
# 調用中間件
response = await log_request(mock_request, mock_call_next)
# 驗證響應
assert response.status_code == 200
# 驗證日誌記錄
mock_logger.info.assert_called_once()
模擬 WebSocket 連接
測試 WebSocket 端點時,我們需要模擬 WebSocket 連接。
# tests/api/test_websocket.py
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import WebSocket, WebSocketDisconnect
from app.routers.websocket import websocket_endpoint
@pytest.mark.asyncio
async def test_websocket_endpoint():
# 創建模擬的 WebSocket 對象
mock_websocket = AsyncMock(spec=WebSocket)
# 配置 receive_text 方法,第一次返回消息,第二次拋出異常
mock_websocket.receive_text.side_effect = [
"Hello WebSocket",
WebSocketDisconnect()
]
# 調用 WebSocket 端點
with patch("app.routers.websocket.manager") as mock_manager:
try:
await websocket_endpoint(mock_websocket, 1)
except WebSocketDisconnect:
pass
# 驗證 WebSocket 方法被調用
mock_websocket.accept.assert_called_once()
mock_websocket.receive_text.assert_called()
# 驗證連接管理器方法被調用
mock_manager.connect.assert_called_once_with(mock_websocket)
mock_manager.disconnect.assert_called_once_with(mock_websocket)
模擬 Redis 和緩存操作
測試使用 Redis 或其他緩存系統的代碼時,我們可以模擬這些依賴項。
# tests/services/test_cache_service.py
from unittest.mock import Mock, patch
from app.services.cache_service import get_cached_user
def test_get_cached_user_hit():
# 模擬 Redis 客戶端
mock_redis = Mock()
mock_redis.get.return_value = '{"id": 1, "username": "cacheduser"}'
# 使用 patch 模擬 Redis 連接
with patch("app.services.cache_service.redis_client", mock_redis):
# 調用緩存服務
user = get_cached_user(1)
# 驗證結果
assert user["id"] == 1
assert user["username"] == "cacheduser"
# 驗證 Redis 調用
mock_redis.get.assert_called_once_with("user:1")
模擬 JWT 令牌驗證
測試涉及 JWT 令牌驗證的代碼時,我們需要模擬令牌解碼和驗證過程。
# tests/auth/test_jwt.py
from unittest.mock import patch
import pytest
from app.auth.jwt import verify_token
def test_verify_token_valid():
# 模擬 jwt.decode 返回有效的負載
with patch("app.auth.jwt.jwt.decode") as mock_decode:
mock_decode.return_value = {"sub": "user123", "exp": 9999999999}
# 驗證令牌
user_id = verify_token("valid_token")
# 驗證結果
assert user_id == "user123"
mock_decode.assert_called_once()
def test_verify_token_expired():
# 模擬 jwt.decode 拋出過期異常
with patch("app.auth.jwt.jwt.decode") as mock_decode:
from jwt.exceptions import ExpiredSignatureError
mock_decode.side_effect = ExpiredSignatureError("Token expired")
# 驗證令牌
user_id = verify_token("expired_token")
# 驗證結果
assert user_id is None
mock_decode.assert_called_once()
模擬 OAuth2 認證流程
測試 OAuth2 認證流程時,我們需要模擬授權服務器的響應。
# tests/auth/test_oauth2.py
from unittest.mock import patch, Mock
import pytest
from app.auth.oauth2 import get_token_from_code
def test_get_token_from_code_success():
# 模擬成功的令牌響應
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"access_token": "mock_access_token",
"token_type": "bearer",
"expires_in": 3600
}
# 模擬 requests.post
with patch("app.auth.oauth2.requests.post", return_value=mock_response) as mock_post:
# 獲取令牌
token_info = get_token_from_code("auth_code")
# 驗證結果
assert token_info["access_token"] == "mock_access_token"
assert token_info["token_type"] == "bearer"
# 驗證 API 調用
mock_post.assert_called_once()
call_args = mock_post.call_args[1]
assert "code=auth_code" in call_args["data"]
模擬數據庫遷移和種子數據
測試涉及數據庫遷移和種子數據的代碼時,我們可以模擬這些操作。
# tests/db/test_migrations.py
from unittest.mock import patch, Mock
import pytest
from app.db.migrations import run_migrations
def test_run_migrations():
# 模擬 Alembic 命令
with patch("app.db.migrations.command") as mock_command:
# 調用遷移函數
run_migrations()
# 驗證 Alembic 命令被調用
mock_command.upgrade.assert_called_once_with("head")
Mocking 的最佳實踐
只模擬直接依賴項
模擬應該集中在被測試代碼直接依賴的組件上,而不是更深層次的依賴項。這樣可以確保測試更加集中和可維護。
# 好的做法
def test_user_service():
# 直接模擬用戶存儲庫
mock_repo = Mock()
mock_repo.get_by_id.return_value = {"id": 1, "name": "Test User"}
# 注入模擬依賴
service = UserService(user_repository=mock_repo)
# 測試服務
user = service.get_user(1)
assert user["name"] == "Test User"
避免過度模擬
過度模擬會使測試變得脆弱,難以維護,並且可能無法發現實際問題。
# 避免這樣做
def test_over_mocking():
# 模擬太多細節
with patch("module.Class") as MockClass:
instance = MockClass.return_value
instance.method1.return_value.method2.return_value.method3.return_value = "result"
# 這樣的測試與實現細節過度耦合
使用夾具(Fixtures)組織模擬
使用 pytest 夾具來組織和重用模擬對象,使測試更加清晰和可維護。
# tests/conftest.py
import pytest
from unittest.mock import Mock, patch
@pytest.fixture
def mock_user_repo():
mock_repo = Mock()
mock_repo.get_by_id.return_value = {"id": 1, "name": "Test User"}
mock_repo.get_by_email.return_value = {"id": 1, "email": "test@example.com"}
return mock_repo
@pytest.fixture
def mock_auth_service():
with patch("app.services.auth_service.AuthService") as MockAuthService:
instance = MockAuthService.return_value
instance.verify_password.return_value = True
instance.create_token.return_value = "mock_token"
yield instance
驗證模擬調用
不僅要測試結果,還要驗證模擬對象是否按預期被調用。
def test_create_user(mock_user_repo, mock_email_service):
# 創建服務
service = UserService(user_repository=mock_user_repo, email_service=mock_email_service)
# 調用方法
service.create_user({"name": "New User", "email": "new@example.com"})
# 驗證存儲庫方法被調用
mock_user_repo.create.assert_called_once()
# 驗證電子郵件服務被調用
mock_email_service.send_welcome_email.assert_called_once_with("new@example.com")
總結
Mocking 是測試 FastAPI 應用程序的強大工具,它允許我們在隔離的環境中測試代碼,而無需依賴外部服務或資源。通過使用 Python 的 unittest.mock
模塊和其他工具,我們可以有效地模擬各種依賴項,包括數據庫、外部 API、文件系統和時間。
關鍵點:
- 選擇正確的模擬類型:根據需要選擇 Mock、MagicMock、AsyncMock 或其他類型。
- 只模擬直接依賴項:避免過度模擬,專注於被測試代碼的直接依賴項。
- 使用 patch 裝飾器和上下文管理器:臨時替換模塊和類,使測試更加隔離。
- 驗證模擬調用:確保模擬對象按預期被調用,包括調用次數、參數等。
- 使用夾具組織模擬:通過 pytest 夾具重用模擬對象,使測試更加清晰和可維護。
- 模擬非同步代碼:使用 AsyncMock 模擬非同步函數和方法,確保非同步代碼的正確測試。
通過掌握這些 Mocking 技術,你可以編寫更加健壯、可靠的測試,確保 FastAPI 應用程序的質量和穩定性。