news 2026/9/15 17:57:54

图覆盖与软件测试充分性:从控制流图到覆盖准则全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
图覆盖与软件测试充分性:从控制流图到覆盖准则全解析

很多人刚开始学软件测试时,对“覆盖率”三个字的第一反应是:覆盖率越高,测试质量越好。这个想法方向没错,但很容易让一个人走进死胡同——因为只看百分比,根本说不清楚“缺口到底在哪里”。真正把测试思路打通、让我知道下一步该测什么的,是学习图覆盖那段日子。图覆盖不是某个工具一键生成的饼图,而是一套描述测试充分性的框架。软件测试里只要谈到白盒测试、结构测试、覆盖准则,几乎都会绕到图覆盖这套逻辑上来。

这篇是软件测试学习笔记的第二篇,我会用尽量少的公式、尽量多的例子,把节点覆盖、边覆盖、边对覆盖、主路径覆盖这些基础概念讲清楚,再延伸到数据流覆盖,最后聊一聊实际工作中怎么选覆盖准则,以及初学阶段最容易踩的坑。不管你是刚开始背软件测试面试题,还是已经写过一阵子自动化用例、正纠结“用例到底够不够”,这篇都适合慢慢读。

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 个节点:

节点编号节点内容
n0max_val = a
n1if b > max_val
n2max_val = b
n3if c > max_val
n4max_val = c
n5return 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 条:

  1. n0 → n1 → n2 → n3 → n4 → n5
  2. n0 → n1 → n2 → n3 → n5
  3. n0 → n1 → n3 → n4 → n5
  4. n0 → n1 → n3 → n5

这 4 条路径分别对应 4 种真实的程序行为:比如第 1 条是b > ac > 新最大值,第 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%”:因为公司不会为一个不可能的路径浪费测试成本,而在报表里把不可达路径标清楚,才是真正专业的表现。

如果你现在刚入门,别着急把所有准则都背下来,先把一段简单代码从“源码”转成“图”,再手工算一次边覆盖。这个过程走通了,后面再难的概念都只是换一层皮而已。图覆盖这套思维,值得你在学习软件测试的路上多花点时间。

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

douyin-downloader 完整指南:批量下载抖音无水印视频与主页作品

douyin-downloader 完整指南&#xff1a;批量下载抖音无水印视频与主页作品 【免费下载链接】douyin-downloader A practical Douyin downloader for both single-item and profile batch downloads, with progress display, retries, SQLite deduplication, and browser fallb…

作者头像 李华
网站建设 2026/9/15 17:54:14

DeepEval 怎么把 Qdrant 等向量数据库接入 RAG 评估流程?

DeepEval 怎么把 Qdrant 等向量数据库接入 RAG 评估流程&#xff1f; 【免费下载链接】deepeval The LLM Evaluation Framework 项目地址: https://gitcode.com/GitHub_Trending/de/deepeval 如果你的 RAG 系统用 Qdrant&#xff08;或 PGVector&#xff09;作为检索引擎…

作者头像 李华
网站建设 2026/9/15 17:53:51

豆包AI微信机器人接入实战:5分钟把微信机器人接上大模型

豆包AI微信机器人接入实战&#xff1a;5分钟把微信机器人接上大模型 【免费下载链接】wechat-bot &#x1f916; Multi-platform IM AI Agent for Telegram, WhatsApp, Lark, and WeChat. Connects ChatGPT / Claude / Kimi / DeepSeek / Ollama / Pi for auto-replies, commun…

作者头像 李华