很多人刚开始学软件测试时,对“覆盖率”三个字的第一反应是:覆盖率越高,测试质量越好。这个想法方向没错,但很容易让一个人走进死胡同——因为只看百分比,根本说不清楚“缺口到底在哪里”。真正把测试思路打通、让我知道下一步该测什么的,是学习图覆盖那段日子。图覆盖不是某个工具一键生成的饼图,而是一套描述测试充分性的框架。软件测试里只要谈到白盒测试、结构测试、覆盖准则,几乎都会绕到图覆盖这套逻辑上来。
这篇是软件测试学习笔记的第二篇,我会用尽量少的公式、尽量多的例子,把节点覆盖、边覆盖、边对覆盖、主路径覆盖这些基础概念讲清楚,再延伸到数据流覆盖,最后聊一聊实际工作中怎么选覆盖准则,以及初学阶段最容易踩的坑。不管你是刚开始背软件测试面试题,还是已经写过一阵子自动化用例、正纠结“用例到底够不够”,这篇都适合慢慢读。
1. 为什么要学“图覆盖”,它和普通覆盖率差在哪
1.1 覆盖率最常见的误区:行覆盖过了不等于测透了
很多团队会在项目里接覆盖率工具,比如 Java 项目的 JaCoCo、Python 项目的 Coverage.py、前端项目的 Istanbul。工具报告里最显眼的通常是“行覆盖率”(line coverage)和“分支覆盖率”(branch coverage)。我之前见过一个团队,某个模块的行覆盖率做到了 95%,大家对质量信心很足,结果上线后出了一个低级 bug:一个elif分支走进去后变量没有按要求更新。
问题出在哪?看下面这段伪代码:
def check_grade(score): if score >= 90: return "A" elif score >= 60: return "B" else: return "C"如果测试用例只测了 98 分和 50 分,行覆盖率其实已经非常高了——每一行都有用例踩过。但score >= 60为真的那个分支呢?没测过。这个分支对应的边,在图上是独立的、有方向的,它没有被走到。
所以“哪些代码被执拗”和“哪些路径被验证”,其实是两个问题。图覆盖要解决的,正是第二个问题:如何把程序变成一张图,再基于这张图定义出“测试需求”,然后用最少的用例去覆盖这些需求。
1.2 程序一旦变成图,测试需求就精确了
图覆盖里的“图”,早期主要指的是控制流图,也就是把代码的执行顺序抽象成节点和有向边。但“图”这个概念本身还能延伸到很多场景:接口测试里可以把每个接口当作节点,调用关系当作边;状态机测试里可以把界面或协议状态当作节点,事件当作边;甚至模块依赖关系也能画成一张有向图。
学图覆盖时先抓住控制流图就够了,因为它是其他图的简化版,也是最容易手工推导的一张图。掌握它的构造方法后,你会发现很多覆盖准则其实是通用的:节点覆盖、边覆盖、边对覆盖、主路径覆盖,这些名字可以套用到任何有向图上。这也是为什么面试官常拿图覆盖来考白盒测试——它不仅仅是代码覆盖率的理论包装,而是一个可以跨场景复用的标准框架。
2. 从代码到图:控制流图的构造方法
2.1 切分基本块的三条硬规则
控制流图不会为每一行代码单独建一个节点,否则图会又大又碎。它的基本单元是“基本块”(basic block)。一个基本块是一段顺序执行的语句,只有一个人口、一个出口。构造控制流图时,我用三条规则来切分:
- 连续的顺序语句合并成一个基本块;
- 遇到条件跳转(if、while、switch、三元表达式等)时,把当前块截断,条件判断单独开一个节点;
- 遇到可能有异常抛出的地方、函数调用返回点、循环回边等,也要考虑是否需要截断块。
之所以要用“基本块”而不是“一行代码一个节点”,是因为覆盖率计算的本质是判断路径,而不是统计单条语句有没有执行。把一连串不可能分支的顺序语句合并成一个节点,图的规模会小很多,测试需求也更清晰。
2.2 用一个三数求最大值函数完整构图
我拿一个非常简单的例子走一遍,哪怕是零基础也能跟上。
def max_of_three(a, b, c): max_val = a if b > max_val: max_val = b if c > max_val: max_val = c return max_val第一步:把顺序语句和分支条件拆成节点。这个函数可以拆成 6 个节点:
| 节点编号 | 节点内容 |
|---|---|
| n0 | max_val = a |
| n1 | if b > max_val |
| n2 | max_val = b |
| n3 | if c > max_val |
| n4 | max_val = c |
| n5 | return max_val |
第二步:按执行流向连边。从一个基本块出发,如果有条件判断,就会分出两条边。这个图的边集合是:
n0 → n1 n1 → n2 (b > max_val 为真) n1 → n3 (b > max_val 为假) n2 → n3 n3 → n4 (c > max_val 为真) n3 → n5 (c > max_val 为假) n4 → n5这里要特别说一个细节:b > max_val为假时,代码会跳到第二个if,所以n1的假分支直接连到n3。很多初学者画图时容易把这个假分支连到别的地方,导致后面的测试路径计算全错。
2.3 测试路径到底是什么
有了图和入口节点、出口节点之后,“测试路径”就定义了:一条从入口节点到出口节点的节点序列,其中相邻节点之间必须存在图中的边。
在这个例子里,所有测试路径有 4 条:
- n0 → n1 → n2 → n3 → n4 → n5
- n0 → n1 → n2 → n3 → n5
- n0 → n1 → n3 → n4 → n5
- n0 → n1 → n3 → n5
这 4 条路径分别对应 4 种真实的程序行为:比如第 1 条是b > a且c > 新最大值,第 4 条是两个条件都不成立。
到这里,“图覆盖”的核心思想已经浮现了:既然所有可执行的路径都在这张图上,覆盖准则就是“你打算选哪些路径作为测试需求”。选得越严格,覆盖得越充分,但需要设计的测试用例也越多。
3. 基础三连:节点覆盖、边覆盖、边对覆盖
3.1 节点覆盖:最省事的入场券
节点覆盖,英文叫 Node Coverage,简称 NC。它的测试需求是:图上所有可达节点至少被一条测试路径覆盖一次。
回到max_of_three的例子。如果只要求节点覆盖,一个测试用例就够了:传(a=1, b=2, c=3),程序一路走 n0 → n1 → n2 → n3 → n4 → n5,6 个节点全被踩到。
节点覆盖是不是太弱了?是的。它只能保证“每个基本块都执行过”,但完全不管分支方向。比如条件判断里的真分支和假分支,节点覆盖根本不区分,因为节点只要执行到一次就算覆盖。这会导致什么后果?前面check_grade的例子就是很好的说明:score >= 60这个条件本身在图上就是一个节点,执行到它并不等于执行了“真分支”和“假分支”两条边。
所以在实际项目中,纯节点覆盖一般只用来做冒烟测试的底线,它最大的优点是成本低、极容易达到,最大缺点是真的会漏 bug。
3.2 边覆盖:把每个分支方向都踩到
边覆盖,Edge Coverage,简称 EC。它对测试需求的定义是:图上每条边至少被一条测试路径覆盖一次。
这个概念天然解决了节点覆盖的盲区。因为只要每条边都走了,每个节点必然也被经过——除非某个节点没有任何入边,但这在正常 CFG 里不会出现。
max_of_three的边覆盖需要几个用例?我试过,两个就够了:
- 用例一:
(a=1, b=2, c=1),走路径 n0 → n1 → n2 → n3 → n5。它覆盖了 n1 的真边、n2→n3、n3 的假边。 - 用例二:
(a=1, b=0, c=3),走路径 n0 → n1 → n3 → n4 → n5。它覆盖了 n1 的假边、n3 的真边。
这两条路径合起来,边集合 7 条边全部覆盖到了。
边覆盖很符合测试人员的直觉:每一个判断条件的“是”和“否”都被验证过。很多团队的“分支覆盖率”工具,本质上统计的就是边覆盖或近似边覆盖。它是实际使用中最常见、性价比最高的一档。
3.3 边对覆盖:把连续两条边绑定在一起测
边对覆盖,Edge-Pair Coverage,简称 EPC。它的测试需求是:图中每条长度为 2 的路径(即连续两条边)至少被一条测试路径覆盖一次。
为什么要测这么细?因为很多 bug 是两个相邻判断条件组合起来才暴露的。比如“登录失败后重试”与“重试仍然失败”是两个相邻分支,单个分支分别测没问题,但两个分支组合后可能出现锁死逻辑。边覆盖只看单条边,抓不到这种组合。
在max_of_three里,边对覆盖的测试需求就多了。连续两条边的组合有这些:
[n0, n1, n2] [n0, n1, n3] [n1, n2, n3] [n1, n3, n4] [n1, n3, n5] [n2, n3, n4] [n2, n3, n5] [n3, n4, n5]需要设计测试路径让这些组合尽量都出现。我实际凑出来,至少要 3 到 4 个用例。这就体现出测试成本随准则严格程度上升的规律。
3.4 三个准则用一张表记住差别
| 准则 | 测试需求粒度 | max_of_three 需要的用例数 | 直观比喻 |
|---|---|---|---|
| 节点覆盖 NC | 每个节点至少一次 | 1 | 每个房间都看一遍 |
| 边覆盖 EC | 每条边至少一次 | 2 | 每扇门都进出一次 |
| 边对覆盖 EPC | 连续两条边至少一次 | 3-4 | 每段相邻的两扇门都连起来走一次 |
4. 主路径覆盖:循环出现后,测试路径是如何被“收编”的
4.1 全路径覆盖为什么在循环面前不可行
有人可能会想:既然要测充分,那就“所有路径都测一遍”不就行了?这个想法在处理循环时会立刻撞上南墙。
看一个带循环和分支的函数:
def count_positive(lst): count = 0 i = 0 while i < len(lst): if lst[i] > 0: count = count + 1 i = i + 1 return count这个函数的控制流图大致是这样的:
- n0:count = 0; i = 0
- n1:while 条件判断
i < len(lst) - n2:if 条件判断
lst[i] > 0 - n3:count = count + 1
- n4:i = i + 1
- n5:return count
边有:n0→n1;n1 的真边去 n2;n2 的真边去 n3;n2 的假边去 n4;n3→n4;n4 回到 n1;n1 的假边去 n5。
关键问题在于 n4→n1 这条回边。循环体可以执行 0 次、1 次、2 次……理论上可以是无限多次,所以“所有路径”是无限集合。测试需求如果是无限多个,那测试就永远做不完了。全路径覆盖在理论上是完备的,在实践上是不可行、甚至没有意义的。
4.2 简单路径和主路径的判别规则
为了把无限路径压缩成有限的、有代表性的集合,图覆盖理论里引入了两个定义。
简单路径:路径中所有节点都不重复的路径。比如 n0→n1→n5 是一条简单路径;n1→n2→n4→n1 不是简单路径,因为 n1 出现了两次。
主路径:一条简单路径,如果它不能作为另一条更长简单路径的一部分而存在,那它就是主路径。换句话说,它已经“长到头了”,再往前接任何一个节点都会导致重复节点。
主路径覆盖(Prime Path Coverage,简称 PPC)就是要求:图上每一条主路径,至少被一条测试路径覆盖。
这相当于什么?相当于程序员跑代码时不再追求“把无限种循环次数都测完”,而是只关心那些“代表结构特征”的路径:循环入口、循环出口、循环体内每个分支、以及它们之间不重复的组合。
4.3 主路径覆盖如何落到测试用例上
在实际操作中,我不会真的列出所有主路径,那样太累。我一般会结合循环次数来看:循环 0 次、循环 1 次、循环 2 次,以及循环内真假分支的组合。这个“0、1、2”方法本质上就是对主路径覆盖的工程近似。
拿count_positive举例:
- 循环 0 次:传入空列表,路径 n0→n1→n5,验证边界;
- 循环 1 次且
lst[0] > 0:传入[1],路径 n0→n1→n2→n3→n4→n1→n5,验证正值分支; - 循环 1 次且
lst[0] <= 0:传入[-1],路径 n0→n1→n2→n4→n1→n5,验证非正值分支; - 循环 2 次:传入
[1, -1],验证循环回边和不同分支组合的稳定性。
真正要警惕的是“不可达路径”。什么叫不可达?比如一个函数里先出现if x > 10,再出现if x < 5,那么“x 既大于 10 又小于 5”这条路径理论上存在,但实际永远执行不到。主路径覆盖不会自动帮你把这些路径剔除,覆盖率统计工具也不会理解数据之间的约束。
所以我一直建议:当覆盖率没到 100% 时,先别急着补用例。先把没覆盖到的路径拿出来,逐个判断它是真正需要测的行为,还是不可达路径。前者补用例,后者在测试报告里显式标注“不可行/不可达”,理由写清楚。这才是一个测试负责人该有的姿态。
5. 数据流覆盖:图覆盖的第二层玩法
5.1 先把三个词搞清楚:def、use、DU 对
图覆盖不只可以描述“代码执行路径”,还可以描述“数据从产生到使用”的路径。这层玩法叫数据流覆盖。
核心是三个概念:
- def:变量被定义(赋值)的位置;
- use:变量被使用的位置;
- DU 对:某个变量的一个“定义”到某个“使用”的组合。
使用又分两种:一种是在判断条件里使用,叫 p-use(predicate use),比如if y > 100里的y;另一种是在赋值表达式、函数参数、返回值里使用,叫 c-use(computation use),比如z = y + 1里的y。
为什么要区分这两个?因为 p-use 跟边直接相关——判断条件会导向不同分支,而 c-use 通常只影响计算结果,不会改变控制流向。
5.2 数据流覆盖需求:从 def 到 use 的路径
数据流覆盖要求在“定义点”和“使用点”之间找到一条路径,而且这条路径中间不能再次重新定义同一个变量。这样的路径叫 def-clear path。为什么要强调不能重新定义?因为如果中间又给同一个变量赋了值,那之前那个定义根本影响不到后面的使用,测了也白测。
数据流覆盖有三个递增的准则:
- All-Defs:每一个定义至少到达一个使用点;
- All-Uses:每一个定义到达每一个使用点;
- All-DU-Paths:每一个定义到每一个使用点的所有 def-clear 简单路径都覆盖。
我用一个简单的费价格函数来展示:
def calculate(x): y = x * 2 # y 的 def if y > 100: # y 的 p-use y = 100 # y 的第二次 def else: y = y + 1 # y 的 c-use return y # y 的 c-use第一次赋值的y = x * 2,它会流向if y > 100这个 p-use。如果中间没有任何y被重新赋值,那么从 def 到 p-use 的路径是 def-clear 的。但是第二次赋值y = 100和后续的return y之间又是另一组 def-use 关系。
这种分析的价值在于,它能发现普通路径覆盖发现不了的问题。比如某个变量定义了但从未被使用,说明代码可能是死代码;定义到使用的路径上被重新定义,说明前面的定义可能是无效的。图覆盖关注“结构有没有被走到”,数据流覆盖关注“这个数据的生命周期里有没有不被察觉的问题”。
5.3 数据流覆盖在实际测试里的位置
数据流覆盖在白盒测试里常被用来补充单测设计,特别是涉及状态变化的代码,比如登录状态、订单状态、缓存刷新。它的缺点是:分析成本高,手工维护很容易出错。所以我不建议所有代码都上数据流覆盖,更适合用在风险高、状态多的核心模块。
真正在工作中用得更多的,反而是把“接口参数”当作变量来分析:一个接口入参从接口层传到服务层、再到数据库层,中间有没有被意外覆盖、有没有路径导致参数没有生效。这种思路其实就是数据流覆盖在系统测试层面的变体,也是软件测试面试里常被包装成“你如何设计参数测试”的高阶题。
6. 准则那么多,实际项目怎么选:包含关系与取舍
6.1 包含关系:谁包含谁,不是拍脑袋
图覆盖的各个准则之间不是毫无关系,它们存在包含关系。这里的包含是指:如果一个测试路径集合满足某个较强准则的测试需求,那么它往往也会满足较弱准则的测试需求。
常见的包含关系可以这样表示:
节点覆盖 NC ⊆ 边覆盖 EC ⊆ 边对覆盖 EPC ⊆ 主路径覆盖 PPC数据流覆盖这边也有类似关系:
All-Defs ⊆ All-Uses ⊆ All-DU-Paths这个“⊆”的含义是:满足后者,通常也满足前者。比如满足边覆盖,一般就隐含满足节点覆盖;满足主路径覆盖,一般就隐含满足边对覆盖。
这条链最有用的地方在于帮助定目标:当你设定的较高准则遇到困难,可以往下降一档,并且仍能保留大部分覆盖保障。反过来,如果项目安全等级高,就要往上升。关系弄清楚以后,就不会出现“列了一大堆用例,连边覆盖都没达到”的情况。
6.2 不同场景下的建议选择
根据我的经验,选准则要看三点:代码风险、运行频率、维护成本。
第一类是快速回归测试,比如每次提交代码都要跑的 CI 流水线。这种环境追求快,选节点覆盖或边覆盖就足够,用例多了跑一遍几分钟甚至十几分钟,团队根本承受不住。第二类是核心业务模块,比如支付、订单、权限校验,建议用主路径覆盖,把关键分支和循环边界都设计进去。第三类是曾经出过严重 bug 的模块,或者状态转换极其复杂的模块,这时候可以考虑数据流覆盖,尤其是 All-Uses 级别,成本比 All-DU-Paths 低不少,效果已经很好。
要特别点醒的是:覆盖率不是越高越好。为了把一个分支从 95% 顶到 100%,可能要写很多复杂用例,还要处理大量不可达路径。与其在边缘地带死磕,不如把省下的时间用来测更重要的业务链路。覆盖率是一个衡量手段,不是测试目标,这个顺序不能搞反。
7. 学习过程中我踩过的坑和一条可行的练习路线
7.1 三个容易误解的点
第一个误解是把“行覆盖”当成“路径覆盖”。行覆盖只能告诉你某行代码执行了没有,不能告诉你它是通过哪条路径执行到的。同一个节点可能由多条不同路径到达,但行覆盖报告里它们都算“被执行”,这会掩盖不同路径组合的问题。
第二个误解是忽略不可达路径的筛选。工具报告覆盖率不到 100% 时,很多人的第一反应是补用例,结果补了半天覆盖率纹丝不动。我之前遇到过一段历史代码,里面有个条件永远是矛盾的,两条路径中有一条根本不可能成立,工具一直报红的正是它。后来我把这个路径标注成不可达,问题才算解决。
第三个误解是认为“图覆盖只适用于白盒测试”。图覆盖的抽象能力完全可以迁移到黑盒和灰盒测试,比如把接口调用链画成图,用主路径覆盖去设计端到端用例;把用户状态机画成图,用边覆盖检查状态迁移是否齐全。我后来做接口测试时,还会刻意画一张“业务状态+接口动作”的有向图,效果比凭经验列用例要稳得多。
7.2 一套自测练习法:从图形构建到覆盖计算
如果你正在自学图覆盖,并且觉得光看概念记不住,我建议你按这套路线练一遍。
第一步,找一个几十行的纯函数,要求里面至少有一个循环和一个嵌套分支,最好能处理列表或数组。网上找面试题库里的算法题就很合适。
第二步,不看答案,自己手动画出控制流图。画完后再拿工具对比,比如 Python 的pyflow,或者自己写一个简单的转换程序来验证。不要小看这一步,很多错误都发生在基本块切分上:要么少拆了一个分支头,要么把else的连边画错。
第三步,为这张图分别计算节点覆盖、边覆盖、边对覆盖的测试需求,然后设计测试用例去覆盖它们。你可以用代码覆盖率工具来验证:达到 100% 的边覆盖时,是不是正好对应你设计的那几条路径。
第四步,加上一个循环,尝试用“0 次、1 次、2 次”的思路去设计主路径覆盖,并把不可达路径标出来。这一步能极大锻炼你的路径感知能力。
我强烈建议把这个练习过程记录成笔记,因为它会为后续学习数据流覆盖、变异测试打下一个特别好的基础。
7.3 面试官问图覆盖,怎么组织回答
最近很多准备软件测试面试的朋友问我:面试官问“你怎么保证测试是充分的”,该怎么答。其实图覆盖就是一个很标准的回答框架。
我会这样组织:先说自己会先用控制流图梳理被测函数的执行结构,然后用边覆盖或主路径覆盖来定义测试需求,再根据需求设计用例;之后借助覆盖率工具做量化验证,遇到未覆盖的路径时区分“真正需要测”和“不可达路径”,最后把结果沉淀成测试设计和复盘报告。这个回答既展示了理论基础,又体现工程经验,比干巴巴说“覆盖率 100%”要可信得多。
最后再分享一个小技巧
我自己学图覆盖时,最大的一次认知升级发生在画完第一张带循环的控制流图之后。当时我盯着那个环看了很久,突然明白覆盖率工具为什么总是“差一点到 100%”:因为公司不会为一个不可能的路径浪费测试成本,而在报表里把不可达路径标清楚,才是真正专业的表现。
如果你现在刚入门,别着急把所有准则都背下来,先把一段简单代码从“源码”转成“图”,再手工算一次边覆盖。这个过程走通了,后面再难的概念都只是换一层皮而已。图覆盖这套思维,值得你在学习软件测试的路上多花点时间。