UNSW-COMP3142-软件测试笔记-全-

UNSW COMP3142 软件测试笔记(全)

001:学期内容总览

在本节课中,我们将对COMP3142软件测试课程整个学期所学的核心概念进行一次全面的回顾。我们将按照学习的逻辑顺序,梳理从软件测试基础到高级算法的所有知识点。

软件测试导论

我们首先从软件测试的导论开始。在这一部分,我们学习了几个核心概念。

软件可信赖性是我们的终极目标。我们希望构建可信赖的软件,而可信赖性由四个属性构成:正确性可靠性安全性健壮性。理解这些属性之间的关系至关重要,例如,什么是正确但不可靠的软件,什么是安全但不健壮的软件。

我们还学习了验证确认的概念。简单来说,验证关注“我们是否正确地构建了产品”,而确认关注“我们是否构建了正确的产品”。验证更多是从工程师的角度思考软件质量,而确认更多是从用户或客户的角度。

此外,我们区分了动态分析静态分析。动态分析需要执行程序并观察其行为,而静态分析则通过扫描程序代码来寻找潜在的缺陷模式。

最后,我们学习了软件测试的六项基本原则。这些原则在实际测试技术中都有体现。例如,敏感性原则要求我们设计能够有效发现缺陷的测试预言;可见性和可观察性原则则通过覆盖率指标来帮助我们了解测试的进展。

功能测试

上一节我们介绍了测试的基础概念,本节中我们来看看功能测试。功能测试也称为黑盒测试,意味着我们不了解程序内部结构,只将其视为一个黑盒。

由于输入空间通常是无限的,功能测试技术的核心机制是根据特定规则对输入空间进行划分。

以下是基于边界值划分输入空间的技术:

  • 边界值测试:我们学习了该技术的两个关键特性,从而衍生出不同的变体。
    • 正常测试 vs. 健壮测试:正常测试只关心有效输入范围内的值;健壮测试则同时考虑无效输入。
    • 单缺陷假设 vs. 最坏情况:基于单缺陷假设,最多只有一个参数取无效值;若打破此假设,允许多个参数取无效值,则是最坏情况测试。

以下是基于等价类划分输入空间的技术:

  • 等价类测试:根据输入的等价类来划分输入空间。同样,它也有正常/健壮以及弱/强等价类测试等变体。

我们还学习了另一种功能测试技术:

  • 基于决策表的测试:决策表是一种可以建模程序因果关系的模型。原因对应程序输入,结果对应程序的行为或动作。关键概念包括(表头)、条目(表值)、条件(输入)和动作(输出)。决策表分为有限条目决策表(条目为布尔值)和扩展条目决策表(条目可为数值等其他类型)。

结构测试

功能测试属于黑盒测试,而如果我们能获得程序的源代码,就可以进行白盒测试,即结构测试。

结构测试技术的核心是关于我们使用的覆盖率指标。我们可以收集代码覆盖率,而本课程更关注与控制流图相关的覆盖率。

首先,回顾一下什么是控制流图。以下是几种主要的控制流覆盖率指标:

  • 语句覆盖:目标是执行程序中的所有语句。
  • 分支覆盖:目标是执行控制流图中所有的分支(即边)。分支通常出现在条件语句处。
  • 条件覆盖:目标是使程序中每个条件表达式的所有可能结果都至少被评估一次。条件覆盖比分支覆盖更全面。
  • 修正条件/判定覆盖:这是一种更严格的条件覆盖标准,被NASA等机构使用。
  • 路径覆盖:目标是覆盖控制流图中所有可能的执行路径。然而,路径覆盖存在一个著名的问题——路径爆炸。分支和循环是导致路径爆炸的主要来源。因此,对于复杂程序,通常无法达到100%的路径覆盖率。

为了简化问题,我们学习了一些基于路径覆盖的衍生指标:

  • 数据流覆盖:控制流覆盖关注是否能到达程序的某个点,而数据流覆盖则关注是以何种值到达该点。其核心概念是定义-使用对:一个值在何处被定义,又在何处被使用。

测试级别

在学习了黑盒和白盒测试技术后,我们接下来看看测试的级别。测试级别与软件开发生命周期紧密相关。

结合测试生命周期,我们得到了著名的V模型。它展示了开发阶段与测试阶段之间的对应关系。

不同级别的测试及其目标如下:

  • 单元测试:最接近代码实现的测试级别,用于测试详细设计和具体实现。通常由开发人员完成,也是测试驱动开发的基础。
  • 集成测试:测试软件架构,关注不同单元组合后的交互。集成策略有多种:
    • 基于分解的集成:按照需求对软件进行分解,包括自顶向下、自底向上和三明治集成。
    • 基于调用图的集成:根据函数间的调用关系图进行集成,例如成对集成(测试有调用关系的函数对)和邻域集成(测试某个函数邻域内的所有函数)。
    • 基于路径的集成:根据控制流图中的执行路径来集成组件。
  • 系统测试:将整个系统作为一个整体进行测试,以验证其是否满足需求。包括:
    • 基于模型的测试:使用模型(如决策表、有限状态机)来描述系统行为,并据此设计测试用例。
    • 非功能性系统测试:测试性能、可用性、兼容性等其他质量属性。

与测试级别相关的一个重要概念是测试脚手架。编写测试用例时通常包含以下部分:

  • 驱动程序:为执行目标单元提供上下文。
  • :模拟被测单元所依赖的、尚未实现或不关心的功能。
  • 输出比较:检查程序行为是否正确。
  • 清理:释放测试中使用的资源,或重置系统状态。

灰盒模糊测试

接下来,我们结合测试级别和测试技术的知识,学习了灰盒模糊测试。灰盒指的是对程序内部有部分了解,而模糊测试是一种系统测试技术。

首先,我们了解了黑盒模糊测试的两种类型:

  • 基于变异的模糊测试:给定初始种子,通过变异其内容来生成新的测试用例。
  • 基于生成的模糊测试:根据规则或模型(如语法)从头开始生成测试用例。

然后,我们学习了白盒模糊测试,它通常与符号执行混合执行相关。

  • 符号执行:使用符号而非具体值来表示变量,积累与输入相关的路径约束,并使用SMT求解器求解,以生成能到达特定程序状态的输入。
  • 缺点:对于复杂程序,约束可能过多,导致可扩展性差。

为了结合黑盒和白盒模糊测试的优点,我们引入了灰盒模糊测试。其核心机制是分而治之

灰盒模糊测试会收集程序执行的部分信息(如边覆盖率),但不像白盒测试那样收集全部运行时信息,从而保持了可扩展性。它通过反馈来指导测试用例的生成,逐步探索程序空间。

灰盒模糊测试涉及几种关键策略:

  • 种子优先级调度:决定接下来使用哪个种子进行测试。
  • 能量调度:决定使用当前种子生成多少个新的测试用例。
  • 变异操作符:决定如何对种子进行变异以生成新的测试用例。

一个著名的灰盒模糊测试工具是American Fuzzy Lop

软件复杂度

在课程的后半部分,我们首先学习了软件复杂度。我们通常假设软件越复杂,越可能存在缺陷。

与覆盖率类似,软件复杂度也有不同的度量指标:

  • 单元级复杂度
    • 圈复杂度:与程序的控制流图结构相关,计算公式为 边数 - 节点数 + 2。它反映了代码的判定复杂度。
    • Halstead 度量:考虑代码的体积,基于程序中不同运算符和操作数的数量及出现次数进行计算。
  • 集成级复杂度:可以对调用图应用圈复杂度等度量。
  • 面向对象程序的复杂度:涉及类、继承、耦合等特定概念的度量。

测试预言

测试预言用于判断测试用例应该通过还是失败。有些缺陷很容易被发现(如程序崩溃),但有些则不然,因此需要设计测试预言。

测试预言有以下几种类型:

  • 指定的预言:通常是人工创建的。
    • 基于模型的预言:例如,将系统建模为有限状态机,检查程序是否进入错误状态。
    • 期望值:在单元测试的断言中使用的预期结果。
    • 自检:在程序内部逻辑中添加断言。
  • 衍生的预言:可以自动获得。
    • 差分测试:使用多个实现相同功能的程序版本,比较它们的输出是否一致。
    • 回归测试:使用已有的测试用例来测试新引入的功能。
    • 蜕变测试:寻找并验证被测程序的蜕变关系(即输入输出之间应满足的某种关系)。
  • 隐式预言:任何程序自然具备的预言,例如程序崩溃肯定意味着有缺陷。可以使用净化器(如地址净化器)来提高隐式预言的敏感性。
  • 人工预言:由人来判断程序行为是否正确,例如通过众包平台进行。

变异测试

变异测试虽然名字听起来像一种测试技术,但它实际上是一种用于评估测试套件质量的特殊技术。

基本思想很简单:我们可以用测试用例来评估程序,也可以用程序来评估测试用例。变异测试通过创建程序的变体(即变异体)来评估测试用例。

关键概念包括:

  • 变异体:通过对原始程序进行微小改动(模拟开发者可能犯的错误)而创建的程序变体。
  • 杀死变异体:如果某个测试用例能导致变异体运行失败,则该变异体被“杀死”。
  • 存活变异体:能通过所有测试用例的变异体。
  • 变异得分被杀死的变异体数量 / 总变异体数量。得分越高,测试套件质量越好。

变异操作符决定了变异体的质量,它们应能模拟开发中可能出现的真实错误。

何时停止测试

“何时停止测试”取决于对程序可靠性的评估。我们认为软件可靠时,就可以停止测试。

因此,我们需要度量可靠性。可靠性意味着在观察期间程序能无故障运行。相关度量指标包括:

  • 需求失效概率失效的请求数 / 总请求数
  • 可用性系统正常运行时间 / 总时间
  • 失效发生率:观察期间内失效发生的频率
  • 平均失效间隔时间:两次失效之间的平均运行时间

我们可以通过统计测试来度量这些指标,即模拟真实的使用环境,收集用户使用剖面,并在此模拟环境中运行程序以收集数据。

组合测试

组合测试是一种可用于多个测试级别的技术,它主要测试程序的不同配置选项组合。

关键思想是使用 t-way 组合。由于选项组合的数量可能非常庞大,t-way组合限制了我们需覆盖的组合维度。例如,2-way组合要求覆盖所有可能的选项值对。

我们不需要为每个组合都创建一个测试用例,因为一个测试用例可以覆盖多个组合。我们使用覆盖数组来追踪每个测试用例覆盖了哪些组合,并以此最小化测试套件。计算最优覆盖数组是困难的,我们学习了使用贪心算法来近似求解。

对于大多数系统,t取2或3通常就足够了。

高级算法

最后,我们学习了一些可用于测试及相关问题的高级算法。

首先,我们将测试问题形式化为搜索与优化问题。测试可以被视为搜索合适的测试套件(搜索问题),并且在预算有限的情况下,我们需要寻找最优解而非完美解(优化问题)。

以下是几种相关的算法:

  • 自适应随机测试:一种启发式算法。如果之前的测试用例没有发现缺陷,则生成一个与之差异很大的新测试用例。
  • 遗传算法:一种元启发式算法,模拟生物进化过程。通过选择、交叉、变异等操作,使测试用例种群朝着优化目标进化。
  • ε-贪心算法:用于解决多臂赌博机问题。以大概率选择当前观测收益最高的“手臂”(策略),以小概率随机探索其他“手臂”,以平衡利用与探索。

总结

本节课中,我们一起回顾了本学期软件测试课程的所有核心概念。我们从软件测试的基础定义和原则出发,逐步深入到功能测试、结构测试、不同测试级别以及灰盒模糊测试等具体技术。随后,我们探讨了软件复杂度、测试预言、变异测试、停止测试的标准、组合测试,最后以将测试形式化为搜索优化问题并介绍相关高级算法作为结束。希望这次回顾能帮助你梳理和巩固过去两个月所学的知识,理解各个概念之间的联系,并在未来的学习和实践中继续运用这些思想。

002:高级测试算法(二)🎯

在本节课中,我们将学习另一种可用于测试的元启发式算法——多臂老丨虎丨机问题及其解决方案。我们将探讨如何将灰盒模糊测试问题映射到多臂老丨虎丨机问题,并学习一个简单而有效的算法:Epsilon-Greedy算法。最后,我们将通过实践例子来观察这些算法的运行效果,并了解影响其性能的关键因素。


元启发式算法回顾

上一节我们介绍了遗传算法,它是一种模拟生物进化过程的元启发式算法。本节中,我们来看看另一种元启发式算法。

启发式是基于经验或假设的技术,用于为特定问题提供解决方案。它们通常只适用于特定类型的问题。为了获得更通用的解决方案,我们使用元启发式算法,它是一种系统性地产生或指导其他启发式的方法。元启发式算法更为通用,可用于解决更广泛的问题。


多臂老丨虎丨机问题 🎰

多臂老丨虎丨机问题是一个经典的优化问题。想象有一台带有多个摇臂的老丨虎丨机(或者多台单臂老丨虎丨机)。拉动每个摇臂后,有一定概率获得奖励。每台机器的奖励概率是固定但未知的。我们的约束条件是,我们只有有限次数的拉动机会(代币)。目标是:在有限的尝试次数内,最大化获得的总奖励。

最优策略当然是每次都拉动奖励概率最高的那个摇臂。但问题在于,我们一开始并不知道哪个摇臂是最好的。因此,我们需要在探索利用之间进行权衡。

  • 探索:尝试不同的摇臂,以收集关于每个摇臂奖励分布的信息。
  • 利用:利用当前已知的最佳摇臂,以最大化即时奖励。

如果探索过多,可能会浪费资源在较差的摇臂上。如果利用过多,则可能因为没有充分探索而错过真正的最佳摇臂。


与灰盒模糊测试的关联

现在,让我们思考为什么这个问题与测试,特别是灰盒模糊测试相关。

我们可以将多臂老丨虎丨机问题映射到模糊测试中:

  • 奖励:在模糊测试中,奖励可以看作是达到的代码覆盖率
  • 摇臂/机器:这可以对应两样东西:
    1. 种子:每个种子(测试用例)可以被视为一个摇臂。执行(变异)该种子生成新测试用例时,有可能获得覆盖率奖励。
    2. 变异操作符:每个变异操作符也可以被视为一个摇臂。每次应用该操作符生成测试用例,都有可能获得覆盖率。
  • 尝试次数/代币:这可以是程序执行的总次数,或者模糊测试活动的总时间(被分割成小的时间单元)。

给定总资源(如执行次数),我们可以用一部分进行探索(找出哪个种子或变异操作符最好),然后用剩余资源去利用那个最好的选择。

需要注意:这种映射并非完美。在多臂老丨虎丨机中,每个摇臂的奖励概率是固定的。但在模糊测试中,随着一个种子被反复使用,基于它获得新覆盖率的概率会随时间下降。不过,为了简化课程内容,我们假设这个映射是有效的。


Epsilon-Greedy 算法

解决多臂老丨虎丨机问题的一个简单算法是 Epsilon-Greedy 算法。从名称可以看出,它是一个贪婪算法。

该算法允许代理在每一步选择是探索还是利用。其核心思想非常简单:

  • 以一个小的概率 ε,随机选择一个摇臂(探索)。
  • 1-ε 的概率,选择当前观测中平均奖励最高的那个摇臂(利用)。

其中,ε 通常是一个介于 0 和 1 之间的小数(例如 0.1)。

以下是该算法的步骤描述:

  1. 初始化:为每个摇臂的估计奖励值进行初始化(例如设为0)。
  2. 定义参数:设定 ε 的值。
  3. 循环执行直到资源耗尽
    • 生成一个随机数。
    • 如果随机数 < ε,则随机选择一个摇臂。
    • 否则,选择当前估计奖励值最高的摇臂。
    • 拉动选中的摇臂,获得奖励(0或1)。
    • 更新该摇臂的估计奖励值(例如,更新为历史奖励的平均值)。

算法的优缺点

优点

  • 非常简单,易于实现。
  • 为平衡探索与利用提供了一个直接的方法。
  • 可以通过调整 ε 值来适应不同问题。

缺点

  • ε 值在运行过程中是固定的,无法动态调整,可能不够自适应。
  • 存在更高级的算法(如置信上界算法、汤普森采样),可以更自适应地处理探索与利用的平衡。

算法实践与参数影响

为了加深理解,让我们通过两个例子来观察算法运行和参数的影响。

实践一:遗传算法示例

以下是使用遗传算法猜测一个随机字符串的例子。我们可以观察不同参数如何影响性能。

# 伪代码概述
1. 设置种群大小、进化代数和变异率。
2. 初始化:生成一个由随机字符串组成的种群。
3. 对于每一代:
   a. 评估每个字符串的适应度(与目标字符串匹配的字符数)。
   b. 如果找到完全匹配的字符串,则终止。
   c. 选择父母字符串(基于适应度)。
   d. 通过交叉操作产生后代字符串。
   e. 以一定概率对后代进行随机变异。
   f. 用新种群替换旧种群。

参数影响观察

  • 目标字符串长度:目标越长,猜中所需代数通常越多。
  • 种群大小
    • 太小(如10):种群多样性不足,可能无法找到解。
    • 太大(如10000):好的“父母”个体相遇交叉的机会可能变小,效率降低,甚至可能导致超时。
    • 需要选择一个适中的大小以保持多样性且有效率。

实践二:Epsilon-Greedy 算法示例

以下是使用 Epsilon-Greedy 算法玩一个五臂老丨虎丨机的例子。每个臂的真实奖励概率是随机设定的。

# 算法核心逻辑
if random.random() < epsilon:
    chosen_arm = random.choice(arms)  # 探索
else:
    chosen_arm = max(arms, key=lambda arm: estimated_rewards[arm])  # 利用

参数 ε 的影响

  • ε 值适中(如0.1):能较好地平衡探索与利用,通常能准确识别出最佳摇臂,并获得接近最优的总奖励(最佳概率 * 总尝试次数)。
  • ε 值过大(如0.8):探索过多。虽然可能仍能准确估计哪个摇臂最好,但用于利用最佳摇臂的次数太少,导致总奖励降低,可能接近随机策略的结果。
  • ε 值过小(如0.01):探索不足。可能无法发现真正的最佳摇臂,而错误地利用了一个次优摇臂,导致总奖励甚至可能低于随机策略。

作业二说明 🛠️

本节课的最后部分,我们将简要说明课程作业二(Assignment 2)的要点。

作业二总分为60分,主要分为两部分:

  1. Pytest 实践(20分):练习使用 Pytest 框架中的夹具和模拟功能进行单元测试。
  2. 模糊测试器实现(40分):在一个名为 Minilo(仿 AFL 的 Python 版灰盒模糊测试框架)中实现四个缺失的核心功能,每个功能10分。

需要实现的四个功能是:

  1. 全局覆盖率跟踪:维护所有种子覆盖到的全局边覆盖率状态。
  2. 种子优先级策略:决定从种子队列中选择哪个种子进行下一次变异。
  3. 能量调度策略:决定选定一个种子后,基于它生成多少个新测试用例。
  4. 随机变异操作符:实现对种子内容进行随机变异的操作符。

如何验证正确性:运行你的实现,并与提供的默认实现(未加新功能)进行比较。如果你的实现能在相同时间内获得更高的代码覆盖率,则说明实现很可能是正确的。对于提供的测试程序 mjs,一个正确的实现大约在15分钟内能达到约3000条边的覆盖率,并可能发现一些程序崩溃。

环境设置提示:作业使用 Docker 提供统一的 Linux 测试环境。你可以在宿主机上编辑代码,并通过目录映射在 Docker 容器中运行。如果遇到初始种子运行超时的问题,可以尝试修改代码中的超时时间常量。


总结

本节课中我们一起学习了:

  1. 多臂老丨虎丨机问题:一个需要在探索未知和利用已知之间取得平衡的优化问题。
  2. 与模糊测试的关联:将种子或变异操作符视为摇臂,将代码覆盖率视为奖励,从而将模糊测试资源分配问题映射到多臂老丨虎丨机问题。
  3. Epsilon-Greedy 算法:一个简单有效的解决方案,以概率 ε 进行探索,以概率 1-ε 进行利用。
  4. 参数影响:通过实践例子,我们看到了遗传算法中种群大小、以及 Epsilon-Greedy 算法中 ε 值对算法性能的关键影响。
  5. 作业指引:了解了作业二的主要任务、实现功能和验证方法。

希望这些知识能帮助你理解高级测试算法,并顺利完成作业。下周我们将对本课程的所有内容进行回顾。

003:高级测试算法(第一部分)

在本节课中,我们将学习如何将软件测试问题转化为搜索或优化问题,并介绍两种基于此思想的高级测试算法:自适应随机测试和遗传算法。通过学习这些算法,你将理解如何更高效、更自动地生成测试用例。


将测试视为搜索问题

上一节我们介绍了软件测试的基本流程。本节中,我们来看看如何将测试过程形式化。

软件测试的核心是生成输入、运行程序并观察结果。但我们生成输入并非毫无目的。我们通常希望测试用例能够发现缺陷、覆盖代码结构(如语句、分支、路径)或执行特定的数据流对。这些目标都是可衡量的。

由于程序的输入空间几乎是无限的,而我们希望从中找到一个能满足特定目标(如高覆盖率)的测试用例集合。这就像在一个巨大的空间中寻找一个合适的物品。因此,测试可以被描述为一个搜索问题:我们在近乎无限的输入空间中,搜索一个能满足特定属性的测试套件。

因为测试目标可以衡量,并且我们可以自动生成候选解决方案(即测试用例),所以我们有可能编写程序来自动化整个搜索过程。大多数基于搜索的测试技术都是完全自动化的。

一个典型的搜索过程如下:

  1. 随机或按策略选择一个候选解决方案(测试套件)。
  2. 检查该方案是否满足目标。
  3. 如果不满足,则尝试另一个方案。
  4. 重复此过程,直到达成目标或穷尽所有可能。

在这个过程中,选择尝试解决方案的顺序至关重要,它直接影响找到满意方案的效率。用于做出这种决策的策略被称为启发式方法


启发式、效率与有效性

在讨论具体算法前,我们需要区分两个关键概念:效率与有效性。

  • 效率:指完成任务的速度。在测试中,体现为达到目标(如特定覆盖率)的收敛速度。
  • 有效性:指任务完成的质量。在测试中,体现为最终能达到的最佳性能(如最大覆盖率)。

启发式方法(例如,种子优先级的排序策略)主要影响效率,即我们多快能找到好的测试用例。但它通常不影响最终的有效性。因为只要有足够的时间,我们最终会尝试所有相关的候选方案,顺序并不改变最终能探索到的空间上限。它只改变我们到达那个上限的速度。

我们之前学习过的一些算法也包含了启发式思想,例如:

  • 广度优先搜索:逐层探索所有节点。
  • 深度优先搜索:沿一条路径深入探索再回溯。
  • A 搜索*:一种贪心算法,使用评估函数(启发式)估计到目标的距离,并选择当前看来最优的路径。

将搜索问题转化为优化问题

在无限输入空间中寻找理想测试套件是不现实的。因此,我们需要引入约束预算来限定搜索范围。

常见的搜索预算包括:

  • 时间预算:例如,只运行模糊测试5小时。
  • 尝试次数预算:例如,只执行目标程序10000次。

在预算限制下,我们往往无法找到全局最优解(例如,绝对无缺陷的程序)。因此,问题从“寻找满足布尔条件(是/否)的最佳方案”转变为“在预算内寻找最优的方案”。这便将搜索问题转化为了一个优化问题

优化问题的目标是,在给定预算内,从所有能尝试的候选方案中找到评估分数最高的那个。这个评估分数可以覆盖率、发现的缺陷数等。

由于预算有限,搜索时所采用的启发式策略变得尤为重要,它直接决定了我们能在有限资源内找到多好的局部最优解。


自适应随机测试

最朴素的搜索策略是完全随机测试。它随机生成方法调用、参数和输入。其优点是实现简单、快速,且能避免设计者偏见。但缺点也很明显:缺乏方向性,效率和有效性通常较低。

为了提高随机测试的效率,我们引入自适应随机测试的核心思想:促进输入多样性。其基本假设是:

  1. 触发缺陷的输入在输入空间中通常是稀疏的。
  2. 如果一个输入未能触发缺陷,那么与其相似的输入很可能同样无效。
  3. 因此,在选择下一个测试输入时,应选择与已执行过的、未成功的测试用例差异最大的那个。

以下是实现此思想的一种算法:固定大小候选集自适应随机测试

该算法维护两个集合:

  • 已执行集:存放所有已执行但未达成目标的测试用例。
  • 候选集:存放一批已生成但未执行的测试用例。

算法关键步骤如下:

  1. 随机生成一个测试用例并执行。
  2. 生成一个包含 N 个随机测试用例的候选集
  3. 从候选集中,选择一个与已执行集中所有测试用例“距离最远”的候选用例执行。
  4. 如果该候选用例仍未达成目标,则将其加入已执行集
  5. 重复步骤2-4。

这个算法的关键在于如何定义和计算“距离”。有两种常用策略:

  • 最大最小距离法:对于每个候选用例,计算它与已执行集中最近用例的距离,然后选择这个距离值最大的候选。
    • 候选得分 = min( distance(候选, 已执行用例1), distance(候选, 已执行用例2), ... )
  • 最大总和距离法:对于每个候选用例,计算它与已执行集中所有用例的距离之和,然后选择和最大的候选。
    • 候选得分 = sum( distance(候选, 已执行用例1), distance(候选, 已执行用例2), ... )

距离的计算方式取决于输入数据的类型:

  • 对于数值,可使用欧几里得距离distance = sqrt( (x1-x2)^2 + (y1-y2)^2 + ... )
  • 对于字符串,可使用编辑距离等。

自适应随机测试通过生成多于执行数量的候选用例,并精心挑选执行对象,旨在用更少的执行次数获得更高的输入多样性,从而提升效率。


基于区域的改进算法

在自适应随机测试的基础上,发展出一些改进策略,如基于区域的算法

其核心思想是定义排除区域。围绕每个已执行但未成功的测试用例,划定一个区域(如一个圆形范围)。在选择下一个测试输入时,只从这些排除区域之外的区域中随机选择。

为了应对随着测试进行,排除区域可能覆盖大部分输入空间的问题,算法会动态缩小排除区域的半径,确保始终有空间可供选择。

另一种改进是动态分区。算法将输入空间动态划分为多个区域,并始终从当前最大的未探索区域中选择测试输入。这确保了测试用例能相对均匀地分布在整个输入空间,避免了某些区域被完全忽略(“饥饿”现象)。

理论上,这些基于区域的算法能比基础的自适应随机测试更有效地保证测试输入的多样性和空间覆盖的均匀性。


遗传算法

前面介绍的算法都依赖于人为设计的启发式规则。那么,能否让算法自动寻找好的启发式呢?这就引出了元启发式算法。元启发式是为寻找启发式规则而设计的更高层策略。

如果我们能为每个测试用例计算一个适应度分数(如代码覆盖率),那么测试生成问题就可以明确地定义为一个优化问题:寻找适应度分数最高的测试用例。遗传算法就是一种强大的元启发式优化算法。

遗传算法受生物进化论启发,其核心思想是:

  1. 维护一个由多个个体(测试用例)组成的种群
  2. 通过适应度函数评估每个个体的优劣。
  3. 让优秀的个体有更高概率“繁殖”后代,淘汰劣质个体。
  4. 在繁殖过程中引入交叉变异操作,以保持种群多样性并探索新可能。
  5. 迭代多代,使种群的整体适应度不断提高。

算法基本流程如下:

  1. 初始化:随机生成初始种群(一组测试用例)。
  2. 循环直到满足终止条件(如达到指定代数或找到满意解):
    a. 选择:根据适应度分数,从当前种群中选择优秀的个体作为“父母”。
    b. 交叉:将两个父母的基因(测试用例的部分)组合,生成新的“后代”测试用例。
    c. 变异:以小概率随机改变后代测试用例的某些部分。
    d. 评估:用适应度函数计算新后代的分数。
    e. 生存选择:从新旧个体中,选择适应度最高的一批个体组成下一代种群。

在测试领域的成功应用是 EvoSuite 框架,它能自动为Java程序生成单元测试,并证明了遗传算法在此类问题上的强大有效性。

遗传算法的思想也与现代灰盒模糊测试(如AFL)的设计有相似之处:

  • 种子队列 类似于种群。
  • 变异操作(如 havoc)类似于变异。
  • 交叉操作 类似于交叉。
  • 通过种子优先级来“偏爱”某些种子,而不是直接淘汰,这与遗传算法中的选择压力类似。
  • 主要使用时间而非代数作为终止条件。

总结

本节课中我们一起学习了如何将软件测试重新定义为搜索与优化问题,并介绍了两种基于此思想的高级算法。

首先,我们了解到测试可以被视为一个搜索问题,因为我们需要从巨大的输入空间中寻找满足特定目标的测试套件。由于资源有限,我们进一步将其转化为优化问题,即在预算内寻找局部最优解。

接着,我们学习了自适应随机测试及其变体。它们通过有策略地选择与已失败测试差异最大的新输入,来提升测试输入的多样性,从而更高效地探索输入空间。

最后,我们探讨了遗传算法这一元启发式方法。它模拟自然选择的过程,通过交叉、变异和适应度选择,迭代地进化出一组高质量的测试用例,在单元测试生成等领域取得了显著成功。

理解这些算法背后的思想,不仅能帮助我们更好地进行测试,也为我们解决其他类型的搜索与优化问题提供了思路。

004:组合测试与何时停止测试

在本节课中,我们将学习本课程的最后一个测试技术——组合测试,并探讨一个在实际项目中至关重要的问题:何时停止测试。

组合测试 🧩

上一节我们介绍了多种测试技术,本节中我们来看看组合测试。从名称可以看出,其核心在于“组合”。在软件中,组合通常出现在两个场景:一是函数输入参数的多种取值组合,二是软件配置选项的不同设置组合。

组合测试是一种测试技术,旨在系统地检查系统输入(如用户参数或配置选项)的所有或部分组合。它也被称为 t-way 测试,其中 t 代表交互级别。

组合爆炸问题 💥

当我们尝试覆盖所有参数组合时,可能会面临“组合爆炸”问题。例如,对于 n 个参数进行两两组合(t=2),需要覆盖的组合数为 C(n, 2) = n * (n-1) / 2。随着参数数量 n 或交互级别 t 的增加,组合数量会急剧增长,使得穷尽测试变得不切实际。

那么,如何确定一个合适的 t 值呢?根据美国国家标准与技术研究院(NIST)的研究报告,大多数软件缺陷(约70%)是由单个参数或两个参数的交互引发的。将 t 设置为 3 通常可以覆盖近90%的缺陷。因此,在实践中,进行 两两组合测试(t=2)三三组合测试(t=3) 通常就足够了,这能极大地减少测试用例数量。

覆盖数组 📊

为了高效地进行组合测试并减少冗余,我们引入“覆盖数组”的概念。覆盖数组是一个 n × k 的矩阵,其中 n 是测试用例数,k 是参数个数。每个单元格代表特定参数在某个测试用例中的取值。

其目标是,在给定的交互级别 t 下,用最少的测试用例覆盖所有可能的参数值组合。一个覆盖数组可以表示为 CA(N; t, k, v),其中:

  • N: 测试用例数
  • t: 交互级别
  • k: 参数个数
  • v: 每个参数的可能取值数(为简化,常假设各参数 v 值相同)

以下是使用覆盖数组的一个简单示例。假设有4个参数(P1, P2, P3, P4),每个参数可取值为0或1(v=2),目标是进行两两组合测试(t=2)。虽然穷举所有组合需要16个测试用例,但通过精心设计的覆盖数组,仅需6个测试用例即可覆盖所有可能的参数对(共6对)的每一种值组合(如00, 01, 10, 11)。

生成覆盖数组的算法 🤖

如何生成这样一个高效的覆盖数组呢?这本身是一个复杂问题。一种相对简单的方法是使用贪心算法

贪心算法的核心思想是迭代地选择测试用例。在每一步,都选择那个能在当前已覆盖组合的基础上,新增覆盖组合数量最多的测试用例。

以下是使用贪心算法构建覆盖数组的简化步骤:

  1. 从一个随机测试用例开始(例如,所有参数值为0)。
  2. 计算当前已覆盖的参数组合。
  3. 寻找一个未使用的测试用例,使其能覆盖最多尚未被覆盖的参数组合。
  4. 将该测试用例加入数组,更新已覆盖组合集合。
  5. 重复步骤3和4,直到所有需要的组合都被覆盖。

贪心算法的优点是速度快、实现简单,并且通常能提供接近最优的解。然而,它可能无法生成全局最小的覆盖数组,尤其在参数多、取值复杂或 t 值较大时。对于更复杂的情况,可能需要使用模拟退火、遗传算法等元启发式方法。

组合测试的局限性 ⚠️

尽管组合测试非常强大,但它也存在一些局限性:

  1. 组合爆炸:即使 t 值较小,当参数数量非常多时,组合数依然可能很大。
  2. 参数约束:实际系统中,某些参数组合可能无意义或不可能出现(例如,互斥的选项)。生成测试用例时需要识别并排除这些无效组合。
  3. 缺陷类型:组合测试擅长发现由参数间交互引发的缺陷,但可能遗漏与程序内部状态、特定计算路径或非交互性逻辑相关的缺陷。

何时停止测试 🛑

我们已经学习了众多测试技术,但测试不能无限进行下去。何时停止测试是一个需要科学决策的问题。我们可以依据多种标准来设定停止测试的准则。

停止测试的常见准则

以下是几种常见的停止测试准则:

  • 需求覆盖:所有根据需求编写的测试用例均已通过。
  • 覆盖率指标:达到了预定的代码覆盖率目标(例如,语句覆盖率 > 90%)。
  • 预算/计划:测试时间或经费已耗尽。
  • 软件可信性:软件达到了足够的可信赖程度。这是最理想但也最难以量化的准则。

软件可信性包括正确性、可靠性、安全性和健壮性。其中,可靠性因其可测量性,常被用作评估是否停止测试的核心依据。

可靠性的度量 📈

可靠性是指在特定环境中、特定时间段内无故障运行的概率。我们可以通过以下指标来量化它:

  1. 需求失效概率(POFOD):计算公式为 失效次数 / 总请求次数。它衡量每次服务请求遭遇失败的概率,与请求频率无关。
  2. 可用性(Availability):计算公式为 系统正常运行时间 / 总时间。它衡量服务在时间维度上的可获取程度,将系统重启、修复时间计入宕机时间。
  3. 故障发生率(ROCOF):计算公式为 失效次数 / 总运行时间。它衡量单位时间内发生故障的频率,适用于请求持续不断的系统。
  4. 平均失效间隔时间(MTBF):计算公式为 总运行时间 / 失效次数。它衡量两次故障之间的平均时间长度。

为了计算这些指标,我们需要在测试过程中收集数据,例如:请求总数、失效次数、每次失效的时间戳、系统总运行时间等。

基于可靠性的统计测试 🧪

为了评估可靠性,我们可以进行统计测试。其关键是根据运行剖面来生成测试用例。运行剖面描述了软件在真实世界中被使用的各种方式及其概率分布。

基于运行剖面的统计测试步骤如下:

  1. 建立运行剖面:通过分析现有类似系统或进行 Beta 测试,收集用户实际使用模式的数据。
  2. 生成测试输入:根据运行剖面中不同输入类型的概率分布,构造测试用例集。
  3. 执行测试并收集数据:运行系统,使用生成的测试输入,并记录失效情况及相关时间数据。
  4. 计算可靠性指标:根据收集的数据,计算 POFOD、可用性、ROCOF 或 MTBF。
  5. 判断是否达标:将计算出的指标与预先设定的可接受阈值进行比较。若达标,则可停止测试;否则,需修复缺陷后继续测试。

统计测试的挑战 🤔

基于运行剖面的统计测试也面临挑战:

  • 剖面准确性:获取真实、准确的运行剖面非常困难,尤其对于全新系统。
  • 剖面变化:用户行为和使用模式会随时间变化。
  • 测试成本:模拟真实使用场景可能需要海量测试输入,生成和执行成本高。
  • 统计不确定性:在极高可靠性的系统中,可能长时间观测不到失效,这使得难以精确评估其可靠性,也无法区分是“系统真的无缺陷”还是“测试不够充分”。

尽管有这些挑战,通过持续测试和改进,系统的故障率通常会呈现下降趋势。当观测到的故障率低于某个可接受的水平时,我们就可以有信心地停止测试并发布软件。

总结 ✨

本节课我们一起学习了两个重要主题。

首先,我们深入探讨了组合测试。我们了解到,组合测试通过系统性地检查输入参数或配置选项的组合来发现缺陷,其核心是 t-way 交互覆盖。为了应对组合爆炸并提高效率,我们引入了覆盖数组的概念,并简要介绍了使用贪心算法来构建覆盖数组的思路。同时,我们也认识到组合测试在参数约束和缺陷检测类型方面的局限性。

其次,我们讨论了何时停止测试这个实践性极强的问题。我们回顾了多种停止准则,并重点学习了以软件可靠性作为判断依据的方法。我们介绍了几个关键的可靠性度量指标:需求失效概率(POFOD)、可用性、故障发生率(ROCOF)和平均失效间隔时间(MTBF)。最后,我们探讨了通过建立运行剖面进行统计测试来评估可靠性,并以此决定测试终点的完整流程及其面临的挑战。

掌握这些知识,将帮助你在未来的工作中更有效、更科学地进行测试,并在合适的时机做出发布决策。

005:变异测试 🧬

在本节课中,我们将学习一种评估测试用例质量的技术——变异测试。我们将了解其核心概念、如何生成变异体、评估测试套件的有效性,并探讨其在实际应用中的考量。


概述

上一节我们介绍了基于控制流的测试覆盖准则。然而,即使达到100%的路径覆盖,测试套件仍可能无法发现某些潜在缺陷。这引出了一个核心问题:如何系统地评估测试用例的质量?本节我们将学习变异测试,它通过创建程序的微小变体(变异体)来评估测试套件的有效性。

变异测试的核心思想

变异测试的基本思想是:为了评估测试用例,我们可以创建目标程序的变体(称为“变异体”),然后看测试用例是否能“杀死”这些变异体。这里的“杀死”借鉴了生物学概念,指测试用例能发现变异体与原始程序的行为差异。

以下是其形式化定义:

  • 给定 一个原始程序 P,一个通过微小修改 P 得到的变异体 P‘,以及一个测试用例集合 T
  • 杀死变异体:如果 T 中的所有测试用例在 P 上都能通过,并且至少有一个测试用例在 P‘ 上失败,则称变异体 P‘ 被测试套件 T 杀死。
  • 存活变异体:如果 T 中的所有测试用例在 PP‘ 上都能通过,则称变异体 P‘ 是存活的。
  • 变异分数:用于量化测试套件的质量,计算公式为:
    变异分数 = (被杀死的变异体数量) / (变异体总数)
    变异分数越高,表明测试套件发现潜在缺陷的能力越强。

如何生成变异体:变异算子

为了生成变异体,我们使用称为“变异算子”的规则,对原始程序进行微小的语法修改。这些算子旨在模拟开发者常犯的简单错误。你无需记住所有算子,只需了解其存在和基本类型即可。

以下是常见的变异算子类型:

  • 操作数与表达式修改

    • 操作数替换:例如,将常量替换为变量,或将变量替换为另一个变量。
    • 算术运算符修改:替换(如 +-)、插入(如 x 前插入 ++)或删除运算符。
    • 条件/关系运算符修改:修改逻辑运算符(&&, ||)、关系运算符(>, >=, ==)等。
  • 语句修改

    • 语句删除:删除某条语句。
    • 语句替换:例如,在 switch 语句中交换 case 块。
    • 代码块边界移动:移动 if、循环或函数体的结束括号(在C语言中)或缩进(在Python中)。
  • 面向对象程序修改

    • 封装相关:修改访问修饰符(public, private, protected),或插入/删除隐藏变量。
    • 继承相关:删除覆盖方法、修改 super 关键字的使用、重命名覆盖方法等。
    • 多态相关:修改类型声明或类型转换,例如将父类引用改为子类引用,或反之。

变异测试的可行性与假设

变异算子主要模拟简单的语法错误,但现实中的缺陷可能源于复杂的逻辑误解。因此,变异测试的有效性依赖于两个关键假设:

  1. 称职程序员假设:假设被测试的软件已接近正确实现,程序员没有严重误解需求或遗漏主要功能。
  2. 耦合效应假设:假设复杂的缺陷可以由一系列简单的语法错误组合而成。因此,能检测简单错误的测试用例,也可能间接检测出复杂的逻辑错误。

变异体的质量与测试充分性

一个有用的变异体必须满足两个条件:

  1. 有效:变异体必须能够成功编译和执行。
  2. 有用:变异体在语义上不应与原始程序等价。语义等价的变异体无法提供任何关于测试有效性的信息。

测试套件对某个变异“不充分”可能有两个原因:一是该变异体与原始程序语义等价;二是测试用例未能触发导致行为差异的执行路径。

实践中的考量:强变异与弱变异

在实际应用中,为每个变异算子生成一个独立的变异体并执行全部测试(称为强变异测试)成本非常高。

为了降低成本,可以采用弱变异测试

  • 它将多个变异算子应用于同一个程序,生成一个“元变异体”。
  • 在执行测试时,不仅比较最终输出,还监控程序执行过程中的中间状态(如变量的值、执行的分支)。
  • 一旦发现某个代码段的中间状态与原始程序不同,就认为“杀死”了所有导致该差异的变异体。
  • 这种方法减少了需要完整执行的程序副本数量,但需要工具支持以监控中间状态。

变异测试与基于变异的模糊测试

请注意,变异测试 与之前学过的 基于变异的模糊测试 虽然名称相似,但目的截然不同:

  • 基于变异的模糊测试:是一种生成测试输入的技术。它通过随机变异已有的“种子”输入来创建新的测试用例,目的是发现程序漏洞。
  • 变异测试:是一种评估测试用例质量的技术。它通过创建程序的变异体来评估测试套件发现缺陷的能力。

有趣的是,变异测试可以用来评估基于变异的模糊测试所生成测试用例的质量。已有研究将变异测试作为基准,来系统化地衡量不同模糊测试工具的效果。

总结

本节课我们一起学习了变异测试。我们从如何评估测试用例质量的问题出发,引入了变异测试的核心思想:通过创建程序的微小变异体,并计算测试套件能“杀死”多少变异体来评估其有效性。我们介绍了用于生成变异体的各种变异算子,讨论了保证变异测试可行的两个关键假设,并区分了强变异与弱变异两种实践方法。最后,我们明确了变异测试与基于变异的模糊测试的本质区别。变异测试为我们提供了一种超越代码覆盖率的、系统化衡量测试套件质量的强大工具。

006:测试预言

在本节课中,我们将要学习软件测试中的一个重要概念——测试预言。我们将了解它的定义、组成部分、不同类型以及在实际测试中的应用。

概述

测试预言是判断程序输出是否可接受的标准。它决定了测试用例的通过或失败。我们将探讨测试预言与规格说明的关系,学习四种主要的测试预言类型,并了解如何在实践中应用它们。

测试预言的定义与回顾

上一节我们介绍了测试脚手架,本节中我们来看看测试预言。在之前的课程中,我们已经接触过测试预言。例如,在测试脚手架代码中,assert 断言语句就是测试预言。它们用于比较程序的输出。

测试预言是一个谓词,用于判断系统输出是否可接受。它决定了测试用例的通过或失败。这与日常生活中的“预言”概念相似,但在测试中,它特指判断程序行为正确与否的机制。

测试预言与规格说明

测试预言与规格说明密切相关,但两者并不等同。规格说明是程序行为的高层描述,而测试预言是针对特定输入判断其输出是否正确的具体标准。测试预言可以从规格说明中派生,但它更为具体。

测试预言的组成部分

测试预言包含两个主要组成部分:

  1. 预言信息:用于判断实现正确性的信息,可以理解为程序的预期输出值。
  2. 检查过程:将实际输出与预期值进行比较的操作。这个过程可以非常简单,例如使用等式检查。

由于测试预言通常以代码形式实现,因此它们也需要被开发。与项目代码类似,测试预言也可能包含错误。一个错误的预言可能导致误报或漏报。因此,我们需要像验证程序代码一样,对测试预言进行轻量级的验证,例如代码审查。

设计测试预言的挑战

在设计测试时,我们擅长为程序生成新的输入,但很难确定预期的输出结果。例如,对于一个计算斐波那契数列的函数,我们可以轻松给出输入值 100,但很难手动计算出 fib(100) 的正确结果。

最常见的做法是开发者手动编写预言信息,并将其与特定的测试用例绑定。这种方法需要大量的手动工作和时间,而我们希望实现测试自动化。因此,存在多种自动生成测试预言的方法。

测试预言的质量属性

理想的测试预言是精确指定系统的预期输出,但这非常耗时。我们可以在精确性和通用性之间进行权衡。例如,我们可以指定函数应遵循的某些属性,而不是具体的输出值。

测试预言有几个关键的质量属性:

  • 完备性:一个测试预言如果能验证任何测试输入,则被认为是完备的。
  • 可靠性:一个测试预言如果对其能处理的任何测试用例都能给出全局正确的判断,则被认为是可靠的。
  • 正确性:一个测试预言如果既完备又可靠,则是正确的;如果可靠但不完备,则是部分正确的。

在实践中,我们需要在构建成本、准确性和完备性之间进行权衡。

测试预言的类型

以下是实践中常用的四种测试预言类型:

1. 规定预言

规定预言使用程序的规格说明或文档来判断程序行为。这是最常见的预言类型。例如,单元测试中的断言通常就是规定预言,因为它们是开发者根据高层规格说明手动创建的。

规定预言有以下几种变体:

  • 预期值预言:最简单的形式,检查特定输入对应的预期输出值。缺点是通常不可重用。
  • 自检断言:将行为检查(断言)嵌入到程序的实际实现代码中,而不是测试用例里。在测试中,我们只需观察断言是否失败。这提高了预言的可重用性。

我们可以在函数级别或系统级别设计自检断言。在系统级别,我们可以使用基于模型的测试。例如,可以使用决策表或有限状态机等模型来设计测试预言。当程序进入错误状态时,测试就失败。

2. 派生预言

当没有规格说明或完整文档时,可以从程序代码或其他现有信息源自动派生测试预言。

以下是几种派生预言的技术:

  • 差异测试:对具有相似功能的两个(或多个)程序版本运行相同的输入,并比较它们的输出。如果输出不同,则至少有一个实现是错误的。
  • 回归测试:确保程序的新更新或更改不会对现有功能产生不利影响。其基本思想是,在更改程序后,重新运行所有现有测试。旧版本的程序及其通过的测试用例就充当了预言的角色。
  • 蜕变测试:当程序的预期输出难以确定或未知时,这种方法非常有效。它利用蜕变关系——一种关联多个输入和输出的属性。通过对输入数据进行符合蜕变关系的变换,并比较输出,来判断程序是否正确。例如,对于 sin(x) 函数,我们知道 sin(x) == sin(π - x)。我们可以用输入 1π-1 测试该函数,并检查输出是否相等,而无需知道 sin(1) 的具体值。
  • 不变式检测:不变式是在程序执行过程中某些特定点始终保持为真的属性或条件。例如,循环不变式。我们可以从代码中提取不变式,并将其用作测试预言。如果这些本应不变的性质发生了改变,则表明程序存在错误。

3. 隐式预言

隐式预言不需要任何领域知识或规格说明,它们基于任何可运行程序都应遵循的通用属性来检测异常。最常见的例子是程序崩溃或死锁。这类预言不需要预期的输出值,因为程序崩溃本身就意味着有问题。

为了提高检测异常(如内存错误)的灵敏度,我们可以使用净化器工具。例如,地址净化器 是一种编译器和运行时技术,可以检测多种内存错误(如缓冲区溢出)。它通过在分配的内存周围设置“红区”来实现。当程序访问红区时,就会被捕获并导致程序崩溃。使用地址净化器非常简单,只需在编译时添加 -fsanitize=address 标志即可。

4. 人工预言

当没有自动化方法或规格说明时,我们可以依赖人工来判断最终输出。这在实践中很常见,例如用户报告Bug。近年来,在机器学习领域,人工预言变得越来越流行,例如在大语言模型训练中,通过人工反馈来优化模型。我们可以通过众包平台来获取这类人工预言。

总结

本节课中我们一起学习了测试预言。测试预言是软件测试中的重要组成部分,它告诉我们一个测试是通过还是失败。我们学习了四种主要类型的测试预言:规定预言、派生预言、隐式预言和人工预言。规定预言源自规格说明;派生预言可从程序工件中自动推导,如差异测试、回归测试和蜕变测试;隐式预言利用程序的通用属性(如是否崩溃),并可借助净化器提高检测灵敏度;最后,我们总是可以依赖人工作为最终的判断者。理解这些不同类型的预言有助于我们在不同场景下选择合适的方法来有效验证软件行为。

007:软件复杂度 📊

在本节课中,我们将要学习软件复杂度的概念。我们将了解它与计算复杂度的区别,并学习在不同层级(单元、集成、系统)上衡量软件复杂度的多种度量方法。理解软件复杂度有助于我们识别代码中潜在的高风险区域,从而更有效地进行测试。


软件复杂度概述

上一节我们回顾了课程更新。本节中,我们来看看什么是软件复杂度。

你可能在算法课程中学习过“复杂度”,例如时间复杂度和空间复杂度,它们使用大O符号(如 O(1)O(n)O(n²))来衡量算法的计算资源消耗。

然而,软件复杂度与此不同。它衡量的是软件结构的复杂程度,关注的是人类理解、维护或更新代码的认知难度。它与代码执行时所需的实际计算资源无关。人们通常认为高复杂度与高缺陷风险相关,因此学习软件复杂度有助于我们在测试中聚焦高风险区域。


单元级复杂度度量

首先,我们从单元级别开始,学习两种度量代码复杂度的方法。

圈复杂度 (Cyclomatic Complexity)

圈复杂度可用于计算任何图的复杂度。对于控制流图,其公式为:

V(G) = E - N + 2P

其中:

  • E 是边的数量。
  • N 是节点的数量。
  • P 是连通分量的数量(通常对于一个函数,P=1)。

因此,公式可简化为:V(G) = E - N + 2

以下是一个计算示例。考虑以下程序及其控制流图:

if (a > b):
    max = a
else:
    max = b
if (max < c):
    max = c
return max

对应的控制流图有 4 条边和 5 个节点。其圈复杂度为:4 - 5 + 2 = 1

通过观察,我们可以总结出影响圈复杂度的因素,并得到一个更快的计算公式,无需绘制控制流图:

V(G) = 2 + #if + #loop + #case - #exit

其中:

  • #if: if 语句的数量(不包括 else)。
  • #loop: 循环语句(如 while, for)的数量。
  • #case: switch 语句中 case 的数量(不包括 default)。
  • #exit: return 或退出语句的数量。

使用此公式计算上述示例:2 + 2 (两个if) + 0 + 0 - 3 (三个return) = 1,结果一致。

圈复杂度的局限性:

  1. 它只衡量控制流的结构复杂度,未考虑数据流。例如,使用复合条件(如 if (a && b && c))可以将嵌套 if 简化,降低圈复杂度,但程序的逻辑复杂度并未降低。
  2. 它忽略了代码规模。一个长达数百行、没有分支的顺序执行函数,圈复杂度很低,但可能包含大量缺陷。

霍尔斯特德复杂度 (Halstead Complexity)

由于圈复杂度的局限性,我们引入另一种度量方法——霍尔斯特德度量法。它基于程序中的运算符和操作数进行计算。

首先需要计算四个基本量:

  • n1: 不同运算符的数量。
  • n2: 不同操作数的数量。
  • N1: 运算符出现的总次数。
  • N2: 操作数出现的总次数。

基于这些,可以推导出多个度量指标:

  • 程序长度 (Program Length): N = N1 + N2
  • 程序词汇表 (Program Vocabulary): n = n1 + n2
  • 程序体积 (Program Volume): V = N * log₂(n)
  • 程序难度 (Program Difficulty): D = (n1/2) * (N2/n2)
  • 编程工作量 (Program Effort): E = D * V

霍尔斯特德度量法在1977年提出,旨在实现语言无关的度量。但对于现代编程语言,区分运算符和操作数有时比较困难,且由其衍生出的“预计编程时间”或“预计缺陷数”等指标在实际中参考价值有限。通常,程序体积 (V)编程工作量 (E) 是相对有用的指标。


集成级复杂度度量

上一节我们介绍了单元级别的复杂度度量。本节中,我们来看看在集成级别如何衡量复杂度。

调用图圈复杂度

由于圈复杂度适用于任何图,因此也可以用于调用图。计算方法是相同的:V(G) = E - N + 2P,其中 E 和 N 是调用图中的边和节点。这可以帮助我们理解模块间调用的结构复杂度。

面向对象复杂度 (CK度量集)

对于面向对象软件,有一套著名的CK度量集,包含6个指标:

  1. 每个类的加权方法数 (WMC)
  2. 继承树的深度 (DIT)
  3. 子类数量 (NOC)
  4. 对象类之间的耦合度 (CBO)
  5. 类的响应集合 (RFC)
  6. 方法间缺乏内聚性 (LCOM)

以下是其中三个关键概念的简要说明:

  • WMC: 一个类中所有方法的复杂度之和(可使用圈复杂度或霍尔斯特德体积计算)。
  • CBO: 与一个类耦合的其他类的数量。耦合是双向的,如果类A使用了类B,或类B使用了类A,则它们相互耦合。
  • RFC: 一个类的方法集合的响应数量。计算公式为:RFC = |{本类方法集合}| + |{这些方法调用的不同外部方法集合}|
  • LCOM (缺乏内聚性度量): 衡量一个类中方法的内聚程度。计算方法之一是:列出类的所有方法和属性,确定每个方法访问哪些属性,然后计算方法对;如果两个方法共享至少一个属性,则它们是一个“内聚对”。LCOM = |非内聚方法对| - |内聚方法对|(若结果为负则取0)。值越高,表示类的方法内聚性越差。

系统级复杂度与总结

最后,我们探讨系统级别的复杂度。

在系统级别,通常复用集成级别的度量方法(如调用图复杂度、CK度量)。一个重要的研究问题是:软件复杂度是否与缺陷正相关?2018年的一项研究表明,这个关系很难一概而论。复杂的代码可能因为开发者更专注而缺陷较少,简单的代码也可能因疏忽而引入缺陷。有趣的是,许多复杂的缺陷预测模型,其效果有时还不如简单的代码行数(LOC) 指标。

尽管如此,复杂度度量在实践中仍有价值。例如,一些自动化测试工具(如Fuzz工具)会使用圈复杂度等指标来识别值得进行模糊测试的“有价值目标函数”。许多公司也会在代码规范中限制函数的圈复杂度,以提升代码可维护性。

本节课总结:
我们一起学习了软件复杂度的概念及其与计算复杂度的区别。我们深入探讨了三种级别的复杂度度量:

  1. 单元级:圈复杂度(快速公式:V=2+#if+#loop+#case-#exit)和霍尔斯特德度量法。
  2. 集成级:调用图圈复杂度和面向对象的CK度量集(如WMC, CBO, RFC, LCOM)。
  3. 系统级:复杂度和缺陷关系的复杂性,以及度量在实践中的应用(如识别测试重点)。

理解这些度量有助于我们在软件测试中更智能地分配精力,聚焦于潜在的高风险复杂模块。


附:Pytest 进阶技巧 🔧

本节将介绍Pytest测试框架中的两个进阶功能,它们对于编写作业2的自动化测试非常有帮助。

1. 夹具 (Fixtures) 回顾与深入

夹具用于为测试提供可重用、可组合的设置和清理代码。

基本结构:

import pytest
import os

@pytest.fixture
def temp_text_file():
    # 设置阶段:创建资源
    file_path = "temp_test_file.txt"
    with open(file_path, 'w') as f:
        f.write("hello world")
    # 使用 yield 将资源提供给测试函数,并暂停执行
    yield file_path
    # 清理阶段:测试结束后执行
    os.remove(file_path)

def test_file_content(temp_text_file):
    # 测试函数接收 fixture 返回的资源
    with open(temp_text_file, 'r') as f:
        content = f.read()
    assert content == "hello world"

yieldreturn 的区别:

  • 在Pytest夹具中,使用 yield 可以在测试函数执行完毕后,返回到夹具中执行清理代码。
  • 如果使用 return,则夹具函数会立即终止,清理代码无法执行。
  • 一个夹具中只能有一个 yield,但可以有多个 return(虽然通常不这么用)。

夹具作用域 (Scope):
可以通过 @pytest.fixture(scope="...") 指定夹具的作用域,控制其创建和销毁的频率:

  • function (默认): 每个测试函数运行一次。
  • class: 每个测试类运行一次。
  • module: 每个模块(文件)运行一次。
  • package: 每个包运行一次。
  • session: 整个测试会话运行一次。

2. 参数化测试 (Parameterization)

当需要对同一个测试函数使用多组不同的输入数据和预期输出来进行测试时,可以使用参数化,避免编写多个重复的测试函数。

使用方法:

import pytest

@pytest.mark.parametrize("input_a, input_b, expected", [
    (1, 2, 3),
    (5, -5, 0),
    (100, 200, 300),
])
def test_addition(input_a, input_b, expected):
    result = input_a + input_b
    assert result == expected

@pytest.mark.parametrize 装饰器的第一个参数是一个字符串,定义了测试函数接收的参数名;第二个参数是一个列表,其中每个元素是一组测试数据(元组形式)。Pytest会分别使用每一组数据运行一次测试函数。

掌握夹具和参数化,可以让你写出更简洁、更强大、更易维护的自动化测试代码。

008:American Fuzzy Lop (AFL) 🐰

在本节课中,我们将学习一种非常流行且实用的灰盒模糊测试工具——American Fuzzy Lop (AFL)。我们将了解它的工作原理,包括如何对目标程序进行插桩、如何处理种子文件、如何进行变异,以及如何进行并行模糊测试。


概述

AFL 是一种高效的灰盒模糊测试工具,它通过插桩目标程序来收集代码覆盖率信息,并利用这些信息智能地生成新的测试用例,以探索更多的程序状态。它被认为是模糊测试领域的一个标准工具。


AFL 的背景与作者

在上一讲中,我们学习了灰盒模糊测试是一种非常有效且实用的技术。今天,我们将学习其中最流行的一种工具。

AFL 由一位名叫 Michał Zalewski 的安全研究员开发,他的用户名是 lcamtuf。他在 Google 工作期间开发了 AFL。有趣的是,“American Fuzzy Lop” 实际上是一种兔子的品种。

我们学习 AFL 是因为它非常流行,并且被许多学术研究和新的灰盒模糊测试工具作为基础或参考标准。

AFL 的作者是一位著名的白帽黑客。基于原始的 AFL,现在有一个名为 AFL++ 的新项目,由开源社区维护。虽然原始的 AFL 仓库已被归档,但其核心概念被 AFL++ 继承。本课程中,我们仍将学习原始的 AFL,因为其核心概念足够清晰,且 AFL++ 的许多复杂特性超出了本课程的范围。


AFL 代码仓库概览

让我们先了解一下 AFL 代码仓库的结构。在 GitHub 上查看 AFL 的仓库,可以看到以下主要目录和文件:

以下是 AFL 仓库中的主要目录和文件及其用途:

  • dictionaries/:包含用于基于字典变异的示例令牌文件。
  • experimental/:包含 AFL 的一些实验性功能,其中许多后来成为了 AFL++ 的主要功能。
  • libdislocator/:类似于 Address Sanitizer,用于确保程序在发生小内存错误时崩溃。
  • libtokencap/:用于从目标程序的源代码中自动提取令牌,这些令牌可用于基于字典的变异。
  • llvm_mode/:包含使用 LLVM 编译器框架进行程序插桩的文件。
  • qemu_mode/:使用 QEMU 仿真框架对二进制程序进行插桩,适用于无法获取源代码的情况。
  • testcases/:包含一些示例初始种子输入文件。

主要的源代码逻辑集中在 afl-fuzz.c 这个文件中,它包含了模糊测试器的核心逻辑,代码超过 8000 行。其他重要的工具文件包括:

  • afl-gcc:用于编译时插桩的编译器封装器。
  • afl-showmap:用于显示位图内容的工具。
  • afl-cmin:用于最小化初始种子语料库的工具。

AFL 工作流程

AFL 的整体工作流程如下:

首先,我们需要对目标程序进行插桩。我们可以使用 afl-gccafl-clang-fast 等工具在拥有源代码时进行插桩,也可以使用 QEMU 模式或二进制重写器在没有源代码时进行插桩。

其次,我们需要为模糊测试提供一些初始种子输入文件。

此外,还有一些可选的准备工作:

  • 使用 afl-cmin 等工具最小化种子语料库,去除冗余的种子。
  • 为目标程序准备字典文件,包含文件格式中的“魔术字节”或关键令牌。
  • 熟悉目标程序的用法,以便测试特定功能。
  • 首次在计算机上运行 AFL 时,可能需要设置 CPU 频率调节模式。

最后,执行 AFL 命令开始模糊测试,并检查其是否正常运行。


AFL 实战演示

让我们通过一个演示来感受 AFL 是如何工作的。假设我们有一个名为 test.c 的简单程序,它读取一个输入文件,并根据文件内容执行不同的路径,其中包含两个会导致崩溃的 bug。

首先,我们使用普通的 gcc 编译程序,但这样编译出的程序没有插桩,AFL 无法使用。我们需要使用 afl-gcc 进行编译:

afl-gcc -o test test.c

编译时,AFL 会提示在程序中插入了多少个检测点。我们可以使用 strings 命令检查二进制文件中是否包含 AFL 的特定字符串,以验证插桩是否成功。

接下来,我们运行 AFL 进行模糊测试。命令的基本格式如下:

afl-fuzz -i input_seeds/ -o findings/ -m none -- ./test @@
  • -i:指定存放初始种子文件的目录。
  • -o:指定 AFL 输出结果的目录。
  • -m none:不限制目标程序的内存使用。
  • --:分隔 AFL 参数和目标程序命令。
  • ./test @@:运行目标程序,@@ 是一个占位符,AFL 会自动将生成的测试文件路径替换到这里。

运行后,AFL 会启动一个用户界面,显示执行速度、发现的路径数、崩溃数等信息。稍等片刻,AFL 就能找到触发程序崩溃的输入文件。我们可以在输出目录的 crashes/ 子目录中找到这些文件,并手动验证它们确实能导致程序崩溃。


AFL 用户界面解读

AFL 的用户界面提供了丰富的信息。以下是各部分的含义:

  • process timing:显示模糊测试已运行的时间以及最近发现新路径、崩溃等事件的时间。
  • overall results:显示总的执行次数、运行速度等。
  • stage progress:显示当前正在使用的变异操作符及其进度。
  • findings in depth:显示发现的路径、崩溃、超时等详细数量。
  • map coverage:显示位图覆盖率,这与代码覆盖率相关。
  • stage execs:显示不同变异操作符的性能统计。
  • total execs:总执行次数。
  • exec speed:当前每秒执行目标程序的次数,是衡量性能的关键指标。

这个用户界面完全是使用 printf 函数输出的,没有借助任何外部图形库。


AFL 的插桩机制

AFL 主要依赖两种类型的插桩代码。

1. Fork 服务器 🍴

Fork 服务器用于控制目标程序的进程创建。它利用 Linux 的 fork() 系统调用和管道(pipe)机制,与模糊测试器进程进行通信。

fork() 通过写时复制(Copy-on-Write)机制快速创建新进程。Fork 服务器在目标程序环境初始化之后启动。之后,每次需要执行一个新的测试用例时,模糊测试器通过管道通知 Fork 服务器,Fork 服务器再 fork() 出一个子进程来实际执行测试。这样就避免了每次执行都重复进行环境初始化的开销,极大地提高了执行速度。

2. 共享内存与覆盖率收集 🗺️

AFL 使用共享内存(Shared Memory)在目标程序和模糊测试器之间传递覆盖率信息。这块共享内存被称为“位图”(bitmap)。

AFL 收集的不是完整的路径覆盖率,而是基本块转移(edge)覆盖率。它的工作原理如下:

  1. 为控制流图中的每个基本块分配一个随机 ID。
  2. 当程序执行从一个基本块(prev)转移到另一个基本块(cur)时,计算一个唯一的边 ID:
    edge_id = (prev_id >> 1) ^ cur_id
  3. 以这个 edge_id 作为索引,在位图对应的字节上进行计数加一操作。

为什么需要右移一位(>> 1)?
这是为了处理基本块跳转到自身的情况(循环)。如果不右移,prev_idcur_id 相同,异或结果永远为 0,所有自循环边都无法区分。右移一位可以避免这个问题。


AFL 的种子处理策略

如何决定保存新种子?

AFL 的核心策略是:当一个测试输入能够覆盖至少一条新的程序边(即基本块转移)时,它就会被保存为新的种子,加入队列供后续变异使用。有时,如果一个测试输入能增加某条边的执行计数(例如触发了更深的循环),也可能被保留。

种子优先级调度

AFL 维护一个种子队列。它采用一种两级优先级(favor)机制:

  1. Favored seeds:被标记为“偏好”的种子。
  2. Non-favored seeds:普通种子。

在一个测试循环中,AFL 按顺序遍历队列。如果存在尚未被本轮访问过的 Favored 种子,那么 AFL 会有很高的概率跳过当前的非 Favored 种子,优先测试 Favored 种子。

如何计算 Favored 种子?

AFL 会为每条被覆盖的边维护一个列表,记录所有能覆盖这条边的种子。然后,它根据文件大小执行时间对这些种子进行排序(从小到大)。对于每条边,它选择排序后列表中的第一个种子(即最小最快的)标记为 Favored。最终,所有被标记为 Favored 的种子构成了一个能覆盖所有已发现边的最小化、最优化的种子集合。

能量调度

选定一个种子后,AFL 需要决定基于它生成多少个测试用例。这通过为每个种子计算一个性能分数来决定,分数主要基于执行时间和覆盖范围。分数高的种子会获得更多的“能量”,即生成更多测试用例的机会。


AFL 的变异策略

AFL 通过变异现有的种子来生成新的测试输入。其变异策略主要分为四类:

1. 修剪

AFL 倾向于保留更小、更快的种子,因为它们通常信息密度更高,变异时更容易命中关键代码。因此,AFL 会尝试对种子进行“修剪”,即移除文件中那些不影响当前覆盖率的字节,使种子文件最小化。

2. 确定性变异

这类变异操作是确定性的:对同一个种子,每次都会生成完全相同的一组测试用例。主要包括:

  • 位翻转:翻转 1, 2, 4 个连续的位。
  • 字节翻转:翻转 1, 2, 4 个连续的字节。
  • 算术运算:对整数进行加减一些预定义的数值。
  • 特殊值替换:用一些特殊整数(如最大值、最小值、0、-1等)替换数据。

3. 非确定性变异

这类变异是随机的,包括:

  • 随机 havoc:随机组合多种变异操作(如随机位/字节翻转、随机块删除/插入等)一次性施加到种子上。
  • 拼接:随机选择两个不同的种子,交换它们的一部分内容,生成新的测试用例。

4. 基于字典的变异

如果用户提供了字典文件(包含目标文件格式的“魔术字节”或关键令牌),AFL 可以利用它进行变异。

  • 确定性方式:遍历种子文件的每个字节位置,尝试用字典中的每个令牌替换该位置开始的一段数据。
  • 非确定性方式:随机地在文件中插入或替换字典令牌。

使用建议:尽可能为目标程序提供字典,这能显著提高发现深层 bug 的效率。对于某些程序,确定性变异可能过于细微,可以使用 -d 选项(AFL)或 -Z 选项(AFL++)来禁用它们,以提升测试速度。


并行模糊测试

为了充分利用多核 CPU 硬件,AFL 支持并行模糊测试。可以运行一个主实例和多个从实例

  • 主实例:使用确定性变异和非确定性变异。
  • 从实例:只使用非确定性变异。

各个实例通过定期检查其他实例的输出目录来共享新发现的种子。如果一个实例发现其他实例的种子能为自己带来新的覆盖率,它就会将这些种子加入到自己的队列中。在用户界面中,“imported” 计数就表示从其他实例导入的种子数量。


总结

本节课我们一起学习了 American Fuzzy Lop (AFL) 这款强大的灰盒模糊测试工具。我们了解了它的背景、整体工作流程,并深入探讨了其两大核心技术:Fork 服务器插桩基于共享内存的边覆盖率反馈。我们还学习了 AFL 如何智能地处理种子(包括优先级调度和能量调度)以及如何通过多种变异策略生成新的测试用例。最后,我们简要介绍了并行模糊测试的概念。掌握 AFL 的原理和使用方法,对于进行高效的软件安全测试至关重要。

009:模糊测试 🎯

在本节课中,我们将要学习一种特定的系统测试技术——模糊测试。我们将了解其基本概念、不同类型(黑盒、白盒、灰盒)以及它们的工作原理和优缺点。


课程概述 📋

在开始今天的主要内容之前,我们先快速了解一下本学期后半段的计划。本周我们将学习模糊测试,特别是名为 American Fuzzy Lop 的特定模糊测试工具,这些内容与作业2相关。下周是实践周,我们将发布一些练习材料,让大家亲身体验模糊测试,并练习 Docker 的使用。第七周结束时,我们将发布作业1的结果和作业2的规范。作业2将涵盖单元测试、集成测试和模糊测试的实践。

现在,让我们开始今天的课程。我们将学习一种特定的系统测试技术,称为模糊测试。在此之前,我们先回顾一下已学内容:功能与结构测试、不同级别的测试(单元、集成、系统测试)。今天,我们将聚焦于一个非常具体且实用的系统测试技术——模糊测试。


什么是模糊测试?🤔

模糊测试是一种自动化测试技术,这意味着它不需要太多人工努力,测试用例是自动生成的。其基本思想是向程序提供大量随机、无效或意外的数据作为输入,然后执行程序并监控其异常行为,如崩溃、断言失败或其他问题。

这里有三个关键术语:

  1. 生成大量随机数据作为输入。
  2. 执行程序。
  3. 监控结果。

这三个关键术语决定了我们如何设计模糊测试策略。

“模糊”一词意味着高度随机。我们试图向目标程序输入大量随机数据,因此这种技术被称为模糊测试。

根据模糊测试的定义,我们可以得出一个通用的工作流程:

  1. 创建大量随机输入。
  2. 使用这些输入执行目标程序。
  3. 观察程序的异常行为。

自动执行这三项任务的工具称为模糊测试工具。


影响模糊测试性能的因素 ⚙️

根据工作流程,哪些因素会影响模糊测试工具的性能?实际上,所有三个因素都会影响。

  1. 生成输入的质量:如果我们借鉴边界值测试的思想,在随机输入中创建一些极值,则更有可能覆盖开发者可能忽略的边界情况。
  2. 执行速度:测试通常有时间预算。在预算内,我们希望尽可能多地执行目标程序。执行速度很重要,在灰盒模糊测试中,我们可以使用一些技巧来加速执行。
  3. 异常行为观察的敏感性:我们需要足够敏感,以捕获程序的某些异常行为。例如,在C程序中读取数组越界一个字节,程序可能不会崩溃,但这仍然是一个缺陷。我们需要捕获这些异常行为,以免遗漏任何错误。

因此,这三个特征都会影响模糊测试工具的性能。


黑盒模糊测试 ⬛

最直观或最简单的模糊测试类型是黑盒模糊测试。就像我们进行功能或黑盒测试一样,我们可以将目标程序视为一个黑盒。我们只需要知道程序需要什么作为输入,并观察程序的输出,而不需要关心内部发生了什么。

黑盒模糊测试是最早、最基本的模糊测试形式之一,诞生于20世纪80年代。然而,随机测试的概念在更早之前由研究人员提出,最初是用于硬件测试。

整个黑盒模糊测试的想法基于“无限猴子定理”。这个定理是说,如果给一只猴子无限的时间,让它随机敲击打字机,它几乎肯定能打出任何给定的文本,包括莎士比亚的全部作品。关键在于“无限的时间”。但在现实中,我们没有无限的时间,只有预算。因此,我们需要比纯粹随机更聪明的策略来设计随机数据生成器。

在之前的练习题中,我们了解到所有三个因素都会影响测试过程的最终性能。在这些因素中,哪些可以由测试工具控制?

  • 如何生成测试输入可以由测试者完全控制。
  • 我们也可以加速目标程序的执行(特别是在灰盒模糊测试中)。
  • 然而,我们通常无法控制对程序异常行为的敏感度,这需要对目标程序进行代码修改。

因此,对于黑盒模糊测试工具,我们基本上只能控制如何生成测试输入。基于此,我们可以将黑盒模糊测试策略分为两种类型。

基于变异的模糊测试 🧬

这种类型涉及通过变异或修改一些现有的程序输入来生成测试输入。因此,它被称为基于变异的模糊测试。

关键在于种子。种子是用于调整或变异以生成新测试输入的程序输入。例如,在对抗性机器学习中,我们向原始图像添加小的扰动,新生成的测试输入可以误导深度神经网络分类器产生错误的标签。这里的“小扰动”就是对原始测试输入的变异。

基于生成的模糊测试 🏗️

与基于现有输入应用变异操作不同,基于生成的模糊测试不需要访问现有的程序输入。

我们根据某些规则从头开始生成测试输入或测试用例。这些规则可以是我们之前在系统测试讲座中学到的,例如上下文无关文法。我们可以遵循这些文法生成一些程序、脚本或XML文件来测试编译器、解释器或解析器。

对于接受结构化输入的目标程序(如编译器、代码解释器),基于生成的模糊测试通常被认为比基于变异的更有效。因为我们不是随机生成畸形的测试输入,而是遵循规则生成测试输入,这样生成的测试输入可以通过输入的有效性检查,从而触及程序更深层的逻辑。

此类目标的例子包括PDF阅读器(PDF文件格式高度结构化)和V8 JavaScript引擎(JavaScript代码高度结构化)。然而,由于需要这些规则,基于生成的模糊测试被认为自动化程度较低,因为这些规则通常需要手动创建。


白盒模糊测试 ⬜

人们通常用“白盒模糊测试”这个术语来指代符号执行

符号执行是构建描述将执行哪条路径的谓词,并估计对程序状态影响的过程。它包含三个关键步骤:

  1. 确定可以采取某条路径的条件。
  2. 识别本不应执行但可能被执行的部分。
  3. 通过计算可以引导程序走特定路径的输入,系统地执行程序中的不同路径,从而生成针对系统中特定部分进行测试的测试用例。

让我们看一个符号执行与普通程序执行的具体例子。

在普通程序执行中,程序使用实际输入执行。而符号执行则使用符号值而不是实际值来执行程序。这些符号值实际上是变量或符号,在根据某些条件求解之前,可以取该类型中的任何值。

第二个区别在于语句计算。在普通程序执行中,语句使用该语句中接收到的变量的新值进行计算。而在符号执行中,由于符号值在求解之前不取任何具体值,因此语句使用符号表达式进行计算。

第三个区别在于程序状态。在普通程序执行中,程序状态由变量的具体值来表征。而在符号执行期间,我们使用由符号表达式组成的谓词。

执行分支:在符号执行中,当我们到达条件分支时,会向程序状态添加一个约束(条件或其反条件),以表示选择该路径必须满足的条件。

随着程序执行,我们会积累这些约束。然而,可能存在冲突的约束,导致不可行分支或不可达代码(也称为死代码)。

为了根据约束找到解决方案(即具体的输入值),我们需要一个称为 SMT求解 的技术。SMT代表“可满足性模理论”。它实际上是确定一个数学公式是否可满足的问题。在符号执行的上下文中,程序状态由对输入的不同表达式或约束组成,因此我们可以尝试使用SMT求解技术来找到一个满足这些条件的具体值,或者确定这些条件是否冲突或无解。

SMT求解技术试图将这些条件转换为布尔可满足性问题。基本上,它将问题转化为回答“这个条件是否可满足?”的是/否问题。有一些自动化工具可以帮助我们解决积累的条件,这些工具称为SMT求解器。其中最著名的是由微软维护的 Z3

通过符号执行,我们可以积累路径上的条件,以便到达程序中的特定位置,然后使用SMT求解器给我们一个满足所有约束的解决方案,并将此解决方案用作测试用例,以执行该特定程序路径。

纯符号执行实际上是一种静态分析技术,尽管它的名字中有“执行”。我们并不实际运行程序。例如,如果我们只关心到达每个基本块的条件,我们可以得到程序的符号执行树。为了到达程序中的某个位置,我们不需要真正运行程序,只需要收集所有条件并将其发送给求解器,求解器会给出一个能到达该位置的结果(除非条件不可满足)。

符号执行有一些局限性:

  1. 路径爆炸问题:一旦程序中有循环,就可能产生无限多条路径;每当有分支时,路径数量就会翻倍。
  2. 不可解条件:即使条件是可满足的(存在解),条件也可能过于复杂,导致求解器无法在可行时间内求解。
  3. 环境交互:程序可能与外部环境交互(例如调用RESTful API或读写文件),这些交互很难被转换为约束供求解器求解。

为了解决这些问题,我们可以结合具体程序执行。这意味着我们可以使用一些具体值来执行程序,生成具体的执行路径,然后只将这条路径上某些感兴趣的值符号化。这可以帮助缓解一些问题,例如路径爆炸。通过结合具体值和符号值,我们可以进行混合执行

符号或混合执行通常用于自动化程序验证,因为它基于SMT求解器的严密性。由于其解决复杂条件的能力,通常用于一些小型但关键的系统。符号执行在一些“夺旗”安全竞赛中也很流行。然而,由于上述限制,很难应用于大型现实世界的程序。即使使用混合执行,对于大型程序也可能存在问题。

在我看来,符号执行或混合执行虽然有些人可能称之为白盒模糊测试,但严格来说并不完全是模糊测试。因为模糊测试,顾名思义,依赖于随机输入,而符号执行或混合执行是非常精确的。


灰盒模糊测试 ⚫⚪

黑盒模糊测试可能存在有效性问题。例如,由于我们对程序内部一无所知,只是纯粹随机生成测试输入,这些输入可能过于随机,以至于在程序的早期就被拒绝。如今我们使用的大多数软件都对输入有有效性检查。为了触及被测软件的深层逻辑,我们不能纯粹随机生成输入。因此,黑盒模糊测试可能因缺乏对程序内部情况的了解而存在有效性问题。

白盒模糊测试(符号执行)则可能存在可扩展性问题。因为我们需要积累程序不同状态下的条件,并使用SMT求解器求解这些约束,它们可能非常复杂,以至于无法求解。

那么,为什么不将两者结合起来呢?在模糊测试中,我们没有“黑加白”,而是有“灰盒”。灰盒模糊测试可以兼具可扩展性和有效性。

灰盒模糊测试的关键思想是:

  1. 首先,我们需要解决黑盒测试的缺点,不能忽略程序内部发生的情况。因此,我们在目标程序执行期间收集信息
  2. 然而,与白盒测试(符号执行收集所有程序状态)不同,我们只收集测试所需的部分信息。这解决了信息过多难以处理的问题,使得灰盒模糊测试可以应用于大型程序。

让我们回到模糊测试的一般工作流程,看看有什么不同。在灰盒模糊测试中,我们仍然需要创建大量随机输入作为测试用例,然后执行目标程序,并观察异常行为(漏洞)。不同之处在于,除了观察漏洞或崩溃,我们还在执行程序后收集执行信息。然后,我们使用这些运行时信息来调整生成测试输入的方式。这就像一个反馈循环:首先生成一些随机测试输入,用它们执行程序,收集信息,评估哪些输入更好或更差,然后利用这些信息更新下一轮输入生成的策略。

灰盒模糊测试体现了“暴力的美学”。为什么需要“暴力”?这又与无限猴子定理有关。我们需要创建大量随机输入来覆盖边界情况。我们需要足够随机以覆盖开发者可能忽略的情况,并且需要大量的输入,因为开发者通常很聪明,会处理大多数情况。使用大量随机输入有点像在暴力破解程序。然而,暴力并不总是有效,我们需要“美学”(策略)。就像童话故事里,大灰狼可以轻易吹倒稻草和木头房子,但无法吹倒砖房。在模糊测试中,我们需要设计一些策略来触及漏洞位置,这些策略可以被视为“美学”。

灰盒模糊测试的关键机制是让模糊器知晓程序内部的某些覆盖情况,例如分支覆盖。这是通过程序插桩实现的。程序插桩是修改程序以便执行某种分析。这种修改可以在源代码级别(编译前)或二进制级别(编译后)进行。如果我们想在源代码级别修改,可以修改编译过程,让编译器注入额外的逻辑来收集覆盖信息。如果我们无法访问源代码,仍然可以使用二进制分析工具和二进制重写工具来重写已编译的程序。

程序插桩可以实现性能分析,因为我们可以向程序中注入任何我们想要的逻辑,显然可以注入一些额外的逻辑,将代码覆盖情况报告给自动化测试器。

回到灰盒模糊测试的工作流程。现在我们理解了为什么需要收集这些额外信息(为了提高有效性),以及如何收集(通过程序插桩)。接下来,看看我们可以根据收集到的执行信息设计哪些策略。

基本上,基于收集到的执行信息,我们可以设计三种策略:

  1. 种子优先级排序:灰盒模糊器可以保留那些能覆盖额外分支的输入作为种子。它维护一个种子池。由于我们有一个种子池用于生成新的测试输入,我们需要决定接下来使用哪个种子。这个决策过程称为种子优先级排序。这需要我们评估种子的质量,例如种子的覆盖率(能覆盖多少基本块或分支)、执行速度等。
  2. 能量调度:一旦选择了一个种子,我们想对其应用随机变异或扰动以生成新的测试输入。然而,这里还有另一个决策:基于这个种子应该生成多少个新的测试输入?这个决策需要能量调度策略。能量调度是确定给定一个种子应生成多少新测试输入。这再次需要我们评估种子的质量。
  3. 测试输入生成:与黑盒测试类似,我们可以使用基于变异的或基于生成的模糊测试。我们可以在这里使用一些高级算法,但这取决于具体情况,应该根据我们试图测试的目标而定。大多数时候,由于灰盒模糊测试根据目标程序的执行轨迹提供了一些提示,我们可以比黑盒模糊测试更有效地使用基于变异的策略。因此,现今大多数灰盒模糊测试都是基于变异的。但这取决于具体情况。

为什么学习灰盒模糊测试?🌟

我们在这门课程中特别需要学习灰盒模糊测试,是因为它最近非常流行。例如,一个十年前开发的黑盒模糊测试工具,其发现的漏洞数量有限。相比之下,一个灰盒模糊测试工具(即使不是最新版本)也能在许多著名的产品中发现大量漏洞。许多大公司也提供模糊测试服务,例如微软的OneF、Google的OSS-Fuzz、AWS的模糊测试服务等。它们提供的大多数服务都基于灰盒模糊测试。这就是为什么我们在这门课程中对灰盒模糊测试特别感兴趣,也是为什么我们将在作业2中练习如何创建一个小型的灰盒模糊器。


总结 📝

本节课我们一起学习了模糊测试。我们首先了解了模糊测试的基本概念和工作流程,以及影响其性能的因素。接着,我们深入探讨了三种主要的模糊测试类型:黑盒模糊测试(包括基于变异和基于生成的子类)、白盒模糊测试(即符号执行)以及结合两者优点的灰盒模糊测试。我们重点分析了灰盒模糊测试的关键机制、策略及其在实际应用中的重要性。最后,我们了解了为什么灰盒模糊测试在现代软件测试中如此流行和实用。

010:系统测试与测试自动化 🧪

在本节课中,我们将继续学习测试级别,并深入了解系统测试。之后,我们将探讨测试自动化技术及其在实际中的应用,包括如何使用 pytest 工具。

课程回顾

在上一讲中,我们学习了两种测试级别:单元测试和集成测试。今天,我们将学习第三种测试级别——系统测试。随后,我们将学习一些在实践中非常有用的测试自动化技术。

系统测试概述

系统测试是指测试整个系统或整个软件。其核心在于测试完全集成的产品,并依据特定需求(无论是功能性的还是非功能性的)进行测试。因此,系统测试既可以是功能性的,也可以是非功能性的。

在三种测试级别中,系统测试最接近我们的日常体验,因为我们是从最终用户的角度来测试整个产品,而不仅仅是单个函数或小功能模块的组合。

功能性系统测试

对于功能性系统测试,我们关注的是产品的功能或业务逻辑。我们测试系统是否能正常运行。通常的方法是提供一些测试输入,并用预期结果来验证程序的输出。

非功能性系统测试

对于非功能性系统测试,我们关注的不是源代码或代码结构,而是系统的性能、可扩展性、可用性等质量属性。测试方法通常是找到衡量这些非功能性特征的方法,然后根据预定义的标准来评估结果,而不是对照预期的输出进行检查。

功能性与非功能性测试示例

让我们以悉尼的有轨电车系统为例。

  • 功能性特征示例

    • 测试电车调度功能:这是整个系统的主要功能。
    • 测试乘客信息系统:验证车厢内屏幕上显示的信息是否准确。
  • 非功能性特征示例

    • 可扩展性测试:测试系统在活动或节日期间能否处理比平时更多的乘客。
    • 可维护性测试:评估对系统进行维护任务(如道路维护)的难易程度。

通常,功能性特征更特定于当前测试的系统,而非功能性特征(如可扩展性、可维护性)在不同系统中可能具有相似的属性。

安全性:功能还是非功能?

安全性在软件系统中是功能性还是非功能性特征?答案可能是两者兼有。

对于大多数日常软件(如Windows自带的计算器),安全性并非必需功能。然而,对于某些软件系统(如防火墙、Windows Defender、Web内容管理系统),安全性则是关键功能。因此,安全性是功能性还是非功能性,取决于具体的软件系统。

功能与非功能测试对比

以下是系统测试背景下功能与非功能测试的简要对比:

  • 功能性测试:主要测试产品的功能。可以在单元、集成或系统测试阶段进行。通常只需一套配置,因为我们只关心软件的业务逻辑。
  • 非功能性测试:测试各种质量因素。主要在系统测试阶段进行。由于需要覆盖不同的属性,某些属性可能需要特定的环境,因此不同测试套件的配置可能不同。

功能性系统测试策略:基于模型的测试

进行功能性系统测试最著名、最常用的技术之一是基于模型的测试。这是因为当今使用的许多软件的行为都可以用模型来捕获或表示。

基于模型的测试不是基于实现来测试软件,而是基于模型来检查软件行为是否符合预期。这些模型有助于设计测试用例,有时还能帮助验证程序行为是否正常。

这里的“模型”只是对所测试或开发系统的抽象。其优势在于,由于我们忽略了一些细节,可以进行更强大的分析。这些模型可以从规范或源代码中推导出来。

常见模型类型

以下是一些在实践中可能遇到的典型模型:

基于图的模型

  • 有限状态机:许多软件系统(尤其是那些具有明确定义状态的系统)都可以建模为有限状态机。例如,自动售货机或日常使用的协议(如HTTP、TCP协议)。该模型可用于推导测试用例和检查整个系统的行为。
  • 佩特里网:擅长捕获分布式系统或并发系统的行为。它由“位置”和“变迁”组成,通过变迁携带令牌在位置间移动,非常适合测试分布式工作流。

非基于图的模型

  • 上下文无关文法:通常用于表示语言(无论是编程语言还是领域特定语言)的结构。适用于测试编译器、解释器或语言解析器。
  • 决策表:我们的“老朋友”,擅长捕获规则。每个规则由“原因”(输入)和“结果”(最终动作)组成。设计决策表时,应避免只关注最终动作,而应同时根据输入或原因来构建规则。

从代码推导的模型

  • 马尔可夫链:一种表示状态和带有概率的转移的概率模型。它通常用于模糊测试(我们将在作业工具中使用)。为什么可以将一段代码建模为概率模型?因为当我们进行随机测试时,向程序输入随机数据,到达程序中特定部分的概率是可以计算的。然而,这种计算通常成本很高,并且取决于程序内部的条件,通常我们需要进行近似计算。

非功能性系统测试指标

如前所述,系统测试中的非功能性测试涉及评估软件质量的各种质量因素。与功能性测试相比,它通常需要更多的时间、精力和资源,因为需要设置不同的配置,有时还需要搭建接近真实的环境。

进行非功能性测试时,我们通常关注以下四类资源:计算资源(如CPU、GPU)、网络内存磁盘(存储)。

性能测试

性能测试关注系统在特定条件下的表现。

  • 负载测试:检查系统在预期负载条件下的行为。例如,模拟500名学生同时使用WebCMS,测试其能否处理。
  • 压力测试:检查系统在超出其预期最大负载的极端条件下的行为。例如,一个设计为支持1000并发用户的新闻网站,在重大突发新闻期间尝试增加到10000用户,观察网站是否会崩溃。
  • 尖峰测试:测试当用户数量或所需资源突然增加或减少时系统的表现。与压力测试关注最终总数不同,尖峰测试更关注资源使用的突然变化。例如,作业提交前,学生并发访问量突然从100激增到1000。
  • 耐久性测试:确保系统在长时间运行下的稳定性和性能。例如,让Microsoft Teams会议录制超过24小时,或长时间运行流媒体服务,观察是否存在内存泄漏等问题。

可扩展性测试

测试系统能否通过增加或移除资源来根据需求进行扩展或收缩。例如,在AWS云服务上部署应用,逐渐增加服务器数量或资源,观察在用户数增长时能否保持性能。这通常用于云服务或弹性实例。

可用性测试

评估用户学习和使用系统的难易程度。例如,将一款医疗保健应用交给一组医生,观察他们是否能轻松使用该应用执行某些操作。反馈可用于改进UI和UX设计,涉及人机交互。

兼容性测试

确保系统各组件之间,以及软件系统与其运行环境之间的兼容性。例如,测试移动应用在不同Android版本、不同操作系统、不同屏幕尺寸上的表现。网页是兼容性测试的典型对象,现代前端框架支持响应式设计,可以通过浏览器开发者工具的设备模拟功能进行测试。

可用性测试

验证系统在特定条件下或一段时间内的可用性或正常运行时间,并测量停机时间。例如,测试在线预订系统,确保其具有99.99%的可用性(即每年只有约52分钟的停机时间)。

可靠性测试

定义为系统在观察期间能够正常且一致地执行。关键是“定义的时间段”。我们可以观察系统一段时间,看是否有错误和崩溃。然而,可靠性测试实际上与其他非功能性测试指标有重叠,因为在观察期间,我们可能会观察到性能问题、停机时间等问题。

测试自动化与脚手架

到目前为止,我们已经学习了许多为不同目标设计测试用例的技术,也学习了如何确定测试目标。但是,我们如何实际运行它们呢?

虽然我们可以手动运行代码并检查结果,但请不要在实践中这样做。因为人工操作速度慢、成本高且容易出错。测试设计需要努力和创造力(正如你们在作业1中可能体验到的),但测试执行不应该。我们应该将测试用例在目标上的执行自动化。

测试自动化是关于开发一些软件或有用的脚本,来为我们执行重复性任务,从而让我们能更专注于测试的策略设计等创造性方面。它控制测试用例的执行方式和时机,负责程序运行的环境和前提条件,并能自动比较结果以检查错误,以及自动重新执行失败的测试用例。

测试脚手架是为支持测试自动化过程而编写的一组程序。它通常不是产品的一部分,并且经常是临时的。脚手架就像为我们创建一些辅助工具来进行测试自动化。

测试脚手架结构

一个典型的测试脚手架代码结构如下:

+-------------------+
|    测试工具      | <-- 整个结构称为“工具”
+-------------------+
| - 驱动 (Driver)   | <-- 目标单元的上层,模拟调用环境
| - 桩 (Stub)       | <-- 目标单元的下层,模拟未实现的功能
| - 输出比较器      | <-- 也称为“测试预言”,用于验证输出
| - 清理代码        | <-- 测试后必须执行的步骤(如重置数据)
+-------------------+
  • 驱动:位于目标单元之上的一层。它是目标单元主调用程序的替代品,为目标的运行准备环境。
  • :位于目标单元之下的一层。它是目标单元中尚未实现或集成以供测试的系统功能的替代品。
  • 输出比较器:用于比较程序输出与预期结果,通常使用断言来实现。
  • 清理代码:在运行特定测试后必须执行的任何步骤。例如,重置数据库中的临时数据或文件,以确保后续测试不受影响。

单元测试框架

有许多框架可以帮助我们编写测试脚手架代码,它们通常被称为单元测试框架(尽管在实践中,我们通常进行的不仅仅是单个函数的测试)。例如:

  • Python: pytest, unittest
  • Java: JUnit
  • JavaScript: Jest, Mocha
  • C++: GTest
  • Rust: 内置测试框架

我们将在作业工具中使用 pytest

使用 pytest 进行实践

pytest 是一个流行的Python测试框架,语法简单,断言能提供丰富信息,并提供了用于管理测试环境设置的“夹具”系统,以及丰富的插件生态系统。我们将学习两个插件:pytest-mock(用于创建桩和驱动)和 pytest-cov(用于检查测试用例的代码覆盖率)。

安装与基本使用

  1. 创建虚拟环境(推荐):
    python -m venv venv
    
  2. 激活虚拟环境
    • Linux/macOS: source venv/bin/activate
    • Windows: venv\Scripts\activate
  3. 安装 pytest
    pip install pytest
    
  4. 运行测试:在项目目录下执行 pytest,它会自动发现并运行测试。

pytest 的优势:丰富的断言信息

与Python内置的 assert 相比,pytest 在断言失败时能提供更详细的上下文信息,便于调试。

测试发现规则

pytest 自动发现测试用例的规则:

  1. 查找文件名匹配 test_*.py*_test.py 的文件。
  2. 在这些文件中,查找以 test_ 开头的函数。
  3. 同时,也会查找以 Test 开头的类(类中的测试方法通常以 test_ 开头)。

有用的命令行标志

  • -q / --quiet: 安静模式,减少输出。
  • -k <表达式>: 只运行名称匹配表达式的测试。
  • -m <标记>: 只运行用 @pytest.mark.<标记> 装饰器标记的测试。
  • --maxfail=<数字>: 在遇到指定数量的失败后停止测试。
  • --lf / --last-failed: 只重新运行上次失败的测试。
  • --lf --maxfail=1: 组合使用,专注于修复单个失败测试。

项目结构建议

一个典型的使用 pytest 的Python项目测试结构如下:

my_project/
├── src/                    # 源代码
├── tests/                  # 测试代码
│   ├── unit/              # 单元测试
│   ├── integration/       # 集成测试
│   └── conftest.py        # 共享的夹具和配置
└── ...

conftest.py 文件用于存放项目范围内共享的夹具,这些夹具对应着测试脚手架中的驱动、桩或更高级的环境设置。

课程总结

在本节课中,我们一起学习了以下内容:

  1. 系统测试:作为测试级别的最高层,测试完全集成的整个系统。它分为:

    • 功能性系统测试:关注软件的核心功能。
    • 非功能性系统测试:关注软件的质量属性,如性能、可扩展性、可用性、兼容性、可用性和可靠性。
  2. 基于模型的测试:一种重要的功能性系统测试策略,利用抽象模型来设计测试用例和验证行为。

  3. 测试自动化与脚手架:为了高效执行测试,我们需要自动化重复任务。测试脚手架提供了必要的结构,包括驱动、桩、输出比较器和清理代码。

  4. pytest 实践:我们介绍了如何使用 pytest 框架来编写和运行自动化测试,包括其安装、基本用法、测试发现规则和一些有用的命令行标志。

通过本课程的学习,我们已经掌握了从测试用例设计(黑盒/白盒策略)、测试目标确定(单元、集成、系统测试级别),到测试执行自动化(测试脚手架和 pytest 工具)的完整测试流程基础知识。

011:测试级别(全) 🧪

在本节课中,我们将学习软件测试的不同级别。我们将从测试目标的角度转向测试过程的角度,探讨如何在软件开发的不同阶段进行测试。具体来说,我们将重点介绍单元测试和集成测试,并了解如何根据需求或代码结构来识别和测试不同的系统组件。


概述 📋

在之前的课程中,我们学习了功能测试和结构测试,它们是从是否拥有源代码的角度来区分测试目标。功能测试关注需求规格,而结构测试关注具体实现。本周,我们将从测试过程的角度,学习不同级别的测试技术,包括单元测试、集成测试和系统测试。这些技术为我们设计测试用例提供了系统化的方法。


测试级别简介

上一节我们介绍了从测试目标角度区分的功能与结构测试。本节中,我们来看看从软件开发过程角度划分的测试级别。

软件测试可以根据其执行的阶段和目标分为不同级别。这通常通过一个称为 V模型 的开发模型来理解。V模型的左侧代表软件开发的生命周期阶段,右侧则对应每个开发阶段需要进行的测试活动。

  • 单元测试 对应于详细设计阶段,测试程序中最小的可测试单元。
  • 集成测试 对应于架构设计阶段,测试多个单元组合后的交互。
  • 系统测试 对应于需求分析阶段,将软件作为一个整体进行测试。
  • 验收测试 是一个特殊类别,涉及用户验收、法规验收等,本课程不深入讨论。

我们主要关注前三个级别:单元测试、集成测试和系统测试。


单元测试 🔬

单元测试是测试过程的基础。它专注于测试程序中一个独立的、最小的可测试部分。

什么是单元测试?

单元测试是一个动态过程,它针对程序的原子单元,使用准备好的测试用例作为输入来运行该单元代码,并将实际输出与预期结果(测试预言)进行比较,以验证其行为是否正确。

其核心在于,单元测试是测试最小的、可隔离的单元的过程。

为什么进行单元测试?

  • 易于设计测试用例:由于测试目标较小(如一个函数),只需关注有限的输入(如函数参数),使得测试用例设计更简单。
  • 更彻底的测试:可以相对更彻底地测试一个单元的所有可能行为。
  • 支持测试驱动开发:可以先根据需求编写单元测试,再实现代码。

什么是“单元”?

“单元”的定义因编程语言而异:

  • 在过程式编程中,一个单元通常是一个函数方法
  • 在面向对象编程中,一个单元可以是一个(包含其所有方法)。

单元测试是白盒还是黑盒?

单元测试既可以是白盒测试,也可以是黑盒测试,这取决于测试的编写时机:

  • 如果在编写代码之前根据规格说明编写测试(如测试驱动开发),则是黑盒测试
  • 如果在编写代码之后,结合源代码逻辑编写测试,则是白盒测试

集成测试 🔗

单元测试确保各个部件本身正常工作。然而,软件中的许多错误并非来自单个单元,而是源于单元之间的交互。集成测试就是用来测试这些交互的。

什么是集成测试?

集成测试,也称为子系统测试,关注的是将多个单元组合成子系统后,它们之间的接口和交互是否正确。关键在于如何确定将哪些单元“集成”在一起进行测试。

接下来,我们将学习几种不同的集成策略。


基于分解的集成测试

第一种集成策略是基于对系统功能的分解。这种方法使用功能分解树来识别子系统。

功能分解树

功能分解树是一种树形结构,用于将一个复杂系统的功能需求分解为更小、更具体的子功能。

  • 顶层功能:树的根节点,代表系统最核心的功能。
  • 子功能:由顶层功能分解而来,是实现顶层功能所必需的。
  • 子功能可以继续分解,直到达到最基本的级别。

构建功能分解树的典型步骤是:识别主要功能 -> 划分主要功能 -> 定义功能间关系 -> 表示这些关系 -> 细化分解。

示例:日历程序

假设一个日历程序具有以下功能:获取下一天日期、计算闰年、计算星期几、计算星座、识别纪念日、计算最近的“黑色星期五”。
我们可以根据这些功能描述(而非具体实现)构建一个功能分解树。例如,“获取下一天日期”这个功能可以进一步分解为“检查日期有效性”、“计算月份最后一天”等子功能。

有了这棵树,我们就可以应用不同的集成策略。

集成策略

以下是基于功能分解树的几种集成方法:

1. 自顶向下集成

这种方法从树的根节点(主功能)开始测试。

  • 步骤:初始时,将所有下一级子功能用桩模块替代。桩模块是模拟子功能行为的伪代码,返回硬编码的正确结果。然后,逐步用实际的实现代码替换这些桩模块。
  • 优点:故障隔离性好。当替换某个桩模块后出现错误,可以确定问题出在该模块对应的功能中。
  • 遍历方式:类似于广度优先搜索,逐层替换。

2. 自底向上集成

这种方法从树的叶子节点(最基础的功能)开始测试。

  • 步骤:叶子节点是实际代码,不需要桩。但它们需要驱动模块来模拟其上层调用者,提供执行所需的上下文和依赖。
  • 优点:需要编写的桩代码较少。由于多个叶子节点可能共享同一个父节点(即共享同一个驱动),可以减少驱动模块的开发工作量。

3. 三明治集成

这种方法结合了自顶向下和自底向上的思想。

  • 步骤:在功能分解树中识别出一条从根到叶的完整路径,将这条路径上的所有组件作为一个切片进行测试。
  • 优点:减少了桩和驱动模块的开发工作量。
  • 缺点:故障定位比纯粹的自顶向下或自底向上更困难,因为错误可能出现在切片中的任何部分。

4. 大爆炸集成

这是一种简单的策略:将所有已实现的模块一次性组合在一起进行测试。

  • 与大爆炸集成和系统测试的区别
    • 范围:两者都测试所有组件。但集成测试更关注组件间的内部交互,而系统测试关注软件作为整体的外部行为。
    • 执行者与时机:集成测试通常由开发团队在单元测试之后、系统测试之前进行。系统测试则由独立的测试团队在集成测试之后进行。

优缺点与选用考量

优点

  1. 良好的故障定位:除大爆炸外,其他策略都是逐步集成,易于定位错误。
  2. 进度可视:可以对照功能分解树清晰地了解测试进度。

缺点

  1. 依赖需求规格:功能分解树源于需求而非代码,而开发者通常不负责编写需求,这可能导致管理上的脱节。
  2. 额外开发工作:自顶向下和自底向上需要开发桩模块或驱动模块。

如何选择策略

  • 需求清晰稳定:适合自顶向下,便于根据稳定需求编写桩模块。
  • 需求频繁变更:适合自底向上,避免因需求变更而频繁修改桩模块。
  • 代码结构变更,需求稳定:适合三明治,功能切片稳定,不受代码重构影响。
  • 一切稳定:可以考虑大爆炸集成。

基于调用图的集成测试 🕸️

基于分解的集成测试依赖于需求规格。然而,开发者更熟悉代码本身。因此,我们可以直接从代码结构出发,使用调用图来进行集成。

什么是调用图?

调用图是一种特殊的控制流图,表示程序中子程序(如函数)之间的调用关系。

  • 每个节点代表一个过程(通常是函数)。
  • 每条表示一个过程调用了另一个过程。

集成策略

1. 成对集成

每个测试会话(测试目标)仅包含调用图中由一条边连接的两个单元(一对函数)。

  • 优点:故障隔离度最高,因为每次只测试两个单元间的交互。
  • 缺点:修复错误时可能缺乏全局上下文。针对某个“对”的修复,可能无法解决该单元在其他“对”中出现的问题。此外,一个单元出现在多个“对”中,修复后需要重新运行所有相关测试用例。

测试会话数量:等于调用图中的边数

2. 邻域集成

为每个节点定义一个“邻域”。通常,邻域半径设为1,即包含该节点本身及其所有直接相邻(通过一条边连接)的节点。每个邻域作为一个测试会话。

  • 优点
    • 减少了测试会话数量(通常节点数少于边数)。
    • 避免了桩和驱动的开发,因为邻域中同时包含了调用者和被调用者。
  • 与三明治集成的相似性:邻域就像是调用图中的一个小子图。

测试会话数量:等于调用图中的节点数

优缺点

优点

  1. 捕获行为:通过调用关系捕获了组件间的交互行为,而不仅仅是静态结构。
  2. 减少桩/驱动:成对和邻域集成都能在一定程度上避免编写桩或驱动模块。
  3. 便于开发人员实施:基于代码调用关系,与开发方式匹配。

缺点

  1. 故障隔离问题:邻域可能变得很大,影响故障定位。
  2. 修复与回归测试:与成对集成类似,一个节点属于多个邻域,修复错误时可能忽略根因,且需要重新运行多个测试会话。

基于路径的集成测试 🛤️

类似于在结构测试中利用控制流图的路径,在集成测试中,我们可以利用跨模块的执行路径。

核心概念

在深入之前,需要理解几个术语(结合控制流图思想):

  • 语句片段:控制流图中的基本块。
  • 源节点:程序执行开始或恢复的语句片段(如函数入口点、从被调用函数返回后的点)。
  • 汇节点:程序执行终止的语句片段(如函数退出点、调用其他函数前的点)。
  • 模块执行路径:连接一个源节点和一个汇节点,且中间没有其他汇节点的节点序列。
  • 消息:将控制权转移到另一个程序单元的机制(如函数调用)。
  • 模块间路径:跨越多个模块(函数)的执行路径,由多个MEP连接而成。MM路径图本质上就是通过调用关系连接起来的各函数控制流图的集合。

如何进行?

通过分析MM路径图,我们可以识别出不同的模块间路径。每一条这样的路径都可以作为一个集成测试的测试目标。测试用例的设计旨在覆盖这些关键的跨模块执行场景。

优缺点

优点

  1. 功能与结构的混合:通过消息(调用)捕获交互(功能层面),同时测试具体代码路径(结构层面)。
  2. 贴近系统行为:测试的是跨模块的实际执行场景,更接近真实的系统运行行为。

缺点
路径爆炸问题:与控制流图路径覆盖一样,当存在分支和循环时,模块间路径的数量可能急剧增长,导致测试不可行。不过,由于这种方法通常不需要开发桩和驱动,在某些情况下可能是值得的。


总结 🎯

本节课中,我们一起学习了软件测试的不同级别。

  • 我们回顾了从测试目标角度区分的功能测试(黑盒)与结构测试(白盒)。
  • 然后,我们转向从开发过程角度学习的测试级别:
    • 单元测试:测试最小的可测试单元(如函数)。
    • 集成测试:测试多个单元组合后的交互。这是本节课的重点。
  • 对于集成测试,我们学习了多种确定子系统(集成策略)的方法:
    1. 基于分解的集成:从需求出发,构建功能分解树。
      • 包括:自顶向下、自底向上、三明治、大爆炸集成。
    2. 基于调用图的集成:从代码结构出发,利用函数调用图。
      • 包括:成对集成、邻域集成。
    3. 基于路径的集成:从代码执行出发,分析跨模块的MM路径。
      • 将MM路径作为测试目标。

理解这些不同的级别和策略,有助于我们在软件开发的正确阶段,选择合适的方法来系统化地设计和执行测试,从而更有效地发现软件中的缺陷。

012:结构测试 2 🧪

在本节课中,我们将要学习结构测试(白盒测试)中的新覆盖标准,了解结构测试的局限性,并初步认识数据流测试。

课程回顾 📚

上一节我们介绍了基于控制流图(CFG)的三种基本覆盖标准:语句覆盖、分支覆盖和条件覆盖。这些标准都关注于图中的单个元素(节点或边)。然而,在实际程序执行中,我们执行的是由节点和边组成的路径。本节中,我们来看看如何将路径作为新的覆盖标准。

路径覆盖 🛤️

路径覆盖要求测试用例覆盖控制流图中的所有可能执行路径。每条路径是程序执行过程中经过的节点和边的序列,顺序至关重要

例如,一个循环执行一次和执行两次,即使经过的节点相同,也被视为两条不同的路径。

路径覆盖的挑战:无限路径

让我们通过一个例子来理解路径覆盖的挑战。考虑以下控制流图:

[Entry] -> [BB1] -> [Loop: BB2] -> (Condition) -> [BB3] 或 [BB4] -> [BB5] -> (Loop back to BB2 or Exit)
  • 如果循环可以执行任意多次,那么程序存在无限多条路径。
  • 即使我们限制循环最多执行100次,并且循环体内有一个分支(每次循环有2种选择),那么可能的路径总数将是 2^100,这是一个天文数字。

因此,理论上路径覆盖是最强的结构测试标准,因为它能体现不同代码元素间的交互。但在实践中,由于循环的存在,实现100%的路径覆盖通常是不可能的

路径覆盖的变体策略 🧩

既然无法覆盖所有路径,我们可以借鉴黑盒测试中划分等价类的思想,将无限多的路径划分为有限的几组,然后从每组中选择代表性路径进行测试。

以下是三种主要的路径覆盖变体策略:

1. 边界-内部覆盖

这种策略关注循环内部的子路径。它将那些仅在循环体内部子路径上不同的路径归为一组。

核心思想:我们只关心循环体内不同的执行子路径,而不关心循环执行了多少次。测试时,确保每个独特的循环体子路径至少被执行一次。

示例
对于包含一个循环的程序,如果循环体内有两条可能的子路径(例如,一个 if-else 分支的两边),那么只需要一个测试用例(执行循环至少两次,分别走通两条子路径)即可满足边界-内部覆盖。

2. 循环边界覆盖

这种策略关注循环执行次数的边界情况。它测试能代表循环不同执行次数的场景。

以下是设计测试用例的策略:

  • 0次循环:测试跳过循环的情况。
  • 1次循环:测试恰好执行一次循环的情况。
  • 2次或更多次循环:测试执行多次循环的情况(可选择特定边界值,如 N-1, N, N+1 次)。

这类似于黑盒测试中的边界值分析。它通常只适用于简单循环(无嵌套、循环条件独立)。

处理复杂循环

  • 嵌套循环:从最内层循环开始,应用上述策略,然后逐层向外。
  • 串联循环:如果循环独立,则分别测试;如果后一个循环的条件受前一个循环影响,则采用类似嵌套循环的自底向上策略。

循环边界覆盖的理论依据:它模仿了形式化验证中证明循环正确性的方法(考虑循环执行0次、1次和多次时,前置条件、后置条件和循环不变式的关系)。

3. 线性代码序列与跳转覆盖

LCSSAJ 关注程序中线性的代码序列,直到遇到分支或跳转点。一个子路径是一段会顺序执行、中间没有分支的代码。

定义

  • LCS:线性代码序列。
  • J:跳转(如循环起点、分支点、函数调用/返回)。

方法

  1. 识别控制流图中所有的跳转点(J)。
  2. 提取所有从一个跳转点到另一个跳转点(或程序入口/出口)的线性代码序列(子路径)。
  3. 要求测试用例覆盖所有这些子路径。

灵活性
LCSSAJ 允许我们组合子路径。定义参数 N,表示可以链接的连续子路径的最大数量。

  • N=1 时,LCSSAJ 等价于语句覆盖
  • N=2 时,LCSSAJ 等价于分支覆盖
  • N 值越大,覆盖越强,越接近路径覆盖。这让我们可以控制测试的强度。

实践练习:搜索函数 🔍

让我们用一个搜索函数来练习边界-内部覆盖和循环边界覆盖。

函数功能:在字符串 str 中查找字符 ch,返回其索引,若未找到则返回 -1。n 是字符串长度。

边界-内部覆盖练习

  1. 识别循环:函数中存在一个 while 循环。
  2. 识别循环内子路径:循环体内有两条子路径:
    • 子路径 A:找到字符,执行 return index;
    • 子路径 B:未找到字符,执行 index++; 继续循环。
  3. 设计测试用例:至少需要一个测试用例,确保两条子路径都被执行。例如,一个测试用例使循环执行两次,第一次走子路径B,第二次走子路径A。

循环边界覆盖练习

  1. 执行0次循环:可能吗?不可能。因为进入循环的条件是 index < n,而在首次到达时 index=0,且根据前面代码 n>=2,条件恒真。
  2. 执行1次循环:可能。测试用例:str="ab", n=2, ch='a'。第一次循环就找到字符并返回。
  3. 执行2次或更多次循环:可能。测试用例:str="abc", n=3, ch='c'。需要循环三次才能找到。

结构测试的局限性 ⚠️

上一节我们介绍了多种覆盖标准,本节我们来看看结构测试本身存在的局限。

1. 可行性问题

  • 路径爆炸:如前所述,即使是中等复杂度的程序,路径数量也可能是指数级或无限的。
  • 不可达代码
    • 防御性编程引入的永远不会触发的错误处理代码。
    • 遗留代码中的死代码。
    • 跨平台代码中特定于某操作系统的部分。
  • 不可能的条件组合:某些条件组合在逻辑上不可能发生(如之前的循环0次例子)。

应对策略

  • 设定覆盖率的经验阈值(如80%分支覆盖)。
  • 聚焦关键代码(如更复杂的模块)。
  • 人工识别并排除死代码或平台特定代码。
  • 现实中,测试常在预算耗尽或截止日期到达时停止。

2. 控制流覆盖的不足

所有基于控制流图的覆盖标准都有一个核心假设:不执行某条语句,则无法发现其中的缺陷。这个假设成立。

但其逆命题不成立:执行了某条语句,并不能保证发现其中的缺陷

示例
假设正确的条件是 if (a <= b),但开发者误写为 if (a >= b)

  • 如果测试用例是 a=5, b=5,那么无论条件是否正确,程序行为都一样(5<=55>=5 都为真)。
  • 因此,即使100%执行了这条语句,也未能暴露错误。

结论:仅靠执行代码(控制流)不足以发现所有缺陷。缺陷的暴露还依赖于执行到该点时的数据值。这就需要引入数据流分析。

数据流测试简介 📊

数据流测试从数据依赖的角度审视程序,关注变量的定义(赋值)和使用。

核心概念:定义与使用

  • 定义:变量被声明、初始化、赋值或作为函数参数传入值。
    • 公式:def(x) 在位置 p
  • 使用:变量在表达式、条件、参数传递或返回语句中被引用。
    • 公式:use(x) 在位置 p

定义-使用对

一个定义-使用对关联了一个变量的定义点和其后的一个使用点,并满足:

  1. 存在从定义点到使用点的路径
  2. 该路径对于该变量是定义清除的,即路径上该变量没有被重新定义。

数据流覆盖标准

基于定义-使用对,可以定义两种覆盖标准:

  1. 定义-使用对覆盖:要求每个定义-使用对至少被一个测试用例执行一次。

    • 优势:能发现控制流覆盖遗漏的缺陷,因为它要求特定的数据值沿特定路径传播。
    • 计算覆盖率 = (已执行的对数) / (总对数)
  2. 定义-使用路径覆盖:要求覆盖连接每个定义-使用对的所有可能执行路径

    • 优势:更全面,结合了数据流和控制流。
    • 挑战:同样存在路径爆炸问题。即使没有循环,程序中的分支也会导致定义-使用对间的路径数量呈指数增长。

实用Python语法:列表推导式与循环 🐍

最后,我们补充一个实用的Python语法,它可以帮助你更简洁地处理集合数据。

列表推导式 提供了一种创建列表的简洁方法,并可包含条件逻辑。

基本形式

new_list = [expression for item in iterable if condition]

示例

# 将一个列表中的每个元素加倍
original = [1, 2, 3, 4, 5]
doubled = [x * 2 for x in original]  # 结果: [2, 4, 6, 8, 10]

# 只将奇数加倍
doubled_odds = [x * 2 for x in original if x % 2 == 1]  # 结果: [2, 6, 10]

遍历字典

my_dict = {'a': 1, 'b': 2, 'c': 3}
# 默认遍历键
keys = [k for k in my_dict]  # 结果: ['a', 'b', 'c']
# 遍历键值对
items = [f"{k}:{v}" for k, v in my_dict.items()]  # 结果: ['a:1', 'b:2', 'c:3']

嵌套循环

# 生成两个列表的笛卡尔积组合
list1 = [1, 2]
list2 = ['a', 'b']
combined = [(x, y) for x in list1 for y in list2]
# 结果: [(1, 'a'), (1, 'b'), (2, 'a'), (2, 'b')]

列表推导式类似于函数式编程中的 mapfilter 函数,但语法更贴近Python风格,易于阅读。

总结 🎯

本节课中我们一起学习了:

  1. 路径覆盖作为强大的理论标准及其不可行性(由于路径爆炸)。
  2. 三种应对路径爆炸的变体策略
    • 边界-内部覆盖:关注循环内子路径。
    • 循环边界覆盖:关注循环执行次数(0, 1, 2+)。
    • LCSSAJ覆盖:关注线性代码序列,可通过参数N控制强度。
  3. 结构测试的主要局限性:可行性问题以及控制流覆盖对数据值不敏感的不足。
  4. 数据流测试简介:通过变量的定义使用对来分析程序,引入了定义-使用对覆盖和定义-使用路径覆盖,以发现更隐蔽的缺陷。
  5. 一个实用的Python列表推导式语法,用于简化集合数据处理。

理解这些覆盖标准的强弱、成本及适用场景,对于设计有效且高效的白盒测试至关重要。

013:结构性测试(全) 🧪

在本节课中,我们将学习结构性测试,这是一种基于程序源代码结构来设计测试用例和衡量测试充分性的技术。我们将探讨其核心概念、不同的覆盖率指标,并通过实例理解如何应用它们。

概述

软件公司常面临一个困境:如果软件运行良好,为何需要测试?而如果软件发布后出现问题,再进行测试则为时已晚。为了解决这个困境,我们需要证明软件达到了特定的质量标准。仅仅声称“软件已测试”是不够的,必须证明软件经过了“充分的测试”。因此,我们需要衡量测试的“充分性”。

然而,由于输入空间无限、运行环境变化等原因,找出所有潜在缺陷是不可能的。另一方面,完全不进行测试,仅依赖用户反馈,也是不充分的。因此,我们需要在“不可能”和“不充分”之间找到一个平衡点,即设定一些用于衡量测试充分性的标准或指标。

这些指标就像一个评分系统,用于衡量一组测试用例的完成度。我们将从两个角度来寻找合适的指标:需求规格说明和程序源代码。本节课,我们将重点关注后者,即基于源代码的结构性测试。

结构性测试与覆盖率

结构性测试的基本思想是根据程序的结构来推导测试用例,旨在达到特定的充分性指标以提高覆盖率。这些指标与程序结构相关,因此结构性测试也被称为白盒测试。

以下是本课程将学习的四种常见的结构性覆盖率指标:

  • 语句覆盖
  • 分支覆盖
  • 条件覆盖
  • 路径覆盖

程序结构本身为我们提供了有价值的信息,既可用于创建新的测试用例,也可用于衡量程序被测试的程度。由于结构性测试关注程序结构,我们需要访问源代码,因此它属于白盒测试。

这些指标用于定义代码被覆盖的百分比。我们通常不追求100%的覆盖率,因为在某些指标下这是不可能的。其背后的基本理念是:如果不执行所有代码,就无法发现所有缺陷。而“执行所有代码”的定义,正是基于这些覆盖率指标。

与基于需求的功能测试相比,白盒测试的动机在于:功能测试可能无法系统地执行我们关心的所有代码。例如,一些与主要功能无关的辅助函数逻辑、未在规格说明中详细描述的错误处理代码,或者需求可能遗漏的某些程序结果。因此,结构性测试是功能测试的补充技术:功能测试擅长暴露概念性缺陷,而结构性测试擅长暴露实际的编码或实现错误。

控制流与数据流

在深入具体技术之前,我们需要理解程序中的控制流和数据流这两个重要概念。

我们目前使用的大多数编程语言(如C、C++)都是命令式编程语言。程序由一系列命令组成,每个命令可以改变程序的状态。这些命令被称为语句。语句可以是简单的(如赋值、函数调用),也可以是复合的(如循环、条件判断)。

程序并非总是顺序执行。条件语句会导致执行路径的分支。控制流关注的是控制权在不同代码块之间传递的逻辑顺序。数据流则关注变量值在程序中的传递信息。

在本课程中,我们更关注控制流。为了表示控制流,我们引入控制流图的概念。CFG是一个有向图,表示系统中控制流的传递。图中的节点是基本块(总是顺序执行的一组语句),边表示控制权从一个基本块传递到另一个基本块。

CFG是我们提取所有结构性覆盖率指标的基础。语句对应图中的节点,分支对应某些边,条件来自条件语句,而路径则是节点和边的序列。

结构性覆盖率指标

现在,让我们深入探讨具体的结构性覆盖率指标。

语句覆盖

语句覆盖非常直观,它要求测试用例至少执行程序中的每个语句一次。

其计算公式为:语句覆盖率 = (已覆盖的语句数 / 总语句数)

基本思想是:如果无法覆盖某个特定语句,我们就无法知道该部分代码是否存在缺陷。因此,我们希望达到100%的语句覆盖。

分支覆盖

分支覆盖要求测试用例覆盖控制流中的所有分支(即CFG中的边)。

其计算公式为:分支覆盖率 = (已覆盖的分支数 / 总分支数)

这有助于发现决策语句中的缺陷。这里引入一个概念:包含关系。如果一个度量A包含另一个度量B,意味着对于任何被测试的程序,任何满足A的测试套件也满足B。分支覆盖包含语句覆盖。这意味着如果我们能覆盖CFG中的所有边,那么所有节点也自然被覆盖了。

然而,我们并不总是选择更强的、能包含其他指标的度量,因为预算通常有限,我们需要根据实际情况选择合适的度量。

条件覆盖与决策覆盖

在了解条件覆盖之前,需要区分两个概念:决策条件

  • 决策:是一个布尔表达式,通常用于控制流或条件语句。例如,if (A && B) 中的 (A && B) 就是一个决策。
  • 条件:是构成决策的基本布尔连接词。例如,在决策 (A && B) 中,AB 就是条件。

决策覆盖要求所有布尔决策都至少被评估为真和假各一次。其计算公式为:决策覆盖率 = (已覆盖的决策数 / 总决策数)。决策覆盖包含分支覆盖。

条件覆盖关注决策中的各个条件。最基本的基本条件覆盖要求每个条件都至少取真和假值一次。其计算公式为:基本条件覆盖率 = (所有条件取的真值总数 / (2 * 条件总数))。基本条件覆盖包含分支覆盖。

另一种更强的形式是复合条件覆盖,它要求评估条件的所有可能组合。这虽然包含分支和决策覆盖,但通常因代价高昂而不被使用。

在实际编程中,许多语言支持短路求值。例如,在 (A && B) 中,如果 A 为假,则不会评估 B。在设计测试用例时,可以考虑这种场景。

修正条件/决策覆盖

修正条件/决策覆盖(MC/DC)是一种更专业且实用的条件/决策覆盖形式。它要求:

  1. 每个入口和出口点都被调用。
  2. 每个决策都取遍所有可能的结果(至少一次真和一次假)。
  3. 每个条件都取遍所有可能的结果。
  4. 每个条件都被证明能独立影响该决策的结果。

MC/DC是一种非常严格的覆盖率指标,通常用于对安全性要求极高的领域,例如航空航天软件(NASA就要求其太空程序中的软件组件满足MC/DC)。

实例:应用语句覆盖设计测试用例

让我们通过一个搜索函数的例子,实践如何应用语句覆盖来设计测试用例。

假设有一个C语言函数,用于在字符串中查找字符并返回其索引(未找到则返回-1)。我们首先为其绘制控制流图。

为了达到100%的语句覆盖,我们需要设计测试用例来覆盖所有基本块(节点)。通过分析CFG,我们可能需要多个测试用例来遍历不同的路径。设计过程是:给定程序,先绘制CFG,然后识别出能至少执行一次所有语句的潜在执行路径,最后思考如何构建测试用例来使程序按所需方式执行。

总结

本节课我们一起学习了结构性测试的核心概念。我们了解到,为了证明测试的充分性,需要基于程序结构定义覆盖率指标。我们重点介绍了四种覆盖率指标:语句覆盖、分支覆盖、条件覆盖和修正条件/决策覆盖,并理解了它们之间的关系和适用场景。我们还通过实例学习了如何根据语句覆盖来设计测试用例。在下一讲中,我们将继续学习其他结构性测试技术。

014:功能测试(二)

在本节课中,我们将继续学习功能测试技术。我们将重点介绍两种基于逻辑关系的测试方法:决策表测试和因果图法。这些方法能帮助我们更系统、更严谨地设计测试用例,尤其是在处理输入与输出之间存在复杂逻辑关系时。


决策表测试

上一节我们介绍了边界值分析和等价类划分,它们主要基于输入值或输出结果对输入空间进行划分。本节中,我们来看看决策表测试。这种方法通过构建一个表格来捕获程序在不同输入条件组合下应采取的动作,从而强制实现逻辑上的严谨性。

一个标准的决策表包含四个部分:

  • 条件桩:列出所有可能的输入条件。
  • 动作桩:列出所有可能的输出动作。
  • 条件项:针对每一列规则,列出各条件的取值(真/假或特定值)。
  • 动作项:针对每一列规则,列出应执行的动作。

决策表中的每一列代表一条规则,它是一组条件与对应动作的配对。

以下是决策表的基本结构示例:

| 条件桩 | 规则1 | 规则2 | ... |
|--------|-------|-------|-----|
| 条件C1 |   Y   |   N   |     |
| 条件C2 |   N   |   Y   |     |
|--------|-------|-------|-----|
| 动作桩 |       |       |     |
| 动作A1 |   X   |       |     |
| 动作A2 |       |   X   |     |

注:Y代表条件为真,N代表条件为假,X代表执行该动作。

决策表主要有两种类型:

  1. 有限条目决策表:每个条件只能取布尔值(真/假)。
  2. 扩展条目决策表:每个条件可以取多个离散值。

在条件项中,符号 - 表示“不关心”。这意味着该条件的取值不影响最终动作的选择。每个 - 实际上代表了两种可能(真或假),因此会使该规则代表的实际用例数量翻倍。

对于一个具有 n 个条件的有限条目决策表,理论上需要 2^n 条规则来覆盖所有条件组合。

决策表测试示例:三角形问题

我们以经典的三角形判定程序为例。程序输入三个整数(代表边长),输出三角形的类型(等边、等腰、不等边)或“非三角形”。

首先,我们定义条件和动作:

  • 条件
    • C1: a < b + c
    • C2: b < a + c
    • C3: c < a + b
    • C4: a == b
    • C5: a == c
    • C6: b == c
  • 动作
    • A1: 非三角形
    • A2: 不等边三角形
    • A3: 等腰三角形
    • A4: 等边三角形
    • A5: 不可能(条件矛盾)

根据逻辑,我们可以构建决策表。例如:

  • 如果 C1 为假(即 a ≥ b + c),则无论其他条件如何,结果都是“非三角形”(A1)。此时 C2 到 C6 都可以标记为 -(不关心)。
  • 如果 C1, C2, C3 均为真(能构成三角形),且 C4, C5, C6 也均为真(三边相等),则动作为 A4(等边三角形)。
  • 如果 C4 为真(a == b),但 C6 为假(b != c),这在逻辑上矛盾(因为 a == b 且 b != c 意味着 a != c,但 C5 未定),可能被标记为 A5(不可能)。

通过系统性地列出条件组合,我们可以得到一系列规则。对于这个有6个条件的例子,完整决策表理论上应有 2^6 = 64 条规则。但通过合并“不关心”项和消除“不可能”规则,我们可以将实际需要设计的测试用例减少到可管理的数量(例如8个)。

决策表测试示例:NextDate 问题

NextDate 程序输入年、月、日,输出下一天的日期。这个例子更能体现决策表在捕获复杂逻辑关系上的优势。

简单的边界条件(如月份 1-12)不足以处理月份天数、闰年等逻辑。我们需要更精细的条件划分:

  • 月份类型:M1 (30天月份), M2 (31天月份,非12月), M3 (12月), M4 (2月)
  • 日期:D1 (1-27), D2 (28), D3 (29), D4 (30), D5 (31)
  • 年份:Y1 (闰年), Y2 (非闰年)

动作也需要细化,不仅仅是“计算”,而是具体操作:

  • A1: 日期加1
  • A2: 日期重置为1,月份加1
  • A3: 日期重置为1,月份重置为1,年份加1
  • A4: 不可能

使用扩展条目决策表,我们可以为每个条件选择具体的值(如 M1, D1, Y1),而不是简单的真/假。这样可以更清晰、更紧凑地表示规则,避免因使用布尔值而导致的规则数量爆炸。

例如:

  • 规则:M1, D4, Y1 -> 动作 A2 (日期重置为1,月份加1)。因为30天的月份,30号的下一天是下个月的1号。
  • 规则:M4, D3, Y2 -> 动作 A4 (不可能)。因为非闰年的2月不可能有29号。

通过合并产生相同动作的规则,我们可以进一步优化决策表,减少冗余。

决策表测试的优点是逻辑严谨,能系统化地覆盖条件组合。但它也有局限性:对于复杂程序,决策表可能变得非常庞大,需要不断迭代和精化设计来消除冗余。


因果图法

在学习了如何直接构建决策表后,我们来看看因果图法。它本身并非独立的测试用例设计技术,而是一种辅助工具,用于可视化地分析和表示输入(因)与输出(果)之间的逻辑关系,并最终转化为决策表。

因果图法起源于硬件测试,它使用逻辑门符号来连接原因和结果。
基本的图形符号包括:

  • 恒等:原因出现,结果出现。
  • :原因出现,结果不出现;原因不出现,结果出现。
  • :多个原因中至少有一个出现,结果就出现。
  • :所有原因都出现,结果才出现。

使用因果图的步骤是:

  1. 识别规格说明中的原因(输入条件)和结果(输出动作)。
  2. 绘制因果图,表示出原因与结果、原因与原因之间的逻辑关系。
  3. 将因果图转换为决策表。
  4. 根据决策表生成测试用例。

因果图法示例:三角形问题(修正版)

我们尝试为三角形问题绘制因果图。原因(C)和结果(E)与之前决策表中的条件和动作类似。

一个正确的因果图需要精确反映所有逻辑约束。例如:

  • 结果“非三角形”(E1)应由“不能构成三角形”的原因导致,即 NOT(C1 AND C2 AND C3)。这意味着 C1, C2, C3 中至少有一个为假。
  • 结果“不等边三角形”(E2)需要“能构成三角形”(C1 AND C2 AND C3)且“三边互不相等”(NOT C4 AND NOT C5 AND NOT C6)同时成立。教科书示例中常会遗漏部分原因间的“与”关系。
  • 结果“不可能”(E5)通常由矛盾的原因导致,例如 C4 为真(a==b)但 C6 为假(b!=c)。

因果图虽然绘制起来可能繁琐且容易出错,但它有两个主要价值:

  1. 辅助调试:它能清晰地展示哪些输入条件的组合会导致特定的输出,帮助定位缺陷根源。
  2. 发现约束:它能揭示输入条件之间的隐含逻辑关系(如互斥、依赖),帮助我们在设计测试用例时避免冗余和无效组合。

功能测试技术总结与比较

本节课我们一起学习了四种功能测试技术:边界值分析、等价类划分、决策表测试以及辅助性的因果图法。它们的核心思想都是对无限的输入空间进行划分,然后选取代表值作为测试用例。

以下是选择和使用这些技术的一些指导:

边界值分析的局限与技巧

  • 局限
    1. 不适合布尔值或逻辑变量,因为它们没有明确的“边界”。
    2. 假设输入变量完全独立,否则会产生大量无效用例(如 NextDate 中的日依赖于月)。
    3. 仅关注输入边界,可能遗漏某些输出行为(如所有边界值测试都未产生“不等边三角形”)。
  • 技巧
    1. 健壮性测试(考虑超出边界的值)适用于测试程序内部变量的边界(如整型溢出)。
    2. 可以利用非极端的输入值,在程序内部构造出极端的边界情况(如使某个中间变量为0)。

等价类划分的使用建议

  1. 弱形式的等价类测试(单缺陷假设)不如强形式(多缺陷假设)全面。
  2. 对于强类型语言(如 Java),进行健壮性测试(输入无效类型)可能没有意义,因为错误会在运行时被语言本身捕获,而非程序逻辑。
  3. 等价类划分(关注输出)和边界值分析(关注输入)是互补的,可以结合使用。
  4. 强等价类测试同样假设变量独立,否则会产生冗余的无效用例。

决策表测试的适用场景
决策表测试在以下情况下特别适用:

  1. 程序包含大量的 if-then-else 逻辑。
  2. 输入变量之间存在逻辑关系。
  3. 计算涉及输入变量的子集。
  4. 存在清晰的因果(输入-输出)关系。
  5. 程序具有较高的圈复杂度(即分支较多)。

技术对比
一般来说,可以这样对比:

  • 复杂性/严谨性:决策表测试 > 等价类划分 > 边界值分析。
  • 生成用例数量:边界值分析(尤其是强健壮性)可能产生最多的用例,决策表测试通过逻辑合并往往能用更少的用例覆盖更多逻辑。
  • 设计工作量:决策表测试通常需要最多的前期分析和设计工作。

本质上,这是在测试效率(快速生成用例)和测试有效性(全面覆盖行为)之间进行权衡。在实际项目中,可以根据被测系统的特点选择合适的、或组合多种测试技术。


Python 语法小贴士

最后,我们简要了解几个现代 Python(3.5+)中有用的语法特性,它们能让代码更简洁清晰。

1. 海象运算符 (:=)

海象运算符 := 允许在表达式内部进行赋值,有助于减少代码行数,提高可读性。

传统写法

data = {"key": "value"}
a = data.get("key")
if a is not None:
    print(a)

使用海象运算符

data = {"key": "value"}
if (a := data.get("key")) is not None:
    print(a)

它让赋值和检查合并为一步。注意,由于其运算符优先级最低,在复杂表达式中建议用括号括起来。

2. 模式匹配 (match-case, Python 3.10+)

match 语句类似于其他语言的 switch,但功能强大得多,可用于值匹配、类型匹配和结构匹配。

基础值匹配

def http_status(code):
    match code:
        case 200:
            return "OK"
        case 404:
            return "Not Found"
        case 500:
            return "Server Error"
        case _: # 通配符,匹配任何情况
            return "Unknown code"

类型与结构匹配

def process_data(data):
    match data:
        case [str(name), int(age)]: # 匹配一个包含字符串和整数的列表
            print(f"Name: {name}, Age: {age}")
        case {"status": 200, "data": list(items)}: # 匹配特定键值的字典
            print(f"Got {len(items)} items")
        case _:
            print("Unknown data format")

模式匹配在验证复杂配置或API响应时特别有用,可以大幅简化条件检查代码。

3. 格式化字符串字面值 (f-string, Python 3.6+)

f-string 提供了一种更简洁、更直观的字符串格式化方式。

传统格式化

name = "Alice"
age = 30
message = "Hello, {}. You are {} years old.".format(name, age)

使用 f-string

name = "Alice"
age = 30
message = f"Hello, {name}. You are {age} years old."

f-string 直接在字符串内嵌入表达式,用大括号 {} 括起来,代码更清晰易读。还可以在大括号内添加格式说明符,如 f"{value:.2f}" 用于格式化浮点数。

本节课中我们一起学习了如何运用决策表进行更严谨的功能测试,了解了因果图作为辅助工具的作用,并对不同的黑盒测试技术进行了比较。同时,也了解了一些能提升代码简洁性的现代 Python 语法。

015:功能测试(全)🎯

在本节课中,我们将要学习功能测试的基础知识。功能测试是一种典型的黑盒测试方法,其核心思想是将待测软件视为一个“黑盒”,在不了解其内部实现细节的情况下,仅根据其规格说明来设计测试用例,以验证软件的功能是否符合预期。


功能测试简介

上一节我们介绍了测试的基本概念,本节中我们来看看功能测试。功能测试,也称为黑盒测试,其核心在于从用户视角出发。这意味着测试人员无需访问软件的源代码,只需关注软件的输入和输出行为。

在之前的课程中,我们讨论了很多关于需求的内容。现在,我们需要更清晰地理解“需求”和“规格说明”这两个术语之间的区别。

  • 需求:描述了程序必须满足的属性,即“我们希望程序做什么”。
  • 规格说明:则更为具体,它更侧重于程序内部功能或整个系统的实际实现细节。

规格说明是根据需求制定的。例如:

  • 需求可能是:“系统需要安全的用户认证”。
  • 对应的一个规格说明可能是:“密码长度至少为8个字符,且不超过16个字符”。

在功能测试中,我们实际上测试的是这些规格说明。


边界值分析

理解了功能测试的基本原理后,我们来看看一些具体的技术。首先学习的是边界值分析。这种技术基于一个观察:程序在处理输入域的边界值时,更容易出现缺陷。

以下是边界值测试如此重要的几个原因:

  1. 程序员在定义边界条件时容易出错。
  2. 用户需求在边界处可能表述不清,导致实现出现偏差。
  3. 边界附近的测试用例通常能更有效地发现缺陷。

让我们通过一个简单的程序来应用这个理念。假设有一个程序接受两个变量 X1X2 作为输入,它们的有效范围分别是 [10, 50][20, 60]

为了设计测试用例,我们需要一个关键假设:单缺陷假设。该假设认为,失效很少是由两个或更多缺陷同时作用引起的,通常只允许一个变量取异常值。

基于单缺陷假设,我们可以设计以下类别的测试用例:

  • 健壮性测试:测试有效输入和无效输入。
  • 一般边界值测试:主要关注有效输入边界。

对于有两个变量 n=2 的程序,采用一般边界值测试(仅考虑有效输入),每个变量取最小值、略高于最小值、正常值、略低于最大值、最大值。测试用例总数为 4n + 1 = 9

如果考虑无效输入(健壮性测试),每个变量还会增加两个超出边界的无效值(略低于最小值和略高于最大值)。测试用例总数为 6n + 1 = 13


等价类划分

上一节我们介绍了基于输入值域的边界值测试,本节中我们来看看另一种重要的功能测试技术:等价类划分

这种技术结合了两种策略:

  1. 测试用例的设计应能代表整个等价类。
  2. 根据单缺陷假设,一次只让一个变量取无效值。

其核心思想是:将输入域划分为若干个子集(等价类),使得同一个子集中的每个输入数据在揭露程序潜在错误方面是等价的。我们只需从每个子集中选取一个代表值进行测试即可。

根据是否考虑无效输入,以及是否采用单缺陷假设,等价类测试可以分为几种强度不同的类型:

  • 弱一般等价类测试:基于单缺陷假设,仅考虑有效输入。
  • 强一般等价类测试:考虑有效输入,但摒弃单缺陷假设,要求覆盖所有变量等价类的笛卡尔积。
  • 弱健壮等价类测试:基于单缺陷假设,同时考虑有效和无效输入。对于有效输入,使用一个有效值;对于无效输入,一次只让一个变量取无效值,其他变量均取有效值。
  • 强健壮等价类测试:同时考虑有效和无效输入,并摒弃单缺陷假设,覆盖所有有效和无效等价类的完整笛卡尔积。

让我们通过一个“三角形分类”程序的例子来实践弱一般等价类测试。程序输入为三个边长 a, b, c。我们首先根据输出(非三角形、不等边三角形、等腰三角形、等边三角形)来划分有效输入的等价类,然后为每个类设计测试用例。


基于决策表的测试

最后,我们简要介绍第三种技术:基于决策表的测试。这种方法适用于逻辑关系复杂的场景,其中结果依赖于多个输入条件的组合。

决策表列出了所有输入条件的所有可能组合,以及每种组合对应的预期输出或动作。它系统地覆盖了条件间的交互,特别适合测试业务规则。

例如,一个折扣规则可能依赖于“是否是会员”和“订单金额是否大于100”这两个条件。决策表可以清晰地列出所有四种组合及对应的折扣。


总结

本节课中我们一起学习了功能测试的基础和三种主要技术:

  1. 边界值分析:关注输入域的边界,基于单缺陷假设设计用例。
  2. 等价类划分:将输入域划分为等价类,并区分为弱/强、一般/健壮等不同测试强度。
  3. 基于决策表的测试:适用于多条件逻辑组合的复杂场景。

这些技术的核心目标都是通过系统化的方法,对输入空间进行划分并选取有代表性的测试用例,从而高效地验证软件功能是否符合规格说明。

016:验证与验证 + 实用Python语法

在本节课中,我们将要学习软件测试中验证与验证的核心概念,并了解一些Python编程中容易导致错误的语法特性。我们将首先探讨静态与动态分析的区别,然后学习验证与验证规划的原则,最后通过一些Python代码示例来理解可变性等关键概念。

验证与验证概述

上一节我们介绍了软件测试的基础知识,本节中我们来看看验证与验证这两个核心概念。

验证关注的是“我们是否在以正确的方式构建产品”,即过程是否正确。
验证关注的是“我们是否在构建正确的产品”,即结果是否符合需求。

软件测试被视为一种验证技术。大多数验证技术属于动态软件分析技术。动态分析与静态分析的关键区别在于是否需要运行程序来进行分析。

静态分析与动态分析

以下是两种分析方法的对比:

  • 静态分析:无需运行程序,通过扫描代码、匹配规则或模式来寻找潜在的缺陷或漏洞。例如,检查数组访问时是否未验证索引值。常见方法包括形式化验证、软件组件分析和污点分析。
  • 动态分析:需要执行目标程序,并观察其运行时行为以发现异常。例如,测试程序的执行效率或寻找导致崩溃的输入。

动态方法通常比静态方法更容易实现自动化,成本也可能更低。然而,静态分析虽然强大,但不擅长发现仅在运行时出现的问题。

验证与验证技术选择

面对多种技术,如何进行选择?这通常是一个权衡取舍的过程。我们需要考虑现有资源、项目目标以及必须满足的质量要求。就像那句工程格言所说:更好、更快、更便宜,我们很难用单一技术同时实现这三者。

在质量保证中,我们必须能够容忍一定程度的不精确性,主要分为三类:

  1. 悲观不精确性:技术可能过于悲观,即使程序具备某些属性,也可能拒绝接受它。这会导致误报
  2. 乐观不精确性:技术可能过于乐观,接受了不具备所需属性的程序。这会导致漏报
  3. 属性复杂性:由于程序某些属性过于复杂难以检查,我们可能用更易检查的属性替代,从而引入不精确性。

对于测试技术,我们更可能遇到乐观不精确性(漏报)。因为如果程序运行崩溃,那一定存在缺陷;但如果程序运行正常,并不代表它完全没有缺陷。因此,测试技术通常是可靠的,但不完整

这里引出了两个关键术语:

  • 可靠性:一个技术是可靠的,当且仅当它只识别真正的缺陷(无误报)。
  • 完整性:一个技术是完整的,当且仅当它能识别出所有真正的缺陷。

大多数测试技术是可靠但不完整的。

验证与验证规划

在软件开发中,我们需要规划如何使用不同的验证与验证技术,这称为V&V规划。规划过程包含七个步骤:

以下是V&V规划的七个步骤:

  1. 确定目标:从用户需求和规格说明中识别验证与验证的目标。
  2. 选择技术:根据项目目标和各阶段特点,选择合适的V&V技术。
  3. 分配职责:明确开发团队、独立测试团队、质量保证团队乃至外部承包商等各方的责任。
  4. 技术集成:将选定的V&V技术集成到开发流程中(如瀑布模型或敏捷开发中的CI/CD)。
  5. 问题跟踪:记录发现的问题,包括发生时间、位置、系统状态、证据(如导致崩溃的输入)和优先级。
  6. 活动跟踪:记录测试活动本身,如已执行和未执行的测试用例数量、资源使用情况、发现的问题数量等,用于内部记录和第三方合规检查。
  7. 评估:收集数据,评估最终产品质量以及所执行的验证工作的有效性,为产品可信度提供保证。

分析与测试原则

在进行分析和测试时,有六项基本原则可以指导我们:

以下是六项核心原则:

  1. 敏感性:测试技术必须对故障或错误敏感。例如,仅寻找程序崩溃可能会遗漏某些错误(如缓冲区溢出读取可能不崩溃)。应尽量使故障更容易被检测到。
  2. 冗余性:鼓励使用多个测试用例或不同的测试技术来检查同一功能,以提高发现错误的可能性。例如,组合不同的模糊测试策略。
  3. 限制性:当无法廉价地检查程序的某些属性时,可以限制问题范围或关注程序的子集,以降低检查的复杂性。
  4. 划分性:采用分而治之的策略。例如,将测试划分为单元测试、集成测试、系统测试等不同级别。
  5. 可见性与可观测性:能够衡量测试进度是否符合目标,并确保验证技术本身是可观测的,以便了解其是否有效。
  6. 反馈性:应用从经验和进展中获得的教训。例如,根据故障类别的可能性来优先安排测试工作(通常更复杂的部分更可能包含缺陷)。

实用Python语法热身

现在,让我们转向一些实际的Python代码,为后续的编程作业做准备。本节假设你已了解Python和面向对象编程的基础知识。我们将重点关注一些容易导致错误的语言特性。

is== 的区别

在Python中,is 用于检查两个对象是否是同一个对象(比较身份标识),而 == 用于检查两个对象的是否相等。

a = (1, 2)
b = (1, 2)
print(a is b)    # 输出: False (两个不同的元组对象)
print(a == b)    # 输出: True (值相等)
a = 1
b = 1
print(a is b)    # 输出: True (小整数被缓存,指向同一对象)
print(a == b)    # 输出: True

注意:对于像 None 这样的单例对象,应使用 is 进行比较。

x = None
print(x is None)   # 正确且推荐的方式
print(x == None)   # 可行,但不推荐

可变性

Python中对象的可变性会影响函数参数传递的行为。可变对象(如列表、字典、集合)在函数内被修改时,会影响原始对象;不可变对象(如整数、字符串、元组)则不会。

def add_to(a, b):
    a += b
    return a

# 示例1: 不可变对象
x, y = 1, 2
z = add_to(x, y)
print(x, y, z)  # 输出: 1 2 3 (x未改变)

# 示例2: 可变对象
list_a, list_b = [1], [2]
list_c = add_to(list_a, list_b)
print(list_a, list_b, list_c)  # 输出: [1, 2] [2] [1, 2] (list_a被改变了!)
print(list_a is list_c)        # 输出: True (是同一个对象)

为了避免这种副作用,在需要修改函数内的可变参数时,应先创建副本。

import copy

original_list = [1, 2, 3]
# 创建副本的不同方法
copy1 = list(original_list)
copy2 = original_list[:]
copy3 = copy.copy(original_list) # 浅拷贝
# 对于嵌套结构,可能需要 copy.deepcopy()

可变默认参数

函数的默认参数如果是可变对象(如列表、字典),该对象会在多次函数调用间共享,这可能导致意想不到的行为。

def append_to(element, target=[]):
    target.append(element)
    return target

print(append_to(1))  # 输出: [1]
print(append_to(2))  # 输出: [1, 2] (而不是预期的[2])

正确的做法是使用 None 作为默认值,并在函数内部创建新的可变对象。

def append_to_fixed(element, target=None):
    if target is None:
        target = []
    target.append(element)
    return target

列表乘法陷阱

使用乘法操作符 * 来创建包含多个相同可变对象的列表时,实际上创建的是对同一个对象的多个引用。

list_of_lists = [[]] * 3
print(list_of_lists)  # 输出: [[], [], []]
list_of_lists[0].append(1)
print(list_of_lists)  # 输出: [[1], [1], [1]] (所有子列表都被修改了)

要创建独立的子列表,应使用列表推导式。

list_of_lists_fixed = [[] for _ in range(3)]
list_of_lists_fixed[0].append(1)
print(list_of_lists_fixed)  # 输出: [[1], [], []]

总结

本节课中我们一起学习了软件测试中验证与验证的核心理论,包括静态与动态分析的区别、V&V规划步骤以及测试的六项基本原则。随后,我们通过Python代码示例,探讨了 is== 的区别、可变性对函数参数的影响、可变默认参数的陷阱以及列表乘法的注意事项。理解这些概念和语法细节,将帮助你在编写和测试代码时避免常见错误,并为后续学习具体的测试技术打下基础。下一周,我们将开始学习黑盒测试技术。

posted @ 2026-03-29 09:32  布客飞龙II  阅读(5)  评论(0)    收藏  举报