bigywater.tech
Python · 100

coverage.py:覆盖率数字到底说明了什么

「覆盖率 90%」是一个很容易被误读的数字。它到底测的是什么、什么情况下会骗你?这篇用一个 7 行的函数和 5 组对照实验,把覆盖率能证明和不能证明的事分开。

100% vs 82%
同一个只调用不断言的测试:行覆盖与行+分支覆盖的读数
-4
被「100% 覆盖」的函数里,一直没人发现的 bug 的结果
100% → 73%
把没被导入的文件也算进来(--source=.)后的总覆盖率

做的事:一个函数,几种测试

shop.py def price_with_discount(price, qty, is_member): total = price * qty if qty >= 10: total = total * 0.9 if is_member: total = total - 5 # 没有下限! return total
test_shop_noassert.py def test_runs(): price_with_discount(100, 10, True) # 只是调用, 什么也不断言
实测 · 本机执行输出
coverage 版本
coverage Coverage.py, version 7.16.2 with C extension

验证一:行覆盖 100%,分支覆盖 82%

同一个测试的两种读数
实测 · 本机执行输出
pytest 9.1.1
$ coverage report -m --include=shop.py  (行覆盖 + 分支覆盖)
Name      Stmts   Miss Branch BrPart  Cover   Missing
-----------------------------------------------------
shop.py       7      0      4      2    82%   3->5, 5->7
-----------------------------------------------------
TOTAL         7      0      4      2    82%
  不开分支覆盖(只算行)时的同一个测试:
  Name      Stmts   Miss  Cover   Missing
  ---------------------------------------
  shop.py       7      0   100%
  ---------------------------------------
  TOTAL         7      0   100%

这个测试只调用了一次,但每一行都执行到了:行覆盖 100%。打开 --branch 之后同一批测试变成 82%,Missing 列指出 3->5 和 5->7 两个分支从未发生——也就是「数量不到 10」和「不是会员」这两种情况从没被走过。行覆盖看不到「if 条件为假」这条路,分支覆盖能。

验证二:覆盖率高,不等于测过

实测 · 本机执行输出
  bug: 会员减 5 元不设下限, 小额订单会变成负数。price_with_discount(1, 1, True) = -4
  只调用不断言的测试(上面的 test_shop_noassert.py, 覆盖了每一行): 1 passed in 0.07s
  一条真正在断言业务规则的测试(价格不应为负): 1 failed in 0.08s
  这条能发现 bug 的测试自己的覆盖率:
    Name      Stmts   Miss Branch BrPart  Cover   Missing
    -----------------------------------------------------
    shop.py       7      1      4      2    73%   4, 5->7
    -----------------------------------------------------
    TOTAL         7      1      4      2    73%

函数里的 bug 是会员减 5 元没有下限,一个 1 元订单会变成 -4 元。那个「100% 行覆盖」的测试通过了——它没有断言,也不可能失败。换成一条断言业务规则的测试(价格不能为负),立刻失败,bug 被发现;而这条测试自己的覆盖率只有 73%,比前一个低。覆盖率衡量代码是否被执行,不衡量结果是否被检查。用它找「没测到的地方」是有效的(读数低说明肯定没测),用它证明「测好了」是无效的。

验证三:没被 import 的文件,默认不在报告里

实测 · 本机执行输出
$ coverage report   (默认: 只统计被导入过的文件)
Name      Stmts   Miss Branch BrPart  Cover
-------------------------------------------
shop.py       7      0      4      0   100%
-------------------------------------------
TOTAL         7      0      4      0   100%
$ coverage run --source=. ...; coverage report   (把整个目录都算进来)
Name          Stmts   Miss Branch BrPart  Cover
-----------------------------------------------
shop.py           7      0      4      0   100%
untested.py       4      4      0      0     0%
-----------------------------------------------
TOTAL            11      4      4      0    73%

默认报告只统计「测试运行期间被导入过」的文件。untested.py 里 4 行代码没有任何测试碰过,但它根本不在报告里,总覆盖率看起来是 100%。加上 --source=. 让 coverage 主动去找目录里的所有文件,它才以 0% 出现,总覆盖率掉到 73%。所以 CI 里的覆盖率门槛,要配上 source,否则漏测的文件会「隐形」。

验证四:# pragma: no cover

实测 · 本机执行输出
Name       Stmts   Miss Branch BrPart  Cover   Missing
------------------------------------------------------
shop2.py       3      0      0      0   100%
------------------------------------------------------
TOTAL          3      0      0      0   100%

在 else: 那一行加上 # pragma: no cover,那个分支就从统计里整个消失(分支数是 0),即使它从来没被执行过,报告依然是 100%。用它排除「防御性代码」是常见做法,但滥用就会让数字失真——review 时值得留意。

验证五:开销

实测 · 本机执行输出
  同一批代码反复执行(200 万次循环, 只有几行不同的代码):
     不开覆盖率    68.7 ms | 行覆盖    68.4 ms (1.00x) | 行+分支覆盖    67.4 ms (0.98x)   (各 7 次取最小值)
  3 万行不同的代码各执行一次:
     不开覆盖率     0.1 ms | 行覆盖     5.4 ms (54.00x) | 行+分支覆盖    14.5 ms (145.00x)   (各 7 次取最小值)

同一批代码反复执行(200 万次循环)时,覆盖率几乎没有额外开销(1.00x)——(我的推测是已经记录过的行不必再被追踪,这一点没有验证)。相反,3 万行不同的代码各执行一次时,行覆盖是 54x、行+分支覆盖是 145x(基线只有 0.1 ms,绝对值是 5.4 与 14.5 ms):开销正比于「第一次见到多少不同的行」,而不是执行了多少次。所以覆盖率对「大量各不相同的代码、每样跑一次」的测试(例如大量小测试)拖累最明显。

分步演示:同一个函数,覆盖率一步步「说」了什么

所有数字取自上面的真实输出
覆盖率的含义与边界
当前观察

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7,coverage 7.16.2(C 扩展)、pytest 9.1.1;每项开销取 7 次最小值
    • 没有验证:HTML/XML 报告、fail_under 门槛、多进程 / subprocess 覆盖(需要额外配置)、sys.monitoring 与旧追踪器(sys.settrace)在开销上的差别
    京ICP备2026067502号-1京公网安备11010502063487号