news 2026/10/5 1:13:23

图覆盖实战指南:从控制流图到主路径覆盖

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
图覆盖实战指南:从控制流图到主路径覆盖

测试这个行当,聊到白盒测试就绕不开一个概念:图覆盖。我当年准备软件测试面试的时候,翻开各种“八股文”资料,几乎每一份都会提到节点覆盖、边覆盖、主路径覆盖这些词,但真正能把这些概念讲明白、还能让我动手算出来的教程特别少。这篇学习笔记,就是专门把“图覆盖”这件事从头到尾捋清楚。

这篇笔记不是什么高深理论,主要解决三个问题:第一,图覆盖的“图”到底是什么图,怎么从代码里构建出来;第二,节点覆盖、边覆盖、边对覆盖、主路径覆盖这几个标准分别要求测什么;第三,怎么基于覆盖需求设计测试用例,并且在实际项目里判断该用哪一层标准。无论你是准备面试,还是在系统学习软件测试基础知识,这篇文章都值得你跟着算一遍。

1. 图覆盖到底在覆盖什么

1.1 为什么测试要先“看图”

很多初学者学功能测试的时候,是顺着业务需求去设计用例的,比如输入一个用户名密码,点击登录,看是否跳转到首页。这是黑盒思路,完全不看代码内部长什么样。但到了白盒测试,情况就不一样了,你必须走进代码内部,看每一行语句、每一个分支是否被执行到。

问题来了,代码内部的结构千差万别,怎么才能用一种统一的方式描述呢?答案就是把代码抽象成“图”。

你可以把图理解成一张地图,代码的执行路径就是地图上的路线。测试用例就是“走地图的人”,你设计一组用例,让这些“行人”尽可能多地走到地图上的关键位置。图覆盖所研究的,就是如何定义“关键位置”,以及如何判断覆盖得够不够。

1.2 图的五个基本要素

在做图覆盖之前,先把图本身的定义搞清楚。图覆盖里说的图,通常指有向图,记作 G=(N, N0, Nf, E),四个要素缺一不可:

  • N(Nodes):节点集合,对应代码中的语句、语句块或基本块。
  • N0(Initial Nodes):初始节点集合,表示程序的入口。
  • Nf(Final Nodes):终节点集合,表示程序的出口或返回点。
  • E(Edges):边集合,表示控制流的跳转关系,也就是从一个节点执行到另一个节点的路径。

举个例子,最简单的顺序结构代码:

x = 1 y = 2 print(x + y)

这段代码可以抽象成三个节点:N1表示x=1,N2表示y=2,N3表示print。两条边:N1到N2,N2到N3。初始节点是N1,终节点是N3。

如果是带分支的代码,比如if-else,那么N2到N3、N2到N4都会连上边,而且每条边上要标注条件为真还是为假。这也是图覆盖的基础:节点负责承载“执行了什么”,边负责承载“怎么跳转的”。

1.3 测试需求TR是覆盖的中心

覆盖这个概念,落到实处是靠“测试需求”(Test Requirement,简称TR)来度量的。每个覆盖准则都会产生一组测试需求,满足全部测试需求的测试用例集合,就是达到该覆盖率目标的用例集。

打个比方,节点覆盖的TR就是“图里所有可达的节点”,哪个用例能覆盖到某个节点,就说明这个TR被满足了。覆盖率计算公式是:

覆盖率 = 已满足的TR数量 / TR总数量 × 100%

这个概念极其重要。很多人在算覆盖率的时候,直接把“执行了多少行代码”当覆盖率,其实就是在统计节点覆盖,只是没有用TR这套语言描述而已。后面讲到所有覆盖准则,本质上都是在围绕TR做文章。

2. 从低到高认识覆盖准则

2.1 节点覆盖NC:最基本的语句覆盖

节点覆盖(Node Coverage,简称NC)要求测试用例集覆盖图中所有可达的节点。翻译成人话,就是每一行代码、每一个基本块,至少被执行一次。

你平时看到的行覆盖率,比如JaCoCo报告里那个行覆盖率百分比,本质上就是在做节点覆盖的统计。这个标准最直观,也最容易实现,但它的弱点非常明显:它是“走过场式”的覆盖,完全不管分支之间的组合关系。

举个极端案例:

def demo(a): if a > 0: print("A") if a < 100: print("B")

如果只用一个用例a=50,两个if都为真,那么代码全部执行了一遍,节点覆盖100%达标。但a>0为假而且a<100为假的情况完全没测过,如果print("B")的if条件被写错,比如写成a>100,节点覆盖根本发现不了。因为这个标准只关心“你去没去过某个地方”,不关心“你走的是哪条路”。

2.2 边覆盖EC:比节点覆盖更进一步

边覆盖(Edge Coverage,简称EC)把要求提升了一档,它要求测试用例集覆盖图中每一条可达的边。对应到代码层面,就是每个分支的真和假两个方向都得走到。

仍以上面demo代码为例,边覆盖要求a>0为真的路径要走一遍、为假的路径也要走一遍,a<100为真和为假也都得走一遍。那就至少需要两个用例,比如a=50覆盖两个真分支,a=-1覆盖a>0假和a<100真,再加一个a=200覆盖两个假分支。

边覆盖在实际项目里对应的是分支覆盖率,很多覆盖率工具的“分支覆盖”指标就是这个。它比节点覆盖更能发现问题,因为很多bug藏在分支未被走的另一半里。不过边覆盖也有自己的盲区,它只关心单条边的走向,不关心多条边连续组合在一起的情况。

2.3 边对覆盖EPC:检查相邻边的组合

边对覆盖(Edge-Pair Coverage,简称EPC)的要求是覆盖图中所有长度为2的路径,也就是相邻的两条边组成的一个“边对组合”。

理解这个标准时,可以把测试从“走到每个路口”升级成“每次连续经过两个路口”。因为很多bug并不是单条边上触发的,而是连续执行两条指令后才显现。

比如代码:

if a > 0: x = 1 else: x = -1 if x > 0: print("positive") else: print("non-positive")

边覆盖只需要你分别走过两个if的真和假分支,比如a=1覆盖真真,a=-1覆盖假假。但边对覆盖会要求覆盖“第一个if走真,第二个if走假”这种组合,比如a=1并且第二个if条件意外变成x>0的相反逻辑。这时候虽然单条边都覆盖到了,但边的组合路径可能被遗漏。

边对覆盖比边覆盖更严格,它逼着测试人员去思考相邻分支的组合关系。实际工程里,纯手工去算边对覆盖会比较累,因为一个稍大的控制流图,边对数量会快速增长。

2.4 主路径覆盖PPC:把路径走到头

主路径覆盖(Prime Path Coverage,简称PPC)是图覆盖里的重量级标准。它要求测试用例覆盖图中所有“主路径”。

要明白主路径,得先理解两个定义:

  • 简单路径:路径中不重复出现节点,但允许首尾相同形成一个环。
  • 主路径:一条简单路径,如果它不再是任何其他简单路径的子路径,那它就是主路径。换句话说,它是“不能再往两头延伸”的简单路径。

为什么主路径覆盖比边对覆盖更严格?因为主路径的长度不固定,可能覆盖到很长的一条链路。边对只看两步,主路径可能把整个函数的所有关键节点都串起来。

不过,主路径覆盖在实际操作中有一个巨大痛点:很多主路径是不可达的。比如一条边走到某个节点后,再往下走的分支条件与前面冲突,这条主路径根本没有对应的输入能触发。这也就是后面要重点聊的“不可行测试需求”问题。

2.5 覆盖准则之间不是简单的“手下线包含上线”

图覆盖标准之间存在包含关系,但这个关系很容易被初学者搞混。先给结论:

  • 满足边覆盖的测试用例集,一定满足节点覆盖。
  • 满足边对覆盖的测试用例集,一定满足边覆盖。
  • 满足主路径覆盖的测试用例集,一定满足边覆盖。
  • 但边对覆盖和主路径覆盖之间没有绝对的包含关系。

看起来有点反直觉。边对覆盖要求覆盖所有长度为2的路径,主路径覆盖要求覆盖所有不可扩展的简单路径,为什么不能说明谁包含谁?

原因是:存在一些边对,它所在的位置无法扩展成一条主路径;也存在一些主路径,它的长度很长,中间的边对虽然被覆盖了,但边对覆盖并没有把所有主路径全部覆盖。所以在某些特定图结构下,两者的测试需求各有遗漏。

我在学习这一部分的时候,发现用“覆盖标准A满足则标准B必满足”来理解包含关系最靠谱。包含关系看的是测试用例集的充分性,而不是TR集合的个数。

3. 完整的实操:从代码到覆盖用例

在实际测试中,图覆盖不是空谈概念,而是要落实到具体代码上的。下面我用一个简单但完整的函数,把所有流程走一遍。

3.1 代码到控制流图的映射规则

先说说怎么把代码转成控制流图。我实操下来,最稳妥的规则有四条:

  • 顺序语句:每个语句或语句块映射成一个节点,按执行顺序连边。
  • if-else分支:条件判断作为分支节点,真和假两个方向分别连边到两个分支节点。
  • while/for循环:循环条件作为分支节点,循环体和循环退出分别连边。
  • return/throw:指向终节点,标记程序从该点退出。

不用精确到每一个单独的语句,可以把连续顺序执行的语句块合并成一个节点。只要这个节点内部没有跳转和分支,合并后完全不影响图覆盖的计算,反而让图更清晰。

3.2 一个完整案例:max_of_three函数

看下面这个求三个数最大值的函数:

def max_of_three(a, b, c): m = a # N1 if b > m: # N2 m = b # N3 if c > m: # N4 m = c # N5 return m # N6

将代码映射成控制流图,节点含义如下:

  • N1:m = a
  • N2:if b > m
  • N3:m = b
  • N4:if c > m
  • N5:m = c
  • N6:return m

边关系:N1到N2;N2的真分支到N3,假分支到N4;N3到N4;N4的真分支到N5,假分支到N6;N5到N6。

3.3 逐步设计测试用例,算清楚的覆盖率

节点覆盖(NC)

TR是六个节点:N1、N2、N3、N4、N5、N6。

用例1:max_of_three(5, 3, 4),执行路径N1-N2(假)-N4(假)-N6,覆盖N1、N2、N4、N6。 用例2:max_of_three(1, 2, 3),执行路径N1-N2(真)-N3-N4(真)-N5-N6,覆盖N1、N2、N3、N4、N5、N6。

两个用例组合,节点覆盖100%。

边覆盖(EC)

TR是全部七条边:N1-N2、N2-N3、N2-N4、N3-N4、N4-N5、N4-N6、N5-N6。

我用上面两个用例检查一遍:用例1覆盖N1-N2、N2-N4、N4-N6;用例2覆盖N1-N2、N2-N3、N3-N4、N4-N5、N5-N6。七条边全部覆盖到。

所以同样是这两个用例,边覆盖也能达到100%。这个例子里的节点覆盖和边覆盖需求刚好重合,并不是巧合,而是因为每个节点都有自己独特的入口边,覆盖了所有边必然覆盖所有节点。

边对覆盖(EPC)

TR是全部长度为2的路径,我一个个列出来:

  • N1-N2-N3
  • N1-N2-N4
  • N2-N3-N4
  • N2-N4-N5
  • N2-N4-N6
  • N3-N4-N5
  • N3-N4-N6
  • N4-N5-N6

一共8条。两个用例肯定覆盖不全,继续增加用例。

用例3:max_of_three(5, 3, 8),执行路径N1-N2(假)-N4(真)-N5-N6。检查一下,这时覆盖了N1-N2-N4、N2-N4-N5、N4-N5-N6,再加上之前两个用例覆盖的,目前已经覆盖了除N3-N4-N6、N2-N4-N6以外的边对。

用例4:max_of_three(1, 5, 4),执行路径N1-N2(真)-N3-N4(假)-N6。覆盖N1-N2-N3、N2-N3-N4、N3-N4-N6、N2-N4-N6?这里要注意,N2-N4-N6这条边对要求连续执行N2-N4-N6,而用例4从N2到N3再到N4再到N6,走的是N2-N3-N4和N3-N4-N6,并没有走N2-N4-N6。

四个用例分别是:

用例入参执行路径
T1(5, 3, 4)N1-N2-N4-N6
T2(1, 2, 3)N1-N2-N3-N4-N5-N6
T3(5, 3, 8)N1-N2-N4-N5-N6
T4(1, 5, 4)N1-N2-N3-N4-N6

四个用例组合,我逐条核对8个边对:

  • N1-N2-N3:T2、T4覆盖
  • N1-N2-N4:T1、T3覆盖
  • N2-N3-N4:T2、T4覆盖
  • N2-N4-N5:T3覆盖
  • N2-N4-N6:T1覆盖
  • N3-N4-N5:T2覆盖
  • N3-N4-N6:T4覆盖
  • N4-N5-N6:T2、T3覆盖

八个边对全部覆盖,边对覆盖率100%。注意,T2,T3,T4这些用例怎么来的,其实就是对着TR列表逐个“补路径”补出来的。这也是图覆盖最实用的价值:它让你有据可依,不是拍脑袋。

主路径覆盖(PPC)

再看主路径。先找出所有主路径,也就是不能再延伸的简单路径。这个图是典型DAG(有向无环图),不存在环,所以从N1出发,一直走到N6的所有路径,每一整条都是主路径:

  • P1:N1-N2-N3-N4-N5-N6
  • P2:N1-N2-N3-N4-N6
  • P3:N1-N2-N4-N5-N6
  • P4:N1-N2-N4-N6

四条主路径。对照用例:

  • T2覆盖P1
  • T4覆盖P2
  • T3覆盖P3
  • T1覆盖P4

四个用例刚好把四条主路径全走了一遍,主路径覆盖也是100%。

这个例子的主路径覆盖和边对覆盖居然用了同一组测试用例,但它们的TR完全不一样。主路径覆盖的TR是四条路径,边对覆盖的TR是八个边对。两者在这张图上达到100%所需用例数量相同,纯属这张图结构比较简单。

3.4 这组用例说明了什么

我的体会是,图覆盖不是一上来就让你把所有标准都堆满,而是让你一层一层往上加。先跑通节点覆盖,再看边覆盖,如果项目对质量要求高,再考虑边对覆盖和主路径覆盖。

几个用例算下来,你应该能感觉到,一套覆盖率报告不是外表光鲜的“执行了几行”,而是背后有一组TR在被逐个验证。这也是软件测试面试里经常问到的点:给你一个函数,你如何设计测试用例达到分支覆盖100%?本质上考的就是你能否把代码转换成控制流图,再对着边列表补用例。

4. 图覆盖在实际测试中的定位

4.1 覆盖率工具的底层逻辑

很多测试同学用过JaCoCo、Clover、Istanbul这类覆盖率工具。工具的报表里通常有行覆盖率和分支覆盖率,行覆盖率基本等价于节点覆盖,分支覆盖率基本等价于边覆盖。

不过工具只能告诉你“哪些地方没执行到”,不能告诉你“这些地方没执行到是因为用例设计遗漏,还是根本不可达”。图覆盖的思维刚好帮你补上这一环,遇到覆盖率不达标的时候,先画控制流图,定位没覆盖的节点和边,分析它属于哪种情况。

如果是用例遗漏,那就对着TR补用例。如果是不可达路径,那就需要在测试文档里说明原因,把该TR标记为不可行并从覆盖率分母里剔除。很多项目覆盖率卡在95%上不去,往往就是有若干条不可达分支在那里占着名额。

4.2 不可行路径是最大的坑

前面提到不可行路径,我举个最典型的例子:

def f(x): if x > 0: y = 1 else: y = -1 if y == 1: print("positive") else: print("non-positive")

这个函数里,第一个if为真时y=1,第二个if的y==1必然为真;第一个if为假时y=-1,第二个if的y==1必然为假。但如果你只看控制流图,节点N2的真分支到N3,再到N5,再到N6;N2的假分支到N4,再到N7,再到N8,这些边看起来都是实际存在的。

问题在于,如果只盯着边对覆盖,要求覆盖N3到N7这条边对,也就是“第一个if走真分支、第二个if走假分支”,你会发现这个需求对应的执行路径是N1-N2(真)-N3-N5(假)-N7,其中N5的条件是y==1,而y=1的时候N5必然走真分支,所以这条边对永远不可能被执行到。

这时候,测试人员要做的不是设计用例去强行覆盖,而是判断这个TR不可行,在覆盖报告中剔除掉。如果不懂得这个概念,你会白白浪费大量时间在构造不存在的用例上。

4.3 到底该选哪一层覆盖标准

选覆盖标准,本质上是“成本和风险”的权衡。以我自己的项目经验来看:

  • 日常Web业务接口测试:节点覆盖+边覆盖就够,代码覆盖率工具默认能做到。
  • 核心支付逻辑、底层基础库:至少边对覆盖,有条件就上主路径覆盖。
  • 航空航天、医疗设备、自动驾驶这类安全关键系统:主路径覆盖几乎是硬指标,因为一小段路径的遗漏可能造成灾难性事故。

另外还要考虑代码的复杂性。一个函数如果有几十个分支,想做到主路径覆盖,测试用例量是爆炸式增长的。这时候更合理的做法是控制函数的圈复杂度,从代码设计层面降低测试成本。这也是为什么很多团队在Code Review的时候强调函数要短小、分支不要嵌套太深。

5. 常见问题与学习建议

5.1 新手最容易踩的几个坑

第一个坑是搞混“路径”和“测试用例”。路径是图里的节点序列,测试用例是具体的输入数据。一个测试用例可以覆盖多条路径里的节点,但不可能覆盖两条完全不同的主路径。计算覆盖率的时候始终围绕TR,不要直接用测试用例个数去衡量覆盖度。

第二个坑是忽略不可行路径。我在学习的时候,对着练习画主路径,发现有一条路径看起来合情合理,但怎么设计输入都走不到,后来才意识到那条路径上的两个判断条件自相矛盾。遇到这种情况,正确做法是标记为不可行TR并剔除,而不是硬凑。

第三个坑是过度追求100%覆盖率。覆盖率是质量指标,不是质量本身。一个覆盖率高的项目不能说没有bug,它只说明所有已定义的结构要素都被执行到了。我曾经见到一个团队把覆盖率卡到95%以上,结果核心业务逻辑没做边界值分析,上线就被用户打出bug。覆盖率和测试设计是两回事,别拿覆盖率当挡箭牌。

5.2 面试里图覆盖常见的考法

软件测试面试里,图覆盖相关的题目其实非常有套路,基本围绕这几个方向:

  • 概念题:节点覆盖、边覆盖、主路径覆盖的定义是什么,包含关系如何。
  • 设计题:给出一个函数,要求设计测试用例达到边覆盖或分支覆盖100%。
  • 判断改错题:给出一个覆盖率报告,问你为什么分支覆盖100%但还有bug。
  • 综合分析题:讲讲你在项目中如何评估测试是否充分。

备考的时候,不要死背定义,把例题亲手算一遍,把每次“覆盖什么TR”都写出来。这个过程比背二十道题都管用。

5.3 给自学者的学习路线建议

图覆盖不是软件测试的全部,它是白盒测试理论里的一个模块。如果你刚开始学软件测试,我建议按这个顺序来:

先掌握软件测试基础知识,包括测试流程、用例设计方法、测试文档规范;再学白盒测试的时候,把控制流图、图覆盖、逻辑覆盖这些概念一起学,因为它们高度关联;接着找一个开源项目,用覆盖率工具跑一遍,观察行覆盖率和分支覆盖率之间的差异;最后刷题巩固,把图覆盖的例题吃透。

另外,学习控制流图时,推荐自己动手画图。工具可以用draw.io或者Graphviz,手画也行,但一定要完整标记节点编号和分支条件。画图的过程就是在训练控制流分析能力,没有这个能力,后面学MC/DC这种更严格的标准会非常吃力。

最后再分享一个小技巧。你在学习图覆盖的时候,很可能遇到教材里那些F symmetric模式的图,例如包含两个节点、三个节点和多个环的抽象图。不要跳过去,哪怕只是照着书上的TR列表逐个验证一遍,也会让你对“简单路径”“主路径”的理解比光看定义深得多。图覆盖是一个需要动手算的领域,光看永远不如自己推导一遍来得扎实。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 1:13:07

PIC18F4620 与 MRAM 工业存储实战:SPI 驱动与掉电保护

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 1:12:24

Proteus中STM32F429/F407仿真难?用F401VE替代的完整移植指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 1:12:09

超节点上MoE模型部署实战:xDeepServe配置全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 1:11:53

六脚三位数码管驱动实战:从TM1650协议到VS调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 1:11:25

STM32 DMA配置完全指南:从原理到串口收发与ADC采集

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 1:11:25

AI落地检查清单:从案例集提炼可复用的工程化交付方法

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华