FastAPI 測試與除錯:pytest + TestClient 完全指南
簡介
在 FastAPI 開發專案時,測試是確保 API 正確性與穩定性的關鍵步驟。即使功能看起來運作正常,缺乏自動化測試仍可能在未來的需求變動或重構時埋下隱憂。利用 pytest 搭配 FastAPI 的 TestClient,我們可以在不啟動真實伺服器的情況下,直接對路由、請求與回應做完整驗證,讓除錯過程變得更快、更可靠。
本篇文章將從核心概念說明開始,透過 5 個實用範例 示範如何寫測試、模擬依賴、檢查例外與驗證資料模型,最後整理常見陷阱、最佳實踐與真實專案的應用情境,幫助初學者到中級開發者快速上手測試與除錯。
核心概念
1. 為什麼選擇 pytest?
- 簡潔的語法:只要寫
assert,pytest 會自動產生清晰的錯誤訊息。 - 強大的插件生態:如
pytest-asyncio、pytest-cov可支援異步測試與覆蓋率報告。 - 自動發現測試:只要檔名符合
test_*.py或*_test.py,pytest 會自動載入。
小技巧:在
pytest.ini中設定addopts = -ra -q,可以讓執行結果更簡潔。
2. TestClient 的角色
TestClient 其實是 Starlette(FastAPI 底層框架)提供的測試用 HTTP 客戶端,內部使用 requests 介面模擬 HTTP 呼叫。它的好處在於:
- 不需要啟動實體伺服器,測試速度快。
- 支援完整的請求流程(headers、cookies、JSON body 等)。
- 可以直接取得 FastAPI 的依賴注入容器,方便測試時覆寫依賴。
3. 基本測試結構
# tests/test_main.py
from fastapi.testclient import TestClient
from app.main import app # 這裡的 app 為 FastAPI 實例
client = TestClient(app)
def test_root():
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"message": "Hello World"}
重點:測試檔案只需要匯入
app,不需要額外的uvicorn或docker設定。
程式碼範例
以下示範 5 個常見且實務導向的測試情境,全部使用 pytest + TestClient。
範例 1:測試 JSON 請求與回應
# tests/test_user.py
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_create_user():
payload = {"username": "alice", "email": "alice@example.com"}
response = client.post("/users/", json=payload)
assert response.status_code == 201
data = response.json()
assert data["id"] is not None
assert data["username"] == "alice"
assert data["email"] == "alice@example.com"
- 使用
json=參數自動將字典序列化為 JSON。 response.json()會把回傳的 JSON 直接轉成 Python 結構,方便斷言。
範例 2:測試帶有路徑參數的 GET
def test_get_user():
# 先建立一筆測試資料
create_resp = client.post("/users/", json={"username": "bob", "email": "bob@example.com"})
user_id = create_resp.json()["id"]
# 取得剛才建立的使用者
resp = client.get(f"/users/{user_id}")
assert resp.status_code == 200
assert resp.json()["username"] == "bob"
- 透過前置步驟建立測試資料,確保測試是 自足 的。
範例 3:模擬依賴(Dependency Override)
FastAPI 允許在測試時 override 依賴,以避免真的呼叫外部服務(如資料庫、第三方 API)。
# app/dependencies.py
def get_db():
# 正式環境會回傳資料庫 Session
...
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.dependencies import get_db
class FakeDB:
def __init__(self):
self.storage = {}
def add_user(self, user):
self.storage[user["id"]] = user
return user
def get_user(self, user_id):
return self.storage.get(user_id)
@pytest.fixture
def client():
fake_db = FakeDB()
# 覆寫依賴
app.dependency_overrides[get_db] = lambda: fake_db
with TestClient(app) as c:
yield c
# 清除覆寫
app.dependency_overrides.clear()
def test_user_via_fake_db(client):
# 直接使用 client fixture
resp = client.post("/users/", json={"username": "cathy", "email": "cathy@example.com"})
assert resp.status_code == 201
user_id = resp.json()["id"]
# 透過覆寫的 FakeDB 取得資料
assert client.app.dependency_overrides[get_db]().get_user(user_id)["username"] == "cathy"
- 關鍵:
app.dependency_overrides只在測試期間有效,測試結束後記得clear()。
範例 4:測試異步路由與 pytest-asyncio
若路由使用 async def,仍可使用同步的 TestClient,但若想直接測試非 HTTP 的協程函式,則需要 pytest-asyncio。
# tests/test_async.py
import pytest
from httpx import AsyncClient
from app.main import app
@pytest.mark.asyncio
async def test_async_hello():
async with AsyncClient(app=app, base_url="http://test") as ac:
resp = await ac.get("/async-hello")
assert resp.status_code == 200
assert resp.json() == {"msg": "Hello from async endpoint"}
- 說明:
httpx.AsyncClient也是 FastAPI 官方推薦的非同步測試工具。
範例 5:驗證例外處理與錯誤回傳
def test_invalid_user_id():
resp = client.get("/users/invalid-id")
assert resp.status_code == 422 # FastAPI 會自動回傳驗證錯誤
json_body = resp.json()
assert json_body["detail"][0]["loc"] == ["path", "user_id"]
assert "value is not a valid integer" in json_body["detail"][0]["msg"]
- 透過測試確認 validation error 與 自訂例外(如
HTTPException(status_code=404))的行為是否如預期。
常見陷阱與最佳實踐
| 陷阱 | 可能原因 | 解決方案 |
|---|---|---|
| 測試相依於真實資料庫 | 直接使用 SessionLocal() 而未覆寫 |
使用 dependency_overrides 注入 in‑memory SQLite 或 FakeDB。 |
| 測試順序影響結果 | 前一個測試留下資料,後一個測試假設乾淨環境 | 每個測試使用 fixture 建立與銷毀獨立環境,或在 client fixture 中使用 transaction.rollback()。 |
| 異步路由測試失敗 | 使用同步 TestClient 呼叫 async 函式時未等候 |
改用 httpx.AsyncClient 並加上 @pytest.mark.asyncio。 |
| 錯誤訊息不明確 | 直接 assert response.text,忽略 JSON 結構 |
透過 response.json()["detail"] 取得結構化錯誤資訊,並斷言關鍵欄位。 |
| 測試覆寫未清除 | app.dependency_overrides 在多個測試間共用 |
在 fixture 的 yield 後 app.dependency_overrides.clear(),或使用 autouse=True 的 fixture 進行自動清理。 |
最佳實踐
- 測試即文件:每個路由的測試檔案名稱應與路由檔案對應,讓新同事能快速了解 API 行為。
- 使用 fixtures 統一管理 client:在
conftest.py中提供clientfixture,所有測試共享同一套設定。 - 加入覆蓋率報告:執行
pytest --cov=app -vv,確保核心業務邏輯的覆蓋率達到 80% 以上。 - CI/CD 整合:將測試指令寫入 GitHub Actions 或 GitLab CI,確保每次 PR 都必須通過測試。
- 保持測試獨立:不要在測試中依賴全域變數或外部狀態,使用 factory-boy 或 faker 產生測試資料。
實際應用場景
1. 企業內部微服務 API
在微服務架構下,每個服務的 API 必須遵守嚴格的合約。透過 pytest + TestClient,開發團隊可以在 CI 階段自動驗證:
- 請求與回應的 JSON Schema 是否符合 OpenAPI 定義。
- 授權機制(JWT、OAuth2)是否正確拋出 401/403。
2. 第三方整合測試
當 FastAPI 需要呼叫外部支付或訊息服務時,使用 dependency override 注入 Mock 客戶端,即可在本地測試所有成功與失敗情境,而不必真的向第三方發送請求。
3. 演算法或資料處理服務
若 API 背後執行複雜的機器學習模型,測試可以:
- 把模型載入改為 輕量的 stub,確保 API 邏輯(參數驗證、回傳格式)正確。
- 使用
pytest.mark.parametrize測試多組輸入與預期輸出,快速定位邊界值問題。
總結
- pytest + TestClient 為 FastAPI 測試提供了 快速、簡潔且功能完整 的解決方案。
- 透過 dependency overrides、fixture 與 異步測試支援,我們可以在不依賴真實外部資源的情況下,完整驗證路由、資料模型與例外處理。
- 注意常見的測試陷阱(資料庫相依、測試順序、異步錯誤等),並遵循最佳實踐(測試即文件、CI 整合、覆蓋率監控),即可讓專案在持續開發與部署過程中保持高品質與可維護性。
最後提醒:寫測試不是為了追求 100% 的覆蓋率,而是為了在功能變更時,快速捕捉破壞性改動,讓除錯變得更有效率。祝你在 FastAPI 的測試旅程中順利前行!