第 13 章 · 覆盖率与质量门禁
本章目标:用 pytest-cov 生成并读懂行/分支覆盖率报告,理解"高覆盖率 ≠ 高质量",并在 CI 中落地
--cov-fail-under质量门禁。
13.1 pytest-cov 是什么、为什么不用 coverage run
pytest-cov 把 coverage.py 的测量能力以插件形式接入 pytest。相比裸跑 coverage run -m pytest,官方 README 列出的增量价值包括:自动清理与合并 .coverage 数据文件并出默认报告、支持按测试上下文记录(--cov-context=test 可精确到参数化用例名)、以及完整的 xdist 分布式支持(多进程甚至远程解释器下依然准确)。
pip install pytest-cov
pytest --cov=mypkg tests/输出形如:
---------- coverage: platform linux, python 3.x ----------
Name Stmts Miss Cover
---------------------------------------
mypkg/__init__.py 2 0 100%
mypkg/core.py 257 13 94%
---------------------------------------
TOTAL 353 20 94%数据文件 .coverage 在每轮开始时自动清空;需要多轮累加时加 --cov-append。
13.2 报告格式与"缺哪行"定位
三种最常用的报告形态:
pytest --cov=src --cov-report=term-missing # 终端表格 + 缺失行号列
pytest --cov=src --cov-report=html # 生成 htmlcov/ 目录,浏览器逐行查看
pytest --cov=src --cov-report=xml # CI 工具消费的机器可读格式term-missing 的关键在 Missing 列——它直接告诉你哪些语句没被执行:
Name Stmts Miss Cover Missing
-------------------------------------------------
src/cart.py 42 6 86% 31-33, 47, 52-54读法示例:31–33 行整段未覆盖(可能是一个没测的分支),47 行单独一行未覆盖(可能是异常路径)。配合 --cov-report=html 打开 htmlcov/index.html 可以逐行高亮查看,是补测试前最直观的侦察手段。
13.3 分支覆盖率:比行覆盖率更严格
只统计"某行执行过没有"会漏掉半成品测试。开启 --cov-branch 后,coverage 会额外检查每个分支的两个方向是否都走过:
def discount(price: float, vip: bool) -> float:
if vip:
price *= 0.8
return price# content of test_cart.py
from cart import discount
def test_vip_discount():
assert discount(100, vip=True) == 80.0 # 只走了 if 为真的一侧不加 --cov-branch 时这个文件覆盖率已是 100%;加上后:
Name Stmts Miss Branch BrPart Cover Missing
----------------------------------------------------------
cart.py 4 0 2 1 88% 4->54->5 表示"第 4 行为假跳到第 5 行"这条边从未发生——即 vip=False 的路径没人测过。工程上建议把分支覆盖率作为默认口径。
13.4 配置文件化
覆盖率选项可以全部搬进配置,命令行保持干净:
# content of pyproject.toml —— pytest 侧启用
[tool.pytest.ini_options]
addopts = ["--cov=src", "--cov-branch", "--cov-report=term-missing"]# coverage 自身的精细配置(coverage 读同一份 pyproject.toml)
[tool.coverage.run]
source = ["src"]
[tool.coverage.report]
show_missing = true
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
'if __name__ == "__main__":',
]exclude_lines 用于排除"不需要测"的惯用行,避免为 __main__ 块写无意义测试凑数:
# content of cli.py —— 配合 exclude_lines 的典型样板
def main():
...
if __name__ == "__main__": # 已在 exclude_lines 中排除,不计入缺失
main()对确实无法稳定触发的防御性分支,也可以就地打标:
def parse_port(raw: str) -> int:
port = int(raw)
if not 0 < port < 65536: # pragma: no cover —— 上游已校验,防御性分支
raise ValueError("port out of range")
return port升级注意
pytest-cov 7 起移除了旧的 .pth 子进程注入机制;若要测量子进程内的代码,改用 coverage 的 patch 相关配置项(如 [run] patch = subprocess)。
13.5 CI 质量门禁实战
门禁的核心是 --cov-fail-under=N:低于阈值时 pytest 以非零码退出,流水线直接失败。推荐做法是本地不设门禁、CI 注入门禁,避免干扰开发体验:
# .github/workflows/test.yml
name: test
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: {python-version: "3.12"}
- run: pip install -e ".[test]"
# 门禁选项通过环境变量注入,仓库内 pyproject.toml 不含 fail_under
- run: pytest --cov=src --cov-fail-under=85
env:
PYTEST_ADDOPTS: "--cov-report=xml"阈值怎么定?两个原则:
- 存量项目从现状起步:先跑一次拿到真实数字(比如 62%),设成
fail-under=60防倒退,再逐步上调; - 不要盲目追求 100%:覆盖率衡量"测试摸到了哪些代码",不衡量断言质量。100% 行覆盖但零有效断言的套件照样放过 bug——覆盖率是必要条件而非充分条件。
看一个触目惊心的反例——覆盖率满分,质量为零:
# ❌ 这两个测试让 discount 的行覆盖率达到 100%,但什么都没验证
def test_discount_vip():
discount(100, vip=True) # 调用了,没断言
def test_discount_normal():
discount(100, vip=False)如果哪天有人把折扣从 8 折改成 9 折甚至删掉折扣逻辑,这两个测试依然全绿。所以团队规范里应该同时要求:覆盖率门禁 + 代码评审关注断言有效性。
13.6 本章小结
- pytest-cov 相对裸 coverage run 的优势:自动清理合并、test context、xdist 支持;
term-missing/html/xml三种报告分别服务"快速定位 / 人工审查 / CI 消费";--cov-branch揭示"只测了单边分支"的盲区,建议作为默认口径;- 门禁 =
--cov-fail-under+ CI 非零退出码;阈值应基于现状渐进收紧,且永远记住覆盖率不是质量本身。
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. 想在终端报告里直接看到"哪些行没被测到",应使用哪个选项?
2. 某函数含 if/else 两分支,但测试只触发了条件为真的路径。行覆盖率 100%,如何让这种盲区现形?
3. 关于 --cov-fail-under,说法正确的是?
4. 下列哪种情况说明覆盖率指标被误用了?
🛠️ 动手实践
- 给第 10 章 mock 过的
get_json函数跑一次--cov --cov-branch --cov-report=term-missing,找出未覆盖的异常处理分支并补一个触发它的测试。 - 在
pyproject.toml中配置[tool.coverage.report] exclude_lines,验证if __name__ == "__main__":块不再计入缺失。 - 搭一个最小 GitHub Actions 工作流(或本地脚本模拟):用环境变量注入
--cov-fail-under=当前值-5并验证门禁生效(故意删掉一个测试观察退出码)。
下一章解决"测试太多跑得慢"的问题——pytest-xdist 多进程并行与分布式策略:第 14 章。