本文 AI 產出,尚未審核

FastAPI 測試與除錯:coverage 測試覆蓋率


簡介

在開發 FastAPI 應用程式時,測試 是確保功能正確、維護成本低的關鍵。即使測試用例已經寫得很完整,我們仍需要一個量化指標來檢視到底有多少程式碼被實際執行過,這就是 測試覆蓋率(coverage)

  • 覆蓋率 能夠快速指出哪些路徑、條件或錯誤處理分支尚未被測試,避免因為遺漏測試而在上線後才發現 bug。
  • 在 CI/CD 流程中加入 coverage 檢查,讓程式碼品質形成「門檻」;未達到設定的百分比就會阻止部署。

本篇文章將以 FastAPI 為例,說明如何使用 coverage.py 來測量測試覆蓋率、設定常見的排除規則,以及在實務上如何解讀與提升覆蓋率。適合剛接觸測試的初學者,也能為已有測試基礎的開發者提供進階的最佳實踐。


核心概念

1. 為什麼要測量覆蓋率?

目的 具體好處
發現盲點 找出未被測試的程式碼路徑(例如例外處理、分支條件)。
品質門檻 在 CI 中設定最低覆蓋率(如 80%),自動阻止低品質 PR 合併。
回歸保護 變更程式碼後,若覆蓋率下降,立刻提醒可能的回歸風險。
文件化 覆蓋率報告可作為團隊溝通的依據,讓新加入的成員快速了解測試範圍。

小提醒:高覆蓋率不等於高品質測試,仍須檢視測試的斷言是否足夠。


2. coverage.py 基本工作原理

coverage.py 會在 Python 解譯器執行程式碼時,插入追蹤點,記錄每一行是否被執行過。執行完測試後,產生一份 .coverage 檔案,接著透過 coverage reportcoverage html 產出可讀的報表。

  • 行級別(line)是最常見的測量方式,亦可選擇 分支級別(branch)來追蹤 if/elsetry/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/elsefor/whiletry/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_tododelete_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 預設會把測試本身也算進去,導致數字失真。 .coveragercomit 區段排除 */tests/*
異步測試未正確等待 async 測試若忘記 await,會導致程式碼未真正執行。 使用 pytest-asyncio,並在每個測試函式前加 @pytest.mark.asyncio
環境差異 本機跑測試時覆蓋率高,CI 中卻下降。 確保 CI 使用相同的 Python 版本依賴,並將 .coveragerc 版本化。
過度追蹤第三方套件 追蹤外部套件會讓報告雜訊變多。 .coveragercsource 設定只針對自家套件目錄(如 app/)。

最佳實踐清單

  1. 在專案根目錄加入 .coveragerc,明確排除不需要的檔案。
  2. 結合 CI(GitHub Actions、GitLab CI)自動執行 coverage run && coverage xml && coverage report --fail-under=XX
  3. 使用分支覆蓋,尤其在 try/exceptif/elif/else 等錯誤處理路徑。
  4. 每次新增功能或 bugfix 時,先撰寫測試,再執行 coverage 確認新路徑已被覆蓋。
  5. 定期檢視 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% 的遺留系統,可採 分階段 方法:

  1. 先跑一次 coverage,產出 HTML 報告,找出最「紅」的模組。
  2. 優先為這些模組寫測試,目標先提升到 70%。
  3. 設定 CI 失敗門檻 為 70%,在每次迭代中持續提升。

此策略讓團隊在不一次性投入大量資源的情況下,逐步提升測試品質。


總結

  • 測試覆蓋率 是衡量程式碼測試完整性的量化指標,對 FastAPI 這類以路由與異步處理為核心的框架尤為重要。
  • 使用 coverage.py 搭配 .coveragerc,可以靈活控制要追蹤的範圍、分支覆蓋與排除規則。
  • 透過 CLIcoverage runcoverage reportcoverage html)與 CI 整合,讓測試品質成為自動化流程的一部份。
  • 高覆蓋率不等於高品質測試,仍需檢視斷言的完整性與邊界條件。
  • 在實務上,將 覆蓋率門檻分支測試HTML 報告檢視 結合,可有效降低上線後的錯誤率,提升團隊開發效率與產品穩定性。

最後一句話:把 coverage 當作每日開發的「健康檢查」儀表板,讓程式碼的每一次變更都在可視化的品質指標下前進,才能真正做到「測試驅動」的高品質 FastAPI 開發。