
做软件测试这几年我遇到过两次比较打击人的场景。一次是面试官问“语句覆盖、分支覆盖、条件覆盖到底什么区别”我当时能背出定义但一追问“它们之间谁包含谁”人就傻了。另一次是入职后写单元测试工具报告显示行覆盖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→n3p2 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→n3p2 n0→n2→n34条边就全部覆盖了。从这个例子看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→n3p2 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满足时一定满足EPCEPC满足时一定满足ECEC满足时一定满足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节点划分n0int max a;n1if (b max)n2max b;b max为真时执行n3if (c max)n4max c;c max为真时执行n5return max;再补上边n0到n1n1到n2true分支和n1到n3false分支n2到n3n2执行完之后进入n3n3到n4true分支和n3到n5false分支n4到n5。一共7条边。这里有新手容易忽略的细节if语句的false分支是直接连到下一个判断节点的而不是连到函数结尾。很多人在画CFG时会把两个if理解成“嵌套关系”其实它们是顺序关系false边要单独画出来。判断顺序节点之间的关系是否正确最直接的办法是看编译器的控制流或调试器里的执行路径。3.2 第二步为每种准则设计测试用例图已经画好接下来按四种准则逐一生成测试需求并设计对应的测试用例。先看结论再逐个解释。覆盖准则核心测试需求最少测试用例数示例测试用例NC覆盖6个节点1maxOfThree(1, 2, 3)EC覆盖7条边2maxOfThree(1, 2, 3) maxOfThree(1, 0, -1)EPC覆盖所有长度为2的路径段3maxOfThree(1, 2, 3) maxOfThree(1, 0, -1) maxOfThree(1, 2, -1)PPC覆盖2条主路径2maxOfThree(1, 2, 3) maxOfThree(1, 0, 3)逐条来看。节点覆盖NC的测试需求是所有可达节点也就是n0到n5全部6个节点都覆盖。设计一个测试用例 maxOfThree(1, 2, 3)执行路径是 n0→n1→n2→n3→n4→n56个节点全部经过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→n3bmax为假和n3→n5cmax为假。补一个用例 maxOfThree(1, 0, -1)路径是 n0→n1→n3→n5把两条漏掉的false边全部补上。EC需要2个用例。EPC要覆盖所有长度为2的路径段。先把路径段列全n0→n1→n2n0→n1→n3n1→n2→n3n1→n3→n4n1→n3→n5n2→n3→n4n2→n3→n5n3→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→n5n0→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/475%。解读覆盖率报告时有几个常见误区。第一看到行覆盖接近100%就以为测试充分忽略了分支覆盖可能只有一半第二分支覆盖只关心条件判断为真和为假是否都发生过不关心复合条件内部每个子条件是否独立影响过结果后者要达到的是条件覆盖或MC/DC级别第三覆盖率是手段不是目的不要为了凑百分比写一堆不验证实际行为的“假用例”。如果想更准确地把工具分支覆盖和边覆盖对应起来可以在JaCoCo配置里关注指令级覆盖但日常项目里行覆盖加分支覆盖两个指标已经够用。关键是理解它们的含义而不是只看数字。4. 常见问题与排查技巧实录4.1 不可达的测试需求怎么处理设计测试需求时经常会发现某些TR根本不可能被任何测试路径满足。典型场景包括代码里写了恒真的条件比如if(true)导致另一分支永远不可达防御性编程留下的空分支某些异常处理分支只有特定外部条件下才可能触发但测试环境模拟不了。遇到这种情况先不要急着在覆盖率报告里做排除。第一步是确认它到底是“测试设计遗漏”还是“代码缺陷”。比如if(false)这种很可能只是开发者暂留的调试代码正经做法是提交代码评审时提出来删除而不是靠测试用例去迁就它。如果确认是合理但不可达的分支再通过工具配置把它从覆盖统计里排除避免覆盖率数字失真。JaCoCo支持通过注解或配置文件排除特定类和方法。实践中我更推荐只排除“明确不可达”的代码不要为了刷高覆盖率乱排除否则覆盖率报告就会失去指导意义。4.2 循环路径怎么覆盖循环是图覆盖里的老大难问题。完全路径覆盖在循环面前直接失效因为循环执行次数没有上限理论上路径数无限。这也是为什么主路径覆盖被教材推崇它在有限的用例数量下仍然保留了路径敏感的特征不会因为循环的存在就退化成“只逛一圈”。工程上处理循环的标准做法是先按循环边界拆场景循环0次、循环1次、循环多次再加一个边界值场景。比如for(int i0; in; i)至少要设计n0、n1、n较大值外加可能影响退出条件的边界值n的取值。用图覆盖的语言来说循环的“0次执行路径”和“1次以上执行路径”都是不同的简单路径只有把它们都覆盖到才算真正测到了循环逻辑。主路径覆盖在循环上的表现也很有意思简单路径不允许中间节点重复所以环只能以特定方式被访问一次这恰好对应“循环至少要完整走一次”的测试需求。4.3 面试和实战中的高频问题软件测试面试题里覆盖率几乎是必考项而且考法越来越细。面试官问“语句覆盖、分支覆盖、条件覆盖有什么区别”其实就是在考你对覆盖准则层级关系的理解。可以这样回答语句覆盖对应图覆盖的节点覆盖要求每一条语句都执行到分支覆盖对应边覆盖要求每个判断的真假分支都跑到条件覆盖更进一步要求复合条件中每一个原子条件的真假都独立发生一次。三者是层层递进的关系语句覆盖最弱条件覆盖最强但条件覆盖仍不等同于MC/DC。还有人问“覆盖率要达到100%吗”这个问题要看场景。简单模块做到分支覆盖100%完全可行但复杂项目里所有模块都追求100%既不经济也不现实。合理的做法是对核心模块设置覆盖率门槛同时配合代码评审和探索性测试把自动化测试覆盖不到的角落用其他手段补上。同样高频的还有“覆盖率能保证没有缺陷吗”。答案是不能。覆盖率衡量的是“测过的地方有多少”不衡量“没测到的交互有多少”。两个系统各测80%的覆盖率可能一个质量远好于另一个因为漏掉的20%所处的业务场景完全不同。覆盖率是充分性指标不是正确性指标。实战里还有一种典型问题覆盖率看起来很高但线上还是出了事故。原因往往是只测了正常路径边界值、异常输入、并发场景都没覆盖到。用图覆盖分析一遍把每个分支、每条主路径列出来之后边界和异常分支有没有测一目了然。学图覆盖这件事我最大的体会是它让我从“大概测了”变成“我知道我测了哪些路径没测哪些路径”。以前写测试用例靠灵感现在会先画CFG再把覆盖准则当成清单逐项核对。建议你也拿一段手头的老代码试试跑一遍常见的覆盖工具看看报告里那些数字——很多看似测得很充分的模块实际分支覆盖可能低得吓人。把图覆盖当成一种习惯之后写用例的思路会清晰很多。