Skip to content

第 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 分布式支持(多进程甚至远程解释器下依然准确)。

bash
pip install pytest-cov
pytest --cov=mypkg tests/

输出形如:

text
---------- 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 报告格式与"缺哪行"定位

三种最常用的报告形态:

bash
pytest --cov=src --cov-report=term-missing     # 终端表格 + 缺失行号列
pytest --cov=src --cov-report=html             # 生成 htmlcov/ 目录,浏览器逐行查看
pytest --cov=src --cov-report=xml              # CI 工具消费的机器可读格式

term-missing 的关键在 Missing 列——它直接告诉你哪些语句没被执行:

text
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 会额外检查每个分支的两个方向是否都走过

python
def discount(price: float, vip: bool) -> float:
    if vip:
        price *= 0.8
    return price
python
# content of test_cart.py
from cart import discount


def test_vip_discount():
    assert discount(100, vip=True) == 80.0    # 只走了 if 为真的一侧

不加 --cov-branch 时这个文件覆盖率已是 100%;加上后:

text
Name          Stmts   Miss Branch BrPart   Cover   Missing
----------------------------------------------------------
cart.py           4      0      2      1      88%   4->5

4->5 表示"第 4 行为假跳到第 5 行"这条边从未发生——即 vip=False 的路径没人测过。工程上建议把分支覆盖率作为默认口径。

13.4 配置文件化

覆盖率选项可以全部搬进配置,命令行保持干净:

toml
# content of pyproject.toml —— pytest 侧启用
[tool.pytest.ini_options]
addopts = ["--cov=src", "--cov-branch", "--cov-report=term-missing"]
toml
# 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__ 块写无意义测试凑数:

python
# content of cli.py —— 配合 exclude_lines 的典型样板
def main():
    ...


if __name__ == "__main__":      # 已在 exclude_lines 中排除,不计入缺失
    main()

对确实无法稳定触发的防御性分支,也可以就地打标:

python
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 注入门禁,避免干扰开发体验:

yaml
# .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——覆盖率是必要条件而非充分条件。

看一个触目惊心的反例——覆盖率满分,质量为零:

python
# ❌ 这两个测试让 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. 下列哪种情况说明覆盖率指标被误用了?

🛠️ 动手实践

  1. 给第 10 章 mock 过的 get_json 函数跑一次 --cov --cov-branch --cov-report=term-missing,找出未覆盖的异常处理分支并补一个触发它的测试。
  2. pyproject.toml 中配置 [tool.coverage.report] exclude_lines,验证 if __name__ == "__main__": 块不再计入缺失。
  3. 搭一个最小 GitHub Actions 工作流(或本地脚本模拟):用环境变量注入 --cov-fail-under=当前值-5 并验证门禁生效(故意删掉一个测试观察退出码)。

下一章解决"测试太多跑得慢"的问题——pytest-xdist 多进程并行与分布式策略:第 14 章