做软件测试这几年,我遇到过两次比较打击人的场景。一次是面试官问“语句覆盖、分支覆盖、条件覆盖到底什么区别”,我当时能背出定义,但一追问“它们之间谁包含谁”,人就傻了。另一次是入职后写单元测试,工具报告显示行覆盖90%,我拿给组长看,他瞟了一眼说:“分支覆盖才50%,你测的都是happy path吧。”那时候我才意识到,自己虽然天天跑用例,但对“测试到底测到多细才算够”这件事,根本没有系统性的理解。
后来我把图覆盖这套理论认真啃了一遍,很多零散的概念一下子串起来了。软件测试里的“覆盖”问题,本质上可以归结为:把程序的执行逻辑抽象成一张图,然后看你设计的测试用例在这张图上走了多少、漏了多少。这套思路就是图覆盖(Graph Coverage)的核心,也是白盒测试、结构化测试的理论底座。这篇学习笔记是“软件测试学习笔记”系列第二篇,专门把图覆盖讲透——从最基础的节点、边、路径概念,到节点覆盖、边覆盖、边对覆盖、主路径覆盖四种准则,再拿一段实际代码完整走一遍流程,最后聊聊那些面试和实战里绕不开的坑。
如果你正在自学软件测试基础,或者在背软件测试八股文准备面试,又或者只是想知道覆盖率报告上那些百分比到底代表什么,这篇笔记都适合你。门槛很低,能看懂if和while就够了。
1. 图覆盖:软件测试里的“地图导航”
1.1 为什么软件测试要用“图”来建模
先想一个问题:一个测试用例跑完程序之后,到底留下了什么?从执行轨迹来看,它其实就是从程序入口走到出口的一条路径。程序里有无穷无尽的输入组合,但执行路径是可以被抽象、被分类、被计数的,而“图”恰好就是描述路径的最佳工具。
把函数、模块、系统转成图之后,测试设计就变成了一件可以计算的事情。你不需要凭感觉回答“测够了没有”,只需要拿着覆盖准则去对比:哪些节点走到了,哪些边漏掉了,哪些路径组合还没被验证过。这个思路在单元测试、集成测试、系统测试里都通用,只要你能够为被测对象建立合适的图模型。
最常见的图模型是控制流图(CFG),节点代表基本块或语句,边代表控制转移。把源代码变成CFG,再套用覆盖准则,就是典型的白盒测试设计方法。很多测试新手觉得白盒测试门槛高,其实第一步不过就是“读懂代码→画图→根据图列测试需求→设计用例”这四个动作。
1.2 图覆盖在整个测试体系中的定位
软件测试按是否需要看代码,可以分成黑盒和白盒两大类。黑盒测试不关心内部实现,只从输入输出和需求规格出发设计用例;白盒测试则要求根据代码本身的结构来设计用例,图覆盖就是白盒测试里最核心的形式化方法。
在经典测试教材里,图覆盖通常被放在“基于结构的测试”章节,和语句覆盖、分支覆盖、条件覆盖这些概念并列。很多人学这些概念时会觉得它们是孤立的,好像语句覆盖是一种方法,分支覆盖是另一种方法,但图覆盖给出了一个统一视角:语句覆盖大致对应节点覆盖,分支覆盖大致对应边覆盖,路径覆盖可以对应主路径覆盖或完全路径覆盖。用图的语言重新描述一遍之后,这些名词之间的包含关系、强弱关系、取舍逻辑就全部贯通了。
这也是为什么我说图覆盖是“地图导航”。它不只是教你一个知识点,而是让你在看代码、写用例、看覆盖率报告时,脑子里有一张可对照的地图:现在走到哪了,还有哪些岔路没走,接下来该往哪走。
2. 四种核心覆盖准则拆解
2.1 先搞清楚图的基本概念
图覆盖虽然听起来理论,但用到的图论概念非常少,先把几个关键词理清楚就够了。
- 节点(Node):程序中的基本块或一条语句,在CFG里用一个圈表示。基本块是顺序执行、中间没有跳转的语句序列。
- 边(Edge):两个节点之间的控制转移,通常由if、while、for等条件引起,在CFG里用带箭头的线表示。
- 初始节点(Initial Node):程序的入口节点。
- 终节点(Final Node):程序的出口节点,可能有多个。
- 路径(Path):从初始节点到终节点,经过一系列节点和边形成的序列。
- 简单路径(Simple Path):路径中除了首尾节点可能相同以外,其他节点都不重复。如果首尾相同,就形成一个环。
- 测试路径(Test Path):一个测试用例实际执行后对应的路径,必须从初始节点到终节点。
- 可达路径与不可达路径:不可达路径在真实执行中永远无法出现,后面章节会专门讲怎么处理。
每次做覆盖分析时,流程是这样的:先根据覆盖准则生成测试需求集合(Test Requirements,简称TR),然后设计测试用例集合,让测试用例对应的测试路径能够满足这些TR。你写用例的过程,本质上就是在“凑路径”,凑出来的路径合在一起,必须把准则要求的节点、边或路径段都覆盖到。
为了后面讨论方便,我这里先给一个简单的示例图:图G有4个节点,n0是初始节点,n1和n2是分支节点,n3是终节点;边有4条,分别是(n0,n1)、(n0,n2)、(n1,n3)、(n2,n3)。这个图非常小,但足够解释所有准则的定义。
2.2 节点覆盖与边覆盖:最基础的两种准则
节点覆盖(Node Coverage,简称NC)要求测试路径集合覆盖图中所有可达的节点。对应到代码上,就是“每一条语句都至少被执行一次”。这是最基础的覆盖要求,也是很多工具默认给出的衡量维度之一。
用上面那个小图举例,NC的TR就是 TR = {n0, n1, n2, n3}。设计两条测试路径 p1 = n0→n1→n3,p2 = n0→n2→n3,四个节点就都被覆盖了。看起来很简单,但要注意,NC只要求“每个节点都走到”,完全不关心边与边之间的组合关系。哪怕程序里if的两个分支只走了其中一个,只要另一个分支里的语句也算节点,NC照样100%。
边覆盖(Edge Coverage,简称EC)比NC更进一步,要求测试路径覆盖图中每一条可达的边。因为每一条边都被走到,必然意味着每条边的两端节点都被走到,所以EC是包含NC的。
小图的EC的TR是 TR = {(n0,n1), (n0,n2), (n1,n3), (n2,n3)}。设计两条测试路径 p1 = n0→n1→n3,p2 = n0→n2→n3,4条边就全部覆盖了。从这个例子看,EC和NC需要的最小用例数似乎一样,但实际代码的CFG往往是不对称的,漏一条边是非常常见的事。
很多测试工具会在报告里同时给出“行覆盖”和“分支覆盖”两个指标。行覆盖和NC类似,分支覆盖和EC很接近,但两者并不完全等价。行覆盖100%时,分支覆盖可能只有50%,原因很简单:一行if语句可能包含多个分支,其中只有一个分支被执行了,这一行照样被标记为已覆盖。很多初学者在这里栽过跟头,把行覆盖当成安全的代名词,结果分支漏得干干净净。
注意:节点覆盖满足,不意味着边覆盖满足。NC是所有覆盖准则里的最低门槛,如果连NC都达不到,后面的准则更是无从谈起。
2.3 边对覆盖与主路径覆盖:更严格的路径要求
边覆盖只关心每一条边是否走到,但有些时序相关的缺陷,需要同时关注连续两条边的组合。边对覆盖(Edge-Pair Coverage,简称EPC)要求测试路径覆盖所有长度为2的可达路径段。一个“长度2的路径段”就是连续的两条边,比如a→b→c。
边界案例继续使用小图G:所有长度为2的可达路径段有两个,分别是(n0,n1,n3)和(n0,n2,n3)。用p1 = n0→n1→n3,p2 = n0→n2→n3即可满足。但如果CFG更复杂,EPC需要的用例数通常会明显高于EC,因为它开始关注“路径片段”的组合关系。
真正让我觉得理论有价值的,是主路径覆盖(Prime Path Coverage,简称PPC)。先解释一下为什么需要它:如果采用完全路径覆盖(Complete Path Coverage),理论上是想覆盖从初始节点到终节点的每一条路径,但一旦程序里有循环,循环可以执行0次、1次、2次……路径数量瞬间变成无限,完全路径覆盖在实践上直接失效。
主路径覆盖用“简单路径”解决了路径爆炸的问题。简单路径不允许中间重复经过节点,这保证了每一条简单路径都是有限的。主路径的定义是:一条简单路径,同时它不能作为其他简单路径的“子路径”存在。所谓子路径,就是完整路径中截取出来的一段。主路径覆盖要求测试路径集合覆盖图中每一条主路径。
用生活化类比:NC是每个房间都要进去看一圈,EC是每段走廊都要走一遍,EPC是任意连续两段走廊都要走一遍,PPC则是要把小区里所有“不重复逛完”的主干路径都走出来。PPC比EPC更严格,因为它覆盖的主路径往往更长、更完整,而EPC只关注局部连续片段。
回到小图G的例子:所有简单路径包括 n0→n1→n3、n0→n2→n3、n0→n1、n0→n2、n1→n3、n2→n3 等。其中 n0→n1 是 n0→n1→n3 的子路径,n0→n2 是 n0→n2→n3 的子路径,所以它们都不算主路径。主路径只有两条:n0→n1→n3 和 n0→n2→n3。在这个小图里PPC与EC的最小用例数恰好一样,都是2个,但在更复杂的图里,PPC的用例数通常会更多。
2.4 覆盖准则之间的包含关系与选型
四种覆盖准则存在明确的包含关系,用符号来表示就是:
完全路径覆盖(CPC)包含主路径覆盖(PPC)包含边对覆盖(EPC)包含边覆盖(EC)包含节点覆盖(NC)。
这里的“包含”是指:满足后一个准则的测试用例集,一定也满足前一个准则的测试需求?方向是这样的:如果A包含B,表示满足B就意味着满足A。即PPC满足时一定满足EPC,EPC满足时一定满足EC,EC满足时一定满足NC。所以PPC是四种准则里最严格的,NC最宽松,严格程度从高到低是:PPC > EPC > EC > NC。
工程上怎么选?看被测代码的风险和成本。核心模块、金融风控、支付流程这类逻辑复杂的代码,建议至少做到EPC,关键方法尽量往PPC靠;普通业务模块做到EC已经能发现大部分问题;NC只是兜底。覆盖率工具里经常看到的要求是核心模块“行覆盖不低于80%、分支覆盖不低于70%”,本质上就是在NC和EC之间做一个成本可控的折中。
有一点要说明:覆盖准则越严格,用例数量越多、维护成本越高,但不代表发现缺陷的能力线性上升。好的做法是针对不同代码颗粒度分层设指标,而不是一刀切地要求所有代码都做到PPC。
3. 实操:从代码到图覆盖的完整流程
3.1 第一步:把代码拆成控制流图
光讲定义不够,还是用一段真实可跑的代码把整个流程走一遍。下面这个函数返回三个数中的最大值,逻辑不复杂,但已经包含两个if分支,足够演示NC、EC、EPC、PPC的区别。
public class MaxOfThree { public static int maxOfThree(int a, int b, int c) { int max = a; // 节点 n0 if (b > max) { // 节点 n1 max = b; // 节点 n2 } if (c > max) { // 节点 n3 max = c; // 节点 n4 } return max; // 节点 n5 } }构建CFG时,先把代码划分成基本块。基本块的入口通常有三种情况:函数第一条语句、跳转指令的目标语句、跳转指令之后的第一条语句。在这个函数里,每个赋值语句和if判断都可以单独作为一个节点,因为中间没有其他跳转。
CFG节点划分:
- n0:int max = a;
- n1:if (b > max);
- n2:max = b;(b > max为真时执行)
- n3:if (c > max);
- n4:max = c;(c > max为真时执行)
- n5:return max;
再补上边:n0到n1;n1到n2(true分支)和n1到n3(false分支);n2到n3(n2执行完之后进入n3);n3到n4(true分支)和n3到n5(false分支);n4到n5。一共7条边。
这里有新手容易忽略的细节:if语句的false分支是直接连到下一个判断节点的,而不是连到函数结尾。很多人在画CFG时会把两个if理解成“嵌套关系”,其实它们是顺序关系,false边要单独画出来。判断顺序节点之间的关系是否正确,最直接的办法是看编译器的控制流或调试器里的执行路径。
3.2 第二步:为每种准则设计测试用例
图已经画好,接下来按四种准则逐一生成测试需求,并设计对应的测试用例。先看结论,再逐个解释。
| 覆盖准则 | 核心测试需求 | 最少测试用例数 | 示例测试用例 |
|---|---|---|---|
| NC | 覆盖6个节点 | 1 | maxOfThree(1, 2, 3) |
| EC | 覆盖7条边 | 2 | maxOfThree(1, 2, 3) + maxOfThree(1, 0, -1) |
| EPC | 覆盖所有长度为2的路径段 | 3 | maxOfThree(1, 2, 3) + maxOfThree(1, 0, -1) + maxOfThree(1, 2, -1) |
| PPC | 覆盖2条主路径 | 2 | maxOfThree(1, 2, 3) + maxOfThree(1, 0, 3) |
逐条来看。
节点覆盖NC的测试需求是所有可达节点,也就是n0到n5全部6个节点都覆盖。设计一个测试用例 maxOfThree(1, 2, 3),执行路径是 n0→n1→n2→n3→n4→n5,6个节点全部经过,NC达标。从这里能看出NC的门槛真的很低,一个全true用例就搞定了。
边覆盖EC的测试需求是7条边全部覆盖。测试用例(1, 2, 3)走了n0→n1→n2→n3→n4→n5,覆盖了6条边:n0→n1、n1→n2、n2→n3、n3→n4、n4→n5;漏掉了两条false边:n1→n3(b>max为假)和n3→n5(c>max为假)。补一个用例 maxOfThree(1, 0, -1),路径是 n0→n1→n3→n5,把两条漏掉的false边全部补上。EC需要2个用例。
EPC要覆盖所有长度为2的路径段。先把路径段列全:
- n0→n1→n2
- n0→n1→n3
- n1→n2→n3
- n1→n3→n4
- n1→n3→n5
- n2→n3→n4
- n2→n3→n5
- n3→n4→n5
这里要注意:n1→n3之后可能接n4也可能接n5,所以(n1,n3,n4)和(n1,n3,n5)是两个不同的边对;n2→n3同样有两种后续,所以要分别列出来。
逐个用例来匹配:用例1路径n0→n1→n2→n3→n4→n5,覆盖了n0→n1→n2、n1→n2→n3、n2→n3→n4、n3→n4→n5;用例2路径n0→n1→n3→n5,覆盖了n0→n1→n3、n1→n3→n5;还剩下n2→n3→n5没有被覆盖。因为用例1走到n2→n3后去了n4,用例2根本没经过n2。所以需要再加一个用例 maxOfThree(1, 2, -1),路径是 n0→n1→n2→n3→n5,这一条把n2→n3→n5补上了。EPC需要3个用例。
PPC要覆盖所有主路径。这个图里所有简单路径中,最长的两条是:
- n0→n1→n2→n3→n4→n5
- n0→n1→n3→n4→n5
这两条都不能再被其他简单路径包含,所以就是主路径。其他简单路径比如n0→n1→n3→n5,是第二条主路径的子路径,所以不算主路径。设计两个用例:用例1 maxOfThree(1, 2, 3)覆盖第一条主路径,用例2 maxOfThree(1, 0, 3)覆盖第二条主路径。PPC需要2个用例。
这个例子比较反直觉:PPC竟然和EC需要一样多的用例。原因是这个图恰好比较对称,而且没有循环。一旦代码里有循环,主路径集合的大小会明显变化,用例数也会随之上升。但这也说明了一件事:覆盖准则需要的用例数量不能凭空拍脑袋,一定要先列TR再设计用例,否则很容易高估或低估测试工作量。
实操小结:每设计完一组测试用例,建议把每个用例对应的执行路径写在用例描述里。这样后面分析覆盖缺口时,不需要重新跑代码去猜路径,直接对照路径列表就能看出来哪个需求还没满足。
3.3 第三步:用工具验证覆盖度
手动分析完,再用工具验证一遍,看看理论和实际是不是对得上。Java生态里最常见的覆盖工具是JaCoCo,它能生成行覆盖、分支覆盖、方法覆盖等指标。这里的“分支覆盖”和上文说的EC很接近,但工具统计的是if、switch等判断点的分支命中情况。
先写一个简单的JUnit测试类:
import org.junit.Test; import static org.junit.Assert.assertEquals; public class MaxOfThreeTest { @Test public void testAllTrue() { assertEquals(3, MaxOfThree.maxOfThree(1, 2, 3)); } @Test public void testSecondFalse() { assertEquals(2, MaxOfThree.maxOfThree(1, 2, 0)); } }跑一下JaCoCo,报告里会出现:第一个用例testAllTrue对应NC用例;如果只有这两个测试,报告大概率显示行覆盖100%,但分支覆盖是75%或更低,具体取决于工具如何统计。比如第一个用例走了两个true分支,第二个用例走了第一个true和第二个false,那么第一个if的两个分支(true/false)都覆盖了,第二个if只覆盖了true分支,false分支没走到,所以整体分支覆盖是3/4=75%。
解读覆盖率报告时有几个常见误区。第一,看到行覆盖接近100%就以为测试充分,忽略了分支覆盖可能只有一半;第二,分支覆盖只关心条件判断为真和为假是否都发生过,不关心复合条件内部每个子条件是否独立影响过结果,后者要达到的是条件覆盖或MC/DC级别;第三,覆盖率是手段不是目的,不要为了凑百分比写一堆不验证实际行为的“假用例”。
如果想更准确地把工具分支覆盖和边覆盖对应起来,可以在JaCoCo配置里关注指令级覆盖,但日常项目里行覆盖加分支覆盖两个指标已经够用。关键是理解它们的含义,而不是只看数字。
4. 常见问题与排查技巧实录
4.1 不可达的测试需求怎么处理
设计测试需求时,经常会发现某些TR根本不可能被任何测试路径满足。典型场景包括:代码里写了恒真的条件(比如if(true))导致另一分支永远不可达;防御性编程留下的空分支;某些异常处理分支只有特定外部条件下才可能触发,但测试环境模拟不了。
遇到这种情况,先不要急着在覆盖率报告里做排除。第一步是确认它到底是“测试设计遗漏”还是“代码缺陷”。比如if(false)这种,很可能只是开发者暂留的调试代码,正经做法是提交代码评审时提出来删除,而不是靠测试用例去迁就它。如果确认是合理但不可达的分支,再通过工具配置把它从覆盖统计里排除,避免覆盖率数字失真。
JaCoCo支持通过注解或配置文件排除特定类和方法。实践中我更推荐只排除“明确不可达”的代码,不要为了刷高覆盖率乱排除,否则覆盖率报告就会失去指导意义。
4.2 循环路径怎么覆盖
循环是图覆盖里的老大难问题。完全路径覆盖在循环面前直接失效,因为循环执行次数没有上限,理论上路径数无限。这也是为什么主路径覆盖被教材推崇:它在有限的用例数量下,仍然保留了路径敏感的特征,不会因为循环的存在就退化成“只逛一圈”。
工程上处理循环的标准做法是先按循环边界拆场景:循环0次、循环1次、循环多次,再加一个边界值场景。比如for(int i=0; i<n; i++),至少要设计n=0、n=1、n=较大值,外加可能影响退出条件的边界值n的取值。用图覆盖的语言来说,循环的“0次执行路径”和“1次以上执行路径”都是不同的简单路径,只有把它们都覆盖到,才算真正测到了循环逻辑。
主路径覆盖在循环上的表现也很有意思:简单路径不允许中间节点重复,所以环只能以特定方式被访问一次,这恰好对应“循环至少要完整走一次”的测试需求。
4.3 面试和实战中的高频问题
软件测试面试题里,覆盖率几乎是必考项,而且考法越来越细。面试官问“语句覆盖、分支覆盖、条件覆盖有什么区别”,其实就是在考你对覆盖准则层级关系的理解。可以这样回答:语句覆盖对应图覆盖的节点覆盖,要求每一条语句都执行到;分支覆盖对应边覆盖,要求每个判断的真假分支都跑到;条件覆盖更进一步,要求复合条件中每一个原子条件的真假都独立发生一次。三者是层层递进的关系,语句覆盖最弱,条件覆盖最强,但条件覆盖仍不等同于MC/DC。
还有人问“覆盖率要达到100%吗”,这个问题要看场景。简单模块做到分支覆盖100%完全可行,但复杂项目里所有模块都追求100%既不经济也不现实。合理的做法是对核心模块设置覆盖率门槛,同时配合代码评审和探索性测试,把自动化测试覆盖不到的角落用其他手段补上。
同样高频的还有“覆盖率能保证没有缺陷吗”。答案是不能。覆盖率衡量的是“测过的地方有多少”,不衡量“没测到的交互有多少”。两个系统各测80%的覆盖率,可能一个质量远好于另一个,因为漏掉的20%所处的业务场景完全不同。覆盖率是充分性指标,不是正确性指标。
实战里还有一种典型问题:覆盖率看起来很高,但线上还是出了事故。原因往往是只测了正常路径,边界值、异常输入、并发场景都没覆盖到。用图覆盖分析一遍,把每个分支、每条主路径列出来之后,边界和异常分支有没有测,一目了然。
学图覆盖这件事,我最大的体会是:它让我从“大概测了”变成“我知道我测了哪些路径,没测哪些路径”。以前写测试用例靠灵感,现在会先画CFG,再把覆盖准则当成清单逐项核对。建议你也拿一段手头的老代码试试,跑一遍常见的覆盖工具,看看报告里那些数字——很多看似测得很充分的模块,实际分支覆盖可能低得吓人。把图覆盖当成一种习惯之后,写用例的思路会清晰很多。