「覆盖率 90%」是一个很容易被误读的数字。它到底测的是什么、什么情况下会骗你?这篇用一个 7 行的函数和 5 组对照实验,把覆盖率能证明和不能证明的事分开。
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
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%,比前一个低。覆盖率衡量代码是否被执行,不衡量结果是否被检查。用它找「没测到的地方」是有效的(读数低说明肯定没测),用它证明「测好了」是无效的。
$ 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,否则漏测的文件会「隐形」。
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):开销正比于「第一次见到多少不同的行」,而不是执行了多少次。所以覆盖率对「大量各不相同的代码、每样跑一次」的测试(例如大量小测试)拖累最明显。
fail_under 门槛、多进程 / subprocess 覆盖(需要额外配置)、sys.monitoring 与旧追踪器(sys.settrace)在开销上的差别