原文地址:impracticalprogrammer.com/TestLikeHar…
原文作者:impracticalprogrammer.com/
发布时间:2021年8月
像硬件工程师一样测试
我是一名软件工程师,大部分时间都与某种形式的硬件工程师合作,特别是CPU设计师和验证团队。硬件和软件工程在表面上是非常相似的--架构一个设计,用一种编码语言实现它,编写测试,通过一些工具运行,将代码转换成有用的东西,然后发货。我通常不羡慕我的计算机工程朋友--发布的时间线本质上很长,专业知识往往很狭窄,瀑布式的统治,而且工具有不合格的声誉。但我确实羡慕硬件世界的测试。为了制造他们有信心的芯片,像英特尔和ARM这样的硬件公司结合了非常正常和深奥但强大的技术。尽管软件项目很少使用这些测试方法,但基本原则可以用来使你的下一个测试计划非常全面。
产生正确的数据
为了使测试有意义,必须有一个正确性的定义。软件通常使用手工编码的每个输入的预期值和内部断言的组合。硬件公司也使用这些(尤其是断言),但是为一个在现代CPU上运行的复杂程序生成预期值通常很难用手来完成。相反,一个CPU公司可能有一个模拟器。或者,许多模拟器。
仿真器通常是一些被测试的架构的C/C++行为模型,其质量和与真实设备的对应关系因公司和需求而异。许多公司会有各种不同的模拟器,例如,一个周期精确的模拟器,可能慢得离谱,但让你检查一切都发生在你期望的时候。一个更快的模拟器可能只是做动态二进制转换,在x86上运行目标架构--确切的时间可能不匹配,但你会得到预期的数据值,几乎和原生运行一样快,特别是对于循环重的测试,可以重复使用转换。
如果一个芯片使用了良好的文档结构,模拟器甚至不需要手动编写--可以直接执行规范来生成预期值。这显然是不容易的,特别是当规范包括未定义的行为时,但这是在相互冲突的模拟器之间进行仲裁的好方法。它也可以成为实验可能的架构变化的好方法,并且可以帮助用我们以后看到的技术正式验证组件。
大多数软件项目不能为了比较而维护第二个实现,但也不一定要把开发成本提高一倍。一个参考实现可以跳过很多性能和可扩展性的问题。参考可能会使用更高级别的语言或现成的组件,这些都不适合用于生产。事实上,由于许多重构保持了行为,但以牺牲清晰度为代价提高了性能,参考实现甚至可能只是一个未优化的、预重构的版本。
让测试变得有趣
现在我们有了一种方法来检查我们的代码在某种情况下是否做了正确的事情,我们需要把它放在能够揭示错误的情况下。对于CPU来说,这通常意味着 "有趣的 "小程序,但对于一个软件项目来说,这可能意味着任何一系列的API调用。CPU公司确实手动编写了大量针对特定功能的单元和集成测试,但这一过程与编写软件中的白盒集成测试基本相同,所以我们认为这是理所当然的,并将重点放在随机和正式测试上。
随机测试
随机测试生成器(通常称为模糊器)了解所有可能的输入领域(例如,CPU的32位指令序列,许多软件系统的JSON文档),并从该宇宙中抽取测试案例。这里的复杂程度可以是巨大的。
一个简单的、快速的、有时出乎意料地有效的测试生成器可能只是从/dev/random中读取并向一个API抛出字节。这很好,如果随机输入可能是有意义的。许多纯随机的输入只是不能通过粗略的输入检查,你最终会很好地测试你的输入验证,而核心功能则完全没有。
其他的,基于突变的生成器可能采取已知的良好输入,对其进行随机修改,并使用修改后的输入作为测试案例。输入修改得越剧烈,产生的测试种类就越多,但测试用例就越有可能无法通过输入验证。基于突变的生成器也可以非常有效,但取决于广泛的有代表性的种子输入,以覆盖很多地方。
为了探索广泛的内部状态,最好的办法是建立一个模糊器,了解输入 "应该 "是什么样子,并随机产生大部分有效的输入。例如,一个计算器的模糊器可以理解 "+"运算符在两个结果之间,一个开放的小括号需要一个封闭的小括号,等等。这些要求可以被正式写成。
Number = {[0-9], [0-9]$Number} // A Number is a digit, optionally followed by more digits
Binary_Operator = {-, +, *, /, ^} // e.g. 4 ^ 3,
Unary_Operator = {- sin cos tan} // Allow negative numbers, trig functions
Statement = {$Number, "(" $Statement ")", $Statement $Binary_Operator $Statement, $Unary_Operator $Statement}
模糊器可以读取这些定义,并通过选择四种类型的语句中的一种,用生成的该类型的实例反复替换$变量来产生随机语句(测试案例)。比如说
Test case = $Statement
Test case = $Statement $Binary_Operator $Statement
Test case = ( $Statement ) ^ $Number
Test case = ( $Number ) ^ 4$Number
Test case = ( 8 ) ^ 42$Number
Test case = ( 8 ) ^ 428
这种类型的测试可以产生非常复杂、有趣的测试,可能会探索人类不会考虑的领域(例如,'- ( - - 2)'将是这个语法中的一个有效测试)。更有趣的领域可以被赋予更多的权重。例如,tan运算符可能比指数化更容易出错,因此应该进行更多的测试。然而,快速生成测试结果可能很棘手,而且最有趣的测试案例可能很难表达,特别是在输入的各部分之间存在依赖关系的情况下。
到目前为止,我们已经讨论了一次建立一个输入(测试案例)。在CPU中,最有趣的错误实际上来自于生成一系列的输入--例如,一个程序可能有一条指令,它在进程流的后面覆盖了指令(一种自我修改代码的形式)。然后,另一条指令可能会引起异常,暂时冲刷大量的状态。当程序到达被覆盖的指令时,它看到的是原代码还是修改后的代码?它应该看到的是哪一个?由于CPU保持着如此多的状态,有趣的bug往往是在几条指令的互动中,甚至可能取决于事件之间的确切时间。
为了找到依赖序列的错误,CPU验证团队使用了静态和动态发生器的工具。静态随机序列发生器只是通过上述方法之一生成大量的指令,并希望通过纯粹的数字力量找到有趣的序列。动态发生器模拟被测设备的内部状态,并根据该状态动态地选择序列中的下一个项目。例如,如果内存单元已经处于重载状态,那么如果下一条指令在最近使用的地址上进行缓存行刷新,那么比起完全随机的指令,你更有可能发现一个错误。大多数基于序列的工具处于中间位置--例如,从静态模板中生成大多数指令,但仔细选择加载、存储和分支,以确保它们大多进入有效地址。动态生成在某种程度上与软件世界中的符号执行有关,尽管它通常较少依赖于对被测系统的检查,而更多的是依赖于测试者编写的代码,指示基于当前系统状态生成什么类型的输入。
形式化的测试
如果你已经生成了一堆随机的输入并消除了错误,那么明显的下一步就是尝试所有可能的输入以确认不可能有错误。这在字面上很少是可行的。相反,自动化的 "形式化 "分析工具可以用来详尽地证明设计的某些属性,例如,某个组件是无死锁的。硬件中最常见的形式化工具是依靠假设来证明断言的。对于任何断言,该工具检查代码流并寻找相关输入的任何组合,在假设的情况下,这些组合可能违反断言。一个被证明的断言成为一个新的假设,或者提供一个反例。该技术与可满足性求解器非常相似,后者也是NP-完备的,但对于许多实际问题来说,也可以在合理的时间内完成。在实践中,形式化验证最难的部分是编码足够的假设和断言,以便工具在合理的时间内取得进展,真正覆盖所有可行的输入,并涵盖系统的所有正确性属性。形式化工具被用于选定的硬件子系统,但很少用于软件,只有明显的例外。
检验测试
你已经在这些测试上花了很多时间,但你怎么知道测试本身是否是高质量的?
检查覆盖率
在硬件和软件中,最常见的测试质量指标是某种版本的覆盖率--可能发生的事情有多大一部分确实发生了?例如,有多少行代码被测试了,或者有多少if()语句的if和else分支都被执行了(读者可以练习一下,为什么100%的行覆盖率不等于100%的条件覆盖率?硬件领域的覆盖率通常包括手工编码的 "覆盖点",每个覆盖项都声明了该属性的可能状态。例如,可能会有一个覆盖点来经历每个可能的异常类型,或者一个覆盖点来运行每个可能的保护环,一个覆盖点来测试某个缓冲区是否已满,等等。通常,CPU测试计划要求交叉,这意味着一个覆盖点可能采取的每个值都应该与另一个覆盖点的每个可能的值一起出现,并手动添加逻辑上不可能的豁免(例如,如果CPU运行在一个特权级别,某些特权异常就不会发生)。 覆盖率包括交叉点的趋势导致了总覆盖率的指数式爆炸。为了使覆盖率保持一个有用的指标,一个CPU公司可能会维持一些工程师团队,他们的全部工作就是编写测试覆盖率要求。编写好的覆盖率要求是保证测试质量的关键,因为覆盖率团队需要针对交互中的交叉点,这些交叉点实际上有可能揭示出错误,而不需要太多的交叉点,以至于测试计划完全不可行。
即使精心选择了覆盖率,编写定向或随机测试以击中某些覆盖交叉点也是非常困难的。例如,一个覆盖交叉点可能需要内存访问失败,而存储缓冲区是满的。但是,让某些非常大的缓冲区填满本身就很棘手,有时需要仔细调整代码,使CPU的某些部分性能最大化,同时限制另一部分的响应能力。在上面添加其他要求会使覆盖率交叉点几乎不可能达到,同时也很难证明它不可能达到(这将证明该交叉点的豁免是合理的)。
为了达到这些难以达到的情况,一个方便的技巧是测试一个不同的系统,它与你真正想测试的系统相关。例如,将缓冲区的大小模板化,并建立一个新版本的系统,其缓冲区的大小是生产缓冲区的1/10。测试修改后的版本,如果修改后的版本在测试中没有错误,那么你至少可以有一些信心,真实的系统不会有与缓冲区满的相关的错误。
检查bug
与其检查有多少设计被测试,不如通过发现哪些bug以及如何发现来评估测试的完整性。最简单的启发式方法是,当继续测试不再发现bug时,就停止测试。在项目的早期,你会发现很多bug,在这些bug被解决之前,你无法有效的继续测试。后来,每一个手写的或随机生成的测试将越来越不可能找到一个bug。在这一点上,你可以宣布,如果有更多的bug,它们不太可能是非常重要的,并开始运送产品。
如果盯着bug曲线并决定它们看起来很平坦并不像你希望的那样以数据为导向,你可以尝试估计你的测试在理论系统中会发现多少bug,并猜测这就是他们在你的系统中发现的那部分。例如,在突变测试中,你故意在设计中引入一个缺陷,并运行测试套件。如果测试发现了这个错误,突变体就会被 "杀死"。突变体被杀死的速度接近于测试套件所发现的错误的比例。突变测试的最大问题是现实性--不成熟的突变可以引入错误,以至于它们永远不会进入测试套件,因此突变测试倾向于高估测试套件的有效性。
突变测试背后的想法是合理的,但需要一个更现实的错误集。幸运的是,每个项目都有一个完整的团队不断地将现实的错误引入到代码中--那些最初编写代码的工程师。与其自动生成新的错误,不如重新引入通过版本控制提交信息确定的旧错误,或来自相关项目的错误,并检查这个项目的测试用例是否能识别所有这些错误。这里显然有一些样本偏差--你永远不会发现你的测试错过了一种你从未发现的错误类型。
一个有趣的,如果可能是不切实际的,估计测试完整性的想法是,从两个独立的测试套件的重叠部分推断出错误的总数。如果两个不同的测试套件只发现相同的bug,这可能是因为这些是所有存在的bug。如果它们都发现了完全不同的错误,那可能是因为存在大量的错误,但两个测试套件都只发现了其中的一小部分。在捕获-标记-再捕获方法中,运行两个测试套件,并报告每个系统单独捕获的bug数量,以及两个测试套件捕获的bug。然后假设这些测试套件是独立的。估计的bug总数是。
估计的错误总数=套件A捕获的数量*套件B捕获的数量/两个套件捕获的数量
像突变测试一样,这种方法可能会高估测试套件的有效性。有些错误比其他的更容易被抓住。这些错误在 "被两者捕获 "的类别中占的比例过高,这将拖累对总错误的估计。
处理错误
尽管做了很多努力,有时错误还是会被发现。回顾和更换一个CPU的费用是非常昂贵的。因此,计算机工程公司对错误的可能性进行规划。通常情况下,错误是轻微的。每家公司都会制作勘误表文件,列出小的错误,这些错误通常会被忽略,除非在专门的工作负载中。有时,CPU设计者感觉到某项优化有可能是错误的,所以他们增加了没有记录的功能来禁用优化,并退回到另一个进程。如果真的有问题,这些 "鸡肋位 "以后可以在内核中设置,例如,为了正确性而牺牲一点性能。例如,在Spectre之后,英特尔发布了一个微代码更新,以便能够访问一个控制优化的新寄存器,使CPU容易被利用。一些软件项目也有类似的回退过程--想想看,通过设置一个秘密环境变量来解决一个错误的指令。最后,有时CPU的设计可以在制造过程中被改变。在所有的晶体管被制造出来之后,导线被沉积在晶圆的顶部,以便将晶体管彼此连接起来。重新设计这些金属层比重新设计工艺中的每一层都要便宜,所以公司有时会使用 "金属改变 "来解决一个错误。这里最接近的软件类比是通过黑掉一个链接器脚本来修复软件的错误,因为重新编译软件的成本太高。 我不建议在你的下一个错误修复中从金属变化中寻找灵感。
2021