线上事故复盘会开到第四十分钟,卡在『这块逻辑没测到』这句话上。有人把当期的质量报告翻出来投屏,念出声:『行覆盖率百分之九十多,怎么会没测到?』会议室安静了几秒。写报告的人心里清楚:报告没撒谎。它没说的是——那个九十多个百分点,和『出事的那个分支有没有被测到』,回答的根本是两道题。覆盖率这个数字天生带着三层分母:行、分支、需求。报告里只写一个数,三层就在互相打掩护。
一、行覆盖:分母最容易虚胖——新零件太多,读数好看
结论:生成代码、协议桩、ORM样板会同时进分子和分母,而且它们的覆盖率天生高——样板越重复、分支越少,越容易被跑得『焕然一新』。
拿本文的演示工程说:全项目 700 行可执行语句里,500 行来自两个生成文件,覆盖率一个 98%、一个 99%。按全量分母报,行覆盖 93.4%;把生成代码剔掉、只留手写业务代码重算,剩 81.0%。同一次测试运行,同一份结果,唯一的区别是分母。十几个百分点的虚高,藏不住一个『完全没测』的大模块,但它藏得掉一个致命分支——复盘会上『没测到』的那一行,多半落在手写层那不到两成的窟窿里,而不是落在生成层那百分之一出头的死角里。
审计动作就一条:按文件路径模式分组,剔除生成代码重算,两个数并排进报告。识别规则用路径和命名特征(比如 _gen、pb/stub),别用『文件头有没有一行自动生成注释』——注释会被删,路径不会说谎。
分母里还有一种『沉默住户』:不可达分支。永远不会命中的防御代码、被历史需求遗留下的死逻辑,都算在可执行行里——它们压低覆盖率,你又无法为它们写用例,最后留给写报告人的只有『忽略』或者『撒谎』两个选项。所以行层的完整审计是两件事:把撑分母的样板切掉,把写不到用例的不可达行按豁免注释的方式标出来、在报告里明说。分母诚实了,分子的成色才有得谈。
二、分支覆盖:逻辑哨兵,但框架的『分支』和业务的『组合』差着一个量级
结论:分支覆盖是三层里真正的逻辑哨兵,可它的格数由框架说了算,风险却由业务说了算——条件越多、嵌套越深,两者差得越离谱。
本文演示里放了一个函数:if is_vip and within_7d and amount_cent < 50000,一个 if,覆盖工具记 2 个分支;而业务真值表是会员与否、是否七天内、金额三档,2×2×3 共 12 种条件组合。跑通『会员+没过期+低于500元』一条用例,框架就能给这个 if 打满勾;可『非会员被误开退款』『金额恰好等于边界』这些组合,可能从来没被执行过。演示工程手写层的分支覆盖 64.3%,比它的行覆盖低了将近十七个点——这个差值本身就是条件组合黑洞的藏身处。
审计动作:对高风险函数列真值表、按组合逐格生成参数化用例,pytest 的 @pytest.mark.parametrize 就是干这个的。用例条数会涨,但每一条都对着一句业务含义;涨出来的那部分,恰好是过去『分支全打满勾』掩护下的空位。
最阴的组合是边界格:演示函数里 12 种组合只有『会员+7天内+低于500元』一种返回 True,金额恰好等于 50000 分的那格与超出 1 分的那格,恰恰是业务争议最多的地方——『低于500元』含不含 500?差的那 1 分算通过还是算拒绝?框架对边界的语义毫无知觉,它只看 if 走没走过。列真值表的价值,就是把『边界的语义』逼成一行行写得出名字的测试用例,而不是留在注释里当口头禅。
三、需求覆盖:分母最诚实的一层,也是报告里最常被省掉的一层
结论:需求覆盖的分母是本次迭代的需求条目,不是任何一行代码——它不看你跑得多干净,只看你承诺的业务面接住了几块。
演示工程六个需求条目,四条有用例闭环,需求覆盖 4-of-6;没盖住的两条,『并发下券返还』和『金额溢出边界』,跟开头复盘会上出事的那类逻辑恰好同型。这一层最诚实,也最容易被省:没有任何工具能自动算出它,得靠用例设计期一条一条维护需求×用例映射表。这动作你不陌生——接口测试里听到『用例数翻倍』,第一反应是先问分母是需求条目还是参数组合。同一条肌肉,用到覆盖率报告上一样好使。
顺便给映射表防『形式化』的一招:需求编号写进用例命名或 pytest 的 marker 里,让流水线每个迭代自动检查『每个 REQ 是否至少有一条对应用例且通过』,映射断链当场挂旗;需求被砍时同步删映射行——否则分母虚减,比例会从 4-of-6 一夜之间『进步』成 4-of-4。
| 分母层 | 虚高/掩护机制 | 审计动作 |
|---|---|---|
| 行覆盖 | 生成代码与样板同进分子分母;不可达分支还悄悄占着分母 | 按文件模式剔除后重算,全量与手写两个数并排 |
| 分支覆盖 | 框架分支是语法单位,远少于业务条件组合;生成层分支几乎全绿 | 看手写层分支数;高风险函数列真值表出参数化用例 |
| 需求覆盖 | 不会虚高,但最容易被整层省掉——工具算不出,全靠人维护映射 | 需求×用例映射表进报告,缺项显式列条目与负责人 |
| 报告里该出现的行 | 演示值 | 分母是什么 | 这个数替谁掩护 |
|---|---|---|---|
| 行覆盖(全量) | 93.4% | 含生成代码的全部可执行行 | 替工具掩护:读数漂亮,含义最少 |
| 行覆盖(仅手写) | 81.0% | 剔除样板后的业务行 | 替你心里的『测得差不多了』验真 |
| 分支覆盖(仅手写) | 64.3% | 框架口径的分支数 | 替逻辑哨兵站岗,差值处即黑洞 |
| 需求覆盖 | 4-of-6 | 本次迭代需求条目 | 谁也不替:缺的就是没接住的承诺 |
三行数字并排放进报告,不许合并、不许加权、不许挑最好看的那个报。
四、跑一遍:纯标准库把三个数重算出来
结论:真实项目里 coverage run -m pytest && coverage json 就有原料;下面脚本按 coverage.py 同款的字段口径构造了一份样例,不装任何第三方库也能把三层分母的差距原样跑出来,接自家工程时把 DEMO 换成读 json、对齐字段路径即可。
# -*- coding: utf-8 -*-
"""coverage_denominator.py —— 三层分母重算覆盖率(纯标准库)
真实场景:coverage run -m pytest && coverage json,读 coverage.json。
本演示按 coverage.py 同款的 json 字段口径构造了一份样例,保证开箱即跑。
"""
from itertools import product
DEMO = {
"files": {
"src/order.py": {
"statements": 150, "covered": 126, "branches": 40, "covered_branches": 26},
"src/report.py": {
"statements": 50, "covered": 36, "branches": 16, "covered_branches": 10},
"src/models_gen.py": {
"statements": 300, "covered": 294, "branches": 60, "covered_branches": 59},
"src/pb/stub.py": {
"statements": 200, "covered": 198, "branches": 30, "covered_branches": 30},
}
}
GEN_PATTERNS = ("_gen.py", "/pb/", "stub") # 生成代码与协议桩的识别规则,按自家工程调
REQUIREMENTS = {
# 需求覆盖:分母是本次迭代的需求条目,不是任何一行代码
"REQ-01 按仓拆单": True, "REQ-02 优惠券叠加": True, "REQ-03 对账定时任务": True,
"REQ-04 并发下券返还": False, "REQ-05 物流文案": True, "REQ-06 金额溢出边界": False,
}
def is_generated(path):
return any(p in path for p in GEN_PATTERNS)
def rollup(files):
keys = ("statements", "covered", "branches", "covered_branches")
return {
k: sum(f[k] for f in files) for k in keys}
def pct(part, whole):
return f"{part / whole * 100:.1f}%" if whole else "n/a"
def refund_eligible(is_vip, within_7d, amount_cent):
# 框架视角:一个 if,统计到 2 个分支
if is_vip and within_7d and amount_cent < 50000:
return True
return False
def main():
all_files = list(DEMO["files"].values())
hand = [f for p, f in DEMO["files"].items() if not is_generated(p)]
a, h = rollup(all_files), rollup(hand)
print("【第一层·行】全量分母:", pct(a["covered"], a["statements"]),
"|剔除生成代码后:", pct(h["covered"], h["statements"]))
print("【第二层·分支】全量:", pct(a["covered_branches"], a["branches"]),
"|手写代码:", pct(h["covered_branches"], h["branches"]))
hit = sum(REQUIREMENTS.values())
miss = "、".join(k for k, v in REQUIREMENTS.items() if not v)
print("【第三层·需求】", f"{hit}/{len(REQUIREMENTS)} =", pct(hit, len(REQUIREMENTS)), f"(未覆盖:{miss})")
# 框架分支数 vs 业务条件组合数:一个量级差
bands = ["<500", "=500", ">500"]
combos = list(product([False, True], [False, True], bands))
print(f"\nrefund_eligible 一个 if:框架报 2 个分支;业务真值表 {len(combos)} 种条件组合。")
print("按真值表逐格生成参数化用例:")
for i, (vip, d7, band) in enumerate(combos, 1):
amount = {
"<500": 49999, "=500": 50000, ">500": 50001}[band]
print(f" case{i}: vip={vip} within7d={d7} amount={amount} -> "
f"{refund_eligible(vip, d7, amount)}")
if __name__ == "__main__":
main()
脚本里有两个坑值得点名。其一,GEN_PATTERNS 是要养着的:代码生成器每引入一类新样板,分母里就多一批『白送的覆盖』,识别规则不更新,重算出来的数就又悄悄虚胖了。其二,REQUIREMENTS 的 True/False 不是从哪台机器上采出来的,是设计用例时一条条填进去的承诺——它逼着写报告的人承认:覆盖率审计到最后,工具能替你算两层,第三层只能你自己签字。
跑出来的输出就三行加一组用例清单:行层 93.4% 对 81.0%、分支层 85.6% 对 64.3%、需求层 4-of-6,再接 12 格真值表参数——前面几节引用的全部数字,都出自这一次运行。
五、回到复盘会:哪道题用哪个数
结论:三层数字进了报告之后,用法是当场接住提问——数字被引用的那一刻,就要能说清它是哪一层分母算出来的。
下次再有人拿『覆盖率九十多』对上『怎么没测到』,回答应该是三十秒内完成的:那个 93.4% 是行层全量口径,剔掉生成代码后手写层是 81.0%;出事函数的分支层只有 64.3%,而需求层早把『并发下券返还』标了未闭环——差距在报告里是明牌,不是马后炮。这就是三层分母的全部意义:它不能让用例自动变好,但它把『没测到』从一具死无对证的现场,变成一张可对账的清单。
覆盖率不是一个错数字,它是三个太容易混用的数字——报告里只写其中一个,就等于给读者报了个『平均温度』,再让他自己决定穿不穿外套。