FastAPI 測試與除錯 – 模擬 Request / Response
簡介
在開發 API 時,測試 是保證功能正確與穩定的關鍵,而 模擬 (mock) request/response 則是單元測試與整合測試中最常用的技巧。
透過模擬,我們可以在不啟動真實伺服器的情況下,驗證路由、依賴注入、驗證器以及中介層 (middleware) 的行為,並快速定位程式錯誤。
FastAPI 本身提供了 TestClient(基於 Starlette 的測試客戶端)以及 Dependency Overriding 機制,使得模擬請求與回應變得相當直觀。本文將一步步說明如何在 FastAPI 專案中 模擬 request / response,並分享實務上常見的陷阱與最佳實踐,幫助你寫出更可靠的測試程式。
核心概念
1. 為什麼要模擬 Request / Response?
- 獨立性:測試不依賴外部服務(資料庫、第三方 API),避免因環境差異導致測試不穩定。
- 速度:不需要啟動實體伺服器或建立真實連線,測試執行時間可縮短至毫秒級。
- 可預測性:透過固定的回傳資料,測試結果可預測,方便驗證邏輯正確性。
2. FastAPI 的測試工具
| 工具 | 目的 | 主要特性 |
|---|---|---|
TestClient |
模擬 HTTP 請求 | 基於 requests,支援同步與非同步端點 |
override_dependency |
替換依賴 | 可在測試期間注入 mock 物件或函式 |
AsyncClient (httpx) |
非同步測試 | 與 FastAPI 完全相容的非同步 HTTP 客戶端 |
小技巧:在測試檔案中,通常會把
app與client放在conftest.py,讓 pytest 能自動載入。
3. 基本範例:使用 TestClient 模擬 GET 請求
# test_main.py
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/items/{item_id}")
def read_item(item_id: int, q: str | None = None):
"""簡單回傳查詢參數與路徑參數"""
return {"item_id": item_id, "q": q}
client = TestClient(app)
def test_read_item():
# 模擬 GET /items/42?q=hello
response = client.get("/items/42?q=hello")
assert response.status_code == 200
assert response.json() == {"item_id": 42, "q": "hello"}
這段程式展示了最基本的 模擬請求,不需要啟動任何伺服器,只要呼叫
client.get()即可取得回應物件。
4. Mock 依賴:取代資料庫存取
假設我們有一個依賴,用於從資料庫取得使用者資訊:
# dependencies.py
from typing import Protocol
class UserRepo(Protocol):
async def get_user(self, user_id: int) -> dict:
...
# real_repo.py
class RealUserRepo:
async def get_user(self, user_id: int) -> dict:
# 真實的 DB 查詢
...
# main.py
from fastapi import Depends, FastAPI
from dependencies import UserRepo
app = FastAPI()
def get_user_repo() -> UserRepo:
return RealUserRepo()
@app.get("/users/{user_id}")
async def read_user(user_id: int, repo: UserRepo = Depends(get_user_repo)):
user = await repo.get_user(user_id)
return user
在測試時,我們可以 覆寫 get_user_repo,提供一個 mock 物件:
# test_user.py
import pytest
from fastapi.testclient import TestClient
from main import app, get_user_repo
class MockUserRepo:
async def get_user(self, user_id: int) -> dict:
# 回傳固定資料,避免真的 DB 呼叫
return {"id": user_id, "name": "測試使用者"}
# 覆寫依賴
app.dependency_overrides[get_user_repo] = lambda: MockUserRepo()
client = TestClient(app)
def test_read_user():
response = client.get("/users/7")
assert response.status_code == 200
assert response.json() == {"id": 7, "name": "測試使用者"}
# 測試結束後清除覆寫,避免影響其他測試
@pytest.fixture(autouse=True)
def reset_overrides():
yield
app.dependency_overrides.clear()
透過
dependency_overrides,我們 不必真的連接資料庫,就能驗證路由的回傳格式與錯誤處理邏輯。
5. 模擬外部 API 呼叫
若 API 內部會向第三方服務發送 HTTP 請求,建議使用 httpx.MockTransport 或 responses 套件來模擬回應。
# external.py
import httpx
async def fetch_weather(city: str) -> dict:
async with httpx.AsyncClient() as client:
r = await client.get(f"https://api.weather.com/{city}")
r.raise_for_status()
return r.json()
測試時使用 httpx.MockTransport:
# test_external.py
import pytest
import httpx
from external import fetch_weather
@pytest.mark.asyncio
async def test_fetch_weather():
# 建立 mock 回傳資料
async def mock_handler(request):
assert request.url.path == "/Taipei"
return httpx.Response(200, json={"temp": 28, "status": "晴"})
transport = httpx.MockTransport(mock_handler)
async with httpx.AsyncClient(transport=transport) as client:
# 以相同方式呼叫 fetch_weather,只是內部使用 mock client
result = await fetch_weather("Taipei")
assert result == {"temp": 28, "status": "晴"}
重點:
MockTransport只會攔截httpx.AsyncClient的請求,讓我們可以 完全控制外部 API 的回應,而不必真的對外發送請求。
6. 測試 Middleware 與例外處理
假設我們有一段自訂 Middleware,會在每個 response 加上 X-Request-ID:
# middleware.py
import uuid
from starlette.middleware.base import BaseHTTPMiddleware
class RequestIDMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
request_id = str(uuid.uuid4())
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
測試這段 Middleware:
# test_middleware.py
from fastapi import FastAPI
from fastapi.testclient import TestClient
from middleware import RequestIDMiddleware
app = FastAPI()
app.add_middleware(RequestIDMiddleware)
@app.get("/ping")
def ping():
return {"msg": "pong"}
client = TestClient(app)
def test_request_id_header():
resp = client.get("/ping")
assert resp.status_code == 200
# 確認 Header 被正確加入
assert "X-Request-ID" in resp.headers
# Header 必須是 UUID 格式
import re
uuid_pat = re.compile(r"^[0-9a-fA-F-]{36}$")
assert uuid_pat.match(resp.headers["X-Request-ID"])
這裡我們直接使用
TestClient,而不需要額外的 mock;因為 Middleware 本身只在請求/回應的生命週期中插手,測試時即可觀察最終的 HTTP Header。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| 依賴未正確覆寫 | 測試時仍然呼叫真實 DB,導致測試緩慢或失敗。 | 使用 app.dependency_overrides,並在測試結束後 clear()。 |
| 同步 vs 非同步混用 | 在 TestClient 中呼叫非同步端點時忘記使用 await,會得到 RuntimeError。 |
若測試非同步端點,使用 httpx.AsyncClient 或 pytest-asyncio。 |
| 全域狀態污染 | 測試中修改全域變數或單例,影響其他測試。 | 盡量把狀態封裝在依賴或函式內,使用 fixture 產生獨立實例。 |
| Mock 回傳不符合實際結構 | 回傳的資料與真實 API 格式不一致,測試通過但上線失敗。 | 參考 OpenAPI schema 或使用 pydantic 的 parse_obj 進行驗證。 |
| 忘記測試例外路徑 | 只測試成功情況,忽略了例外處理。 | 針對 4xx/5xx、timeout、validation error 等情況寫測試。 |
最佳實踐
- 分層測試:先對 Service 層、Repository 層寫單元測試(使用 mock),再對 API 路由寫整合測試(使用 TestClient)。
- 使用 Fixtures:將
client、app、dependency_overrides包裝成 pytest fixture,保證測試的隔離性與可重用性。 - 驗證回傳結構:結合 Pydantic model,使用
model.parse_obj(response.json())確保回傳符合預期 schema。 - 保持測試快:每個測試的執行時間建議不超過 200ms,過慢的測試往往是因為真的呼叫了外部資源。
- CI 整合:將測試指令加入 GitHub Actions、GitLab CI 等,確保每次 PR 都會跑完整測試。
實際應用場景
開發新功能時驗證資料流
- 當新增一個需要同時呼叫外部信用評分 API 與內部 DB 的端點時,可先用
MockTransport模擬信用評分回應,再用dependency_overrides模擬 DB,確認整體邏輯正確。
- 當新增一個需要同時呼叫外部信用評分 API 與內部 DB 的端點時,可先用
回歸測試
- 專案升級 FastAPI 版號或改寫中介層時,執行完整的
TestClient整合測試,確保舊有路由仍保持相同的 HTTP 狀態碼與回傳結構。
- 專案升級 FastAPI 版號或改寫中介層時,執行完整的
性能基準測試
- 使用
TestClient搭配pytest-benchmark,在不啟動真實伺服器的情況下測量每個端點的平均回應時間,快速定位瓶頸。
- 使用
安全測試
- 透過 mock 產生惡意請求(如缺少 JWT、過長的 body),驗證 API 的驗證與錯誤回傳是否符合安全需求。
總結
模擬 request / response 是 FastAPI 測試流程中不可或缺的一環。透過 TestClient、dependency_overrides、以及 httpx.MockTransport,我們可以:
- 快速、可靠 地驗證路由、依賴與中介層的行為
- 避免外部資源 造成測試不穩定或執行時間過長
- 在 CI/CD 中自動化測試,提升程式碼品質與部署信心
掌握上述技巧後,你將能在 開發階段即捕捉錯誤,並在 上線前提供完整的測試覆蓋,讓 FastAPI 應用更具彈性與可維護性。祝你寫測試寫得順利,開發更快、更好!