Python 單元測試與除錯:測試覆蓋率(Coverage)
簡介
在軟體開發的整個生命週期中,測試是確保程式品質的關鍵環節。即使寫了完整的單元測試,若不清楚測試到底涵蓋了多少程式碼,仍可能留下隱藏的缺陷。這時 測試覆蓋率(coverage)就派上用場:它量化了測試執行時實際觸及的程式碼比例,讓開發者可以直觀地看到哪些路徑尚未被測試。
對初學者而言,了解覆蓋率的概念與工具使用,能養成「測試驅動」的好習慣;對中級開發者來說,則是提升測試品質、減少回歸錯誤的必備技巧。本文將從概念說明、實作範例、常見陷阱到最佳實踐,全面介紹 Python 中的測試覆蓋率。
核心概念
什麼是測試覆蓋率?
測試覆蓋率是指在執行測試套件時,程式碼中被執行(或「走過」)的行數、分支、條件等的比例。常見的衡量指標包括:
| 指標 | 說明 |
|---|---|
| Line Coverage | 被執行的程式碼行數佔總行數的百分比 |
| Branch Coverage | 每個 if/else、try/except 等分支是否皆被測試 |
| Statement Coverage | 每條語句是否被執行 |
| Function/Method Coverage | 每個函式或方法是否被呼叫 |
為什麼要追蹤覆蓋率?
- 發現盲點:自動化測試往往只測試「主要路徑」,忽略邊緣情況。覆蓋率報告能一目了然地指出未被觸及的程式碼。
- 提升信心:在持續整合(CI)流程中加入覆蓋率門檻,可確保每次提交都有基本的測試保護。
- 指導測試設計:根據報告調整測試案例,避免過度或不足的測試。
常用工具:coverage.py
Python 官方推薦的測試覆蓋率工具是 coverage.py。它支援多種執行方式(直接跑腳本、與 pytest 整合、在 CI 中使用),且能產生 HTML、XML、JSON 等多種報表。
安裝方式非常簡單:
pip install coverage
程式碼範例
以下示範如何在不同情境下使用 coverage.py,並說明常見的測試技巧。
範例 1:基本使用 – 產生簡易報表
# file: calculator.py
def add(a, b):
"""回傳兩數相加的結果"""
return a + b
def divide(a, b):
"""除法,b 為 0 時拋出例外"""
if b == 0:
raise ValueError("除數不能為 0")
return a / b
# file: test_calculator.py
import pytest
from calculator import add, divide
def test_add():
assert add(2, 3) == 5
def test_divide_normal():
assert divide(10, 2) == 5
def test_divide_zero():
with pytest.raises(ValueError):
divide(10, 0)
執行測試並收集覆蓋率:
coverage run -m pytest
coverage report -m # 顯示行覆蓋率
輸出示例:
Name Stmts Miss Cover
--------------------------------------
calculator.py 9 0 100%
test_calculator.py 15 0 100%
重點:
-m參數讓coverage直接呼叫pytest,不需要額外的設定檔。
範例 2:產生 HTML 報表,視覺化未覆蓋程式碼
coverage html # 產生 htmlcov 目錄
在瀏覽器開啟 htmlcov/index.html,即可看到每個檔案的彩色標示:
- 綠色:已執行
- 紅色:未執行
- 黃色:部分執行(例如只有
if的一側被測)
透過這種視覺化,開發者可以快速定位「死碼」或缺乏測試的分支。
範例 3:與 pytest-cov 整合,設定最低覆蓋率門檻
pip install pytest-cov
在 pytest 執行時直接加入 --cov 參數:
pytest --cov=calculator --cov-fail-under=90
--cov=calculator:只統計calculator套件的覆蓋率--cov-fail-under=90:若覆蓋率低於 90% 會讓測試失敗,適合 CI 使用
範例 4:測試多分支程式 – Branch Coverage
# file: utils.py
def classify_age(age):
"""根據年齡分類"""
if age < 0:
raise ValueError("年齡不可為負")
if age < 13:
return "兒童"
elif age < 20:
return "青少年"
elif age < 65:
return "成人"
else:
return "長者"
# file: test_utils.py
import pytest
from utils import classify_age
def test_negative():
with pytest.raises(ValueError):
classify_age(-1)
@pytest.mark.parametrize("age,expected", [
(5, "兒童"),
(16, "青少年"),
(30, "成人"),
(70, "長者"),
])
def test_classify(age, expected):
assert classify_age(age) == expected
使用 coverage 的 --branch 參數來檢查分支覆蓋率:
coverage run --branch -m pytest
coverage report -m
報表會顯示每行的 branch 數字,例如 4 2 50% 表示第 4 行有兩個分支,其中只有 50% 被測。
範例 5:排除不需要測試的程式碼(如 __init__.py、設定檔)
在專案根目錄建立 .coveragerc:
# .coveragerc
[run]
omit =
*/tests/*
*/__init__.py
setup.py
執行 coverage run -m pytest 時,coverage 會自動忽略上述檔案,讓報表更聚焦於業務邏輯。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 最佳實踐 |
|---|---|---|
| 只看總覆蓋率 | 總覆蓋率高(如 95%)不代表所有重要分支都被測。 | 針對 branch 與 condition 也設定門檻,尤其是錯誤處理路徑。 |
| 忽略例外情況 | 測試往往只驗證「正常」流程,導致 except 分支未被執行。 |
使用 pytest.raises、parameterize 測試各種例外。 |
| 過度依賴覆蓋率 | 100% 覆蓋率不代表測試品質好,仍可能缺乏斷言或測試資料不完整。 | 確保每個測試都有 assert,且涵蓋不同的輸入組合。 |
| 未排除第三方套件 | 直接測量整個環境會把外部套件的程式碼算進覆蓋率,結果失真。 | 在 .coveragerc 中使用 source= 或 omit= 只測量自家模組。 |
| 忽略 CI 整合 | 手動執行覆蓋率報表不會自動阻止錯誤併入主幹。 | 在 GitHub Actions、GitLab CI 等加入 coverage 步驟,並設定失敗門檻。 |
最佳實踐總結:
- 從一開始就加入測試,避免後期大幅補測。
- 使用
pytest-cov,在開發階段即看到即時覆蓋率。 - 設定合理門檻(如 line ≥ 80%、branch ≥ 70%),並隨專案成熟度逐步提升。
- 排除非業務程式碼,讓報表聚焦於真正需要保護的邏輯。
- 持續監控:在 CI 中產生 HTML 報表或使用
codecov.io、coveralls.io讓團隊即時看到趨勢。
實際應用場景
- 新功能開發:在實作新函式前,先寫測試案例;完成後跑
coverage確認所有分支都有測試。 - Bug 修復:根據缺陷回報,寫出能觸發錯誤的測試,確保未來不會再次出現同樣問題。
- 遺留系統重構:先以
coverage為基礎,找出未被測的老舊程式碼,逐步為其補上測試,再進行重構。 - CI/CD 流程:在 Pull Request 檢查階段加入
coverage步驟,若未達門檻即阻止合併,提升主幹品質。 - 安全敏感模組:對加密、權限檢查等關鍵模組設定更高的分支覆蓋率門檻(如 90%+),降低安全漏洞風險。
總結
測試覆蓋率是 量化測試完整性 的有效工具,配合 coverage.py、pytest-cov 等生態系統,開發者可以輕鬆掌握程式碼被測程度。
- 了解 Line、Branch、Function 等指標的差異,才能針對不同需求設定合適的門檻。
- 透過 HTML 報表、CI 整合 與
.coveragerc排除不相關檔案,讓報表更具可讀性。 - 警惕「只看總數」的陷阱,強調 斷言、例外測試 與 多分支覆蓋,才能真正提升程式品質。
把測試覆蓋率當作每日開發的「健康檢查」儀表板,長期下來不僅能減少回歸錯誤,還能培養團隊的測試文化,讓 Python 專案更穩健、更易維護。祝你在測試之路上玩得開心,寫出更可靠的程式!