FastAPI 測試與除錯:coverage 測試覆蓋率
簡介
在開發 FastAPI 應用程式時,測試 是確保功能正確、維護成本低的關鍵。即使測試用例已經寫得很完整,我們仍需要一個量化指標來檢視到底有多少程式碼被實際執行過,這就是 測試覆蓋率(coverage)。
- 覆蓋率 能夠快速指出哪些路徑、條件或錯誤處理分支尚未被測試,避免因為遺漏測試而在上線後才發現 bug。
- 在 CI/CD 流程中加入 coverage 檢查,讓程式碼品質形成「門檻」;未達到設定的百分比就會阻止部署。
本篇文章將以 FastAPI 為例,說明如何使用 coverage.py 來測量測試覆蓋率、設定常見的排除規則,以及在實務上如何解讀與提升覆蓋率。適合剛接觸測試的初學者,也能為已有測試基礎的開發者提供進階的最佳實踐。
核心概念
1. 為什麼要測量覆蓋率?
| 目的 | 具體好處 |
|---|---|
| 發現盲點 | 找出未被測試的程式碼路徑(例如例外處理、分支條件)。 |
| 品質門檻 | 在 CI 中設定最低覆蓋率(如 80%),自動阻止低品質 PR 合併。 |
| 回歸保護 | 變更程式碼後,若覆蓋率下降,立刻提醒可能的回歸風險。 |
| 文件化 | 覆蓋率報告可作為團隊溝通的依據,讓新加入的成員快速了解測試範圍。 |
小提醒:高覆蓋率不等於高品質測試,仍須檢視測試的斷言是否足夠。
2. coverage.py 基本工作原理
coverage.py 會在 Python 解譯器執行程式碼時,插入追蹤點,記錄每一行是否被執行過。執行完測試後,產生一份 .coverage 檔案,接著透過 coverage report 或 coverage html 產出可讀的報表。
- 行級別(line)是最常見的測量方式,亦可選擇 分支級別(branch)來追蹤
if/else、try/except等分支是否全部走過。 coverage支援 排除(omit)特定檔案或路徑,避免測試工具、設定檔等雜訊影響統計。
3. 在 FastAPI 專案中安裝與設定
# 1️⃣ 安裝核心套件
pip install fastapi uvicorn[standard] pytest pytest-asyncio
# 2️⃣ 安裝 coverage
pip install coverage
接著在專案根目錄建立 .coveragerc(可自行命名為 coverage.cfg),以下是一個常見的範例:
# .coveragerc
[run]
branch = True # 追蹤分支覆蓋率
source = app # 只測量 app 資料夾(排除測試本身)
omit =
*/tests/* # 排除測試檔案
*/__init__.py # 排除 __init__
*/settings.py # 排除設定檔
[report]
show_missing = True # 顯示未執行的行號
skip_covered = True # 跳過已 100% 覆蓋的檔案
[html]
directory = htmlcov # HTML 報告輸出目錄
技巧:
branch = True能讓if/else、for/while、try/except的每條分支都被計算,對於 API 的錯誤處理尤為重要。
4. 撰寫測試範例(pytest + async)
以下示範一個簡易的 Todo API,並提供 3 個測試檔案,說明如何在測試中觸發不同路徑。
4.1 app/main.py – FastAPI 核心程式
# app/main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List
app = FastAPI()
class TodoItem(BaseModel):
id: int
title: str
done: bool = False
# 假資料庫(僅示範用)
_DB: List[TodoItem] = []
@app.post("/todos", response_model=TodoItem, status_code=201)
async def create_todo(item: TodoItem):
"""建立新的 Todo,若 ID 已存在則拋出例外"""
if any(t.id == item.id for t in _DB):
raise HTTPException(status_code=400, detail="ID already exists")
_DB.append(item)
return item
@app.get("/todos/{todo_id}", response_model=TodoItem)
async def read_todo(todo_id: int):
"""依 ID 取得 Todo,找不到回 404"""
for t in _DB:
if t.id == todo_id:
return t
raise HTTPException(status_code=404, detail="Todo not found")
@app.delete("/todos/{todo_id}", status_code=204)
async def delete_todo(todo_id: int):
"""刪除 Todo,若不存在則回 404"""
global _DB
for t in _DB:
if t.id == todo_id:
_DB.remove(t)
return
raise HTTPException(status_code=404, detail="Todo not found")
4.2 tests/test_create.py – 測試 create_todo
# tests/test_create.py
import pytest
from httpx import AsyncClient
from app.main import app, _DB
@pytest.fixture(autouse=True)
def clear_db():
# 每個測試前清空假資料庫,避免相互干擾
_DB.clear()
yield
@pytest.mark.asyncio
async def test_create_success():
async with AsyncClient(app=app, base_url="http://test") as client:
payload = {"id": 1, "title": "寫測試文件"}
response = await client.post("/todos", json=payload)
assert response.status_code == 201
data = response.json()
assert data["id"] == 1
assert data["title"] == "寫測試文件"
assert data["done"] is False
@pytest.mark.asyncio
async def test_create_duplicate_id():
# 先建立一筆
async with AsyncClient(app=app, base_url="http://test") as client:
await client.post("/todos", json={"id": 1, "title": "第一筆"})
# 再嘗試相同 ID
resp = await client.post("/todos", json={"id": 1, "title": "重複"})
assert resp.status_code == 400
assert resp.json()["detail"] == "ID already exists"
4.3 tests/test_read_delete.py – 測試 read_todo 與 delete_todo
# tests/test_read_delete.py
import pytest
from httpx import AsyncClient
from app.main import app, _DB
@pytest.fixture(autouse=True)
def seed_db():
# 為每個測試預先放入兩筆資料
_DB.clear()
_DB.extend([
{"id": 1, "title": "第一筆", "done": False},
{"id": 2, "title": "第二筆", "done": True},
])
yield
_DB.clear()
@pytest.mark.asyncio
async def test_read_existing():
async with AsyncClient(app=app, base_url="http://test") as client:
resp = await client.get("/todos/2")
assert resp.status_code == 200
data = resp.json()
assert data["title"] == "第二筆"
assert data["done"] is True
@pytest.mark.asyncio
async def test_read_not_found():
async with AsyncClient(app=app, base_url="http://test") as client:
resp = await client.get("/todos/999")
assert resp.status_code == 404
assert resp.json()["detail"] == "Todo not found"
@pytest.mark.asyncio
async def test_delete_success():
async with AsyncClient(app=app, base_url="http://test") as client:
resp = await client.delete("/todos/1")
assert resp.status_code == 204
# 確認資料已被移除
get_resp = await client.get("/todos/1")
assert get_resp.status_code == 404
@pytest.mark.asyncio
async def test_delete_not_found():
async with AsyncClient(app=app, base_url="http://test") as client:
resp = await client.delete("/todos/123")
assert resp.status_code == 404
assert resp.json()["detail"] == "Todo not found"
重點:上述測試同時涵蓋了 成功路徑、錯誤路徑、以及 例外分支,配合
branch = True後,我們可以看到每條if都被執行過。
5. 產生與閱讀覆蓋率報告
5.1 執行測試並產生 .coverage
# 先清除舊的統計檔
coverage erase
# 執行 pytest,讓 coverage 同時追蹤
coverage run -m pytest
5.2 文字報告(快速檢查)
coverage report
範例輸出(截圖說明):
Name Stmts Miss Cover Missing
-------------------------------------------------
app/main.py 38 2 95% 14, 27
tests/test_create.py 30 0 100%
tests/test_read_delete.py 38 0 100%
-------------------------------------------------
TOTAL 106 2 98%
- Miss 為未執行的行號,可直接在 IDE 中跳轉修正。
TOTAL為整體覆蓋率,若設定門檻(例如 90%),可以在 CI 中加入判斷:
coverage report --fail-under=90
5.3 HTML 報告(視覺化)
coverage html
產生的 htmlcov/index.html 會以不同顏色標示:
- 綠色:已覆蓋
- 紅色:未覆蓋
- 黃色:部分覆蓋(分支未完整)
開發者只要在瀏覽器打開,即可快速定位「死角」程式碼。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
| 只看行覆蓋率 | 行覆蓋率 100% 仍可能缺少分支測試。 | 開啟 branch = True,並檢查 分支缺失。 |
| 測試檔案被算入覆蓋率 | coverage 預設會把測試本身也算進去,導致數字失真。 |
在 .coveragerc 的 omit 區段排除 */tests/*。 |
| 異步測試未正確等待 | async 測試若忘記 await,會導致程式碼未真正執行。 |
使用 pytest-asyncio,並在每個測試函式前加 @pytest.mark.asyncio。 |
| 環境差異 | 本機跑測試時覆蓋率高,CI 中卻下降。 | 確保 CI 使用相同的 Python 版本、依賴,並將 .coveragerc 版本化。 |
| 過度追蹤第三方套件 | 追蹤外部套件會讓報告雜訊變多。 | 在 .coveragerc 的 source 設定只針對自家套件目錄(如 app/)。 |
最佳實踐清單
- 在專案根目錄加入
.coveragerc,明確排除不需要的檔案。 - 結合 CI(GitHub Actions、GitLab CI)自動執行
coverage run && coverage xml && coverage report --fail-under=XX。 - 使用分支覆蓋,尤其在
try/except、if/elif/else等錯誤處理路徑。 - 每次新增功能或 bugfix 時,先撰寫測試,再執行
coverage確認新路徑已被覆蓋。 - 定期檢視 HTML 報告,將未覆蓋的程式碼列入技術債清單,安排優先修復。
實際應用場景
1. 企業內部 API 服務的品質門檻
某金融公司在部署 FastAPI 微服務時,將 最低覆蓋率 85% 設為合併保護(branch protection)。每次 Pull Request 觸發 GitHub Actions,若測試或覆蓋率未達標,CI 會自動失敗,阻止程式碼進入 main 分支。這樣的機制有效降低了 生產環境突發錯誤 的機率。
2. 開源套件的貢獻者指引
在開源 FastAPI 擴充套件(例如 fastapi-pagination)的 README 中加入:
# 開發者必讀
pip install -e .[dev] # 包含 pytest、coverage
coverage run -m pytest
coverage report --fail-under=90
新貢獻者在提交 PR 前必須先保證 90% 以上的覆蓋率,維持套件的測試品質。
3. 逐步提升舊有系統的測試深度
對於已有三年歷史、測試覆蓋率只有 45% 的遺留系統,可採 分階段 方法:
- 先跑一次 coverage,產出 HTML 報告,找出最「紅」的模組。
- 優先為這些模組寫測試,目標先提升到 70%。
- 設定 CI 失敗門檻 為 70%,在每次迭代中持續提升。
此策略讓團隊在不一次性投入大量資源的情況下,逐步提升測試品質。
總結
- 測試覆蓋率 是衡量程式碼測試完整性的量化指標,對 FastAPI 這類以路由與異步處理為核心的框架尤為重要。
- 使用
coverage.py搭配.coveragerc,可以靈活控制要追蹤的範圍、分支覆蓋與排除規則。 - 透過 CLI(
coverage run、coverage report、coverage html)與 CI 整合,讓測試品質成為自動化流程的一部份。 - 高覆蓋率不等於高品質測試,仍需檢視斷言的完整性與邊界條件。
- 在實務上,將 覆蓋率門檻、分支測試 與 HTML 報告檢視 結合,可有效降低上線後的錯誤率,提升團隊開發效率與產品穩定性。
最後一句話:把 coverage 當作每日開發的「健康檢查」儀表板,讓程式碼的每一次變更都在可視化的品質指標下前進,才能真正做到「測試驅動」的高品質 FastAPI 開發。