作者 | Daniel Lemire 译者 | 弯月
我们编写软件的首要目标就是确保它是正确的。软件必须做程序员希望它做的事情,必须满足用户的需求。
企业经营采用复式记账,交易需要至少记录两个账户:借方和贷方。与更原始的方式相比,复式记账的优点之一是,它能够完成一定程度的审计并发现错误。如果拿会计和软件编程做比较,我们可以认为软件测试就相当于复式会计及其后续的审计工作。
对于会计师来说,将一个原始的会计系统转换为复式记账系统,通常是一项艰巨的任务。在许多情况下,会计师需要从头开始重建账簿。同样,对于一个已经开发完成、但根本没有任何测试的大型应用程序来说,添加测试也是非常困难的。这就是为什么在构建软件时,应首先考虑测试的原因。
急于求成的程序员,或新手程序员可能会快速编写例程,通过编译并运行,然后看到结果正确就可以了。但谨慎或有经验的程序员明白,不应该假设例程是正确的。
常见的软件错误可能会导致程序突然终止,甚至损坏数据库。后果可能会非常惨重:1996 年,由于一个软件错误,导致阿丽亚娜-5 运载火箭爆炸。而这个错误是由浮点数数转换整数引起的,这是一个16 位的有符号整数,只能表示小整数值。该整数无法表示浮点数,而程序在检测到这个意外的错误时停止了运行。很讽刺的是,触发这个错误的功能不是必需的,只是作为一个子系统从早期的阿丽亚娜火箭模型中集成而来的。按照 1996 年的物价计算,这个错误的成本大约为 4 亿美元。
生产正确软件的重要性早已广为人知。几十年来,优秀的科学家和工程师一直在努力。
确保正确性的常见策略有以下几种。例如,我们要做一项复杂的科学计算,那么就需要成立几个独立的团队来计算答案。如果所有团队得出的答案都相同,则可以得出结论这个答案是正确的。这种冗余策略通常用于防范与硬件相关的故障。然而不幸的是,编写多个版本的软件通常是不切实际的。
许多程序员都接受过高等数学教育。他们希望我们能证明一个程序是正确的。抛开硬件故障不谈,我们必须确保软件不会遇到任何错误。事实上,如今的软件非常成熟,我们可以证明程序是正确的。
下面,我们通过一个例子来说明我们的观点。我们可以使用 Python 的 z3 库。非Python 用户也不用担心,你不必实际运行这个示例。
首先,我们运行命令 pip install z3-solver 来安装必要的库。假设我们需要确保对于所有 32 位整数,不等式 ( 1 + y ) / 2 < y 成立。我们可以使用以下脚本:
import z3
y = z3.BitVec("y", 32)
s = z3.Solver()
s.add( ( 1 + y ) / 2 >= y )
if(s.check() == z3.sat):
model = s.model()
print(model)
在这个示例中,我们构造了一个 32 位字 BitVec 来表示我们的示例整数。默认情况下,z3 库会将这个变量解释为 -2147483648~ 2147483647 之间的整数值,即 -~(包括-和)。我们输入不等式:( 1 + y ) / 2 >=y(注意:与我们希望验证的不等式相反)。如果 z3 没有找到反例,则表明不等式 ( 1 + y ) / 2 < y 成立。
运行脚本时,Python 显示了一个整数值 2863038463,表示 z3 找到了反例。z3 库总是会给出一个正整数,我们只能自行决定该如何解释这个结果,比如数字 2147483648 应该解释为 -2147483648,2147483649 应该解释为 -2147483647 等等。这种表示通常称为二进制补码。因此,数字 2863038463 实际上应该理解为负数。不过,确切的值为多少并不重要,重要的是当变量为负时,我们的不等式 ( 1 + y ) / 2 < y 是不成立的。我们可以简单地验证一下,给变量赋值 -1 ,得到的结果为:0 < -1。当变量赋值为 0 时,这个不等式也不成立:0 < 0。此外,我们还可以检查一下当变量赋值为 1时,该不等式是否成立。为此,我们需要添加一个变量大于 1 的条件(s.add( y > 1 )):
import z3
y = z3.BitVec("y", 32)
s = z3.Solver()
s.add( ( 1 + y ) / 2 >= y )
s.add( y > 1 )
if(s.check() == z3.sat):
model = s.model()
print(model)
修改之后,脚本在执行时没有显示任何内容,因此我们可以得出结论:只要变量 variable 大于 1,这个不等式就成立。
既然我们已经证明不等式 ( 1 + y ) / 2 < y 成立,那么不等式 ( 1 + y )< 2 * y 是否也成立呢?我们来试试看:
import z3
y = z3.BitVec("y", 32)
s = z3.Solver()
s.add( ( 1 + y ) >= 2 * y )
s.add( y > 1 )
if(s.check() == z3.sat):
model = s.model()
print(model)
脚本运行后显示 1412098654,即 2824197308 的一半,我们需要将 z3 的这个结果解释为负值。为了避免这个问题,让我们添加一个新条件,这样变量的值在乘以 2 后仍然可以解释为正值:
import z3
y = z3.BitVec("y", 32)
s = z3.Solver()
s.add( ( 1 + y ) / 2 >= y )
s.add( y > 0 )
s.add( y < 2147483647/2)
if(s.check() == z3.sat):
model = s.model()
print(model)
这次结果得到了验证。如上所示,即便是相对很简单的情况,这种形式化的方法也需要费一番周折。在计算机科学发展的早期,计算机科学家可能比较乐观,但到了 20 世纪 70 年代,Dijkstra 等人就表达了怀疑:
我们看到,自动程序验证器在验证很小的程序时,即便使用比较快的机器,即便能同时进行许多并行处理,也会很快达到处理的极限。但即使如此,我们还是不得不怀疑,验证的结果真的正确吗?有时我在想……
大规模应用这类数学方法是不切实际的。错误的形式多种多样,并非所有错误都可以用数学形式简明扼要地表示出来。即使我们能够用数学形式准确地表示问题,也不能相信仅凭 z3 这样的工具就能找到解决方案,随着问题的难度加大,计算的时间也会越来越长。一般来说,实证取向更合适。
随着时间的推移,程序员逐步理解了测试软件的必要性。但并非所有代码都需要测试,通常原型或示例就无需进一步验证。但是,任何在专业环境中设计完成、必须实现的重要功能至少应该完成部分测试。测试能够降低将来不得不面对灾难状况的可能性。
-
单元测试。这些旨在测试软件程序的特定组件。例如,针对单个函数的单元测试。在大多数情况下,单元测试都是自动完成的,程序员只需按下按钮或输入命令,即可执行这些测试。单元测试通常会避免获取有价值的资源,例如在磁盘上创建大型文件或建立网络连接。单元测试通常不涉及操作系统的设置。
-
集成测试。这些旨在验证完整的应用程序。通常这些测试需要访问网络,有时还需要访问大量数据。集成测试有时需要人工干预,而且还需要应用程序的特定知识。集成测试可能需要设置操作系统和安装软件。集成测试也可以自动化,至少应该部分自动化。大多数情况下,集成测试都会以单元测试作为基础。
单元测试通常会作为持续集成的一部分。持续集成通常会自动执行特定任务,包括单元测试、备份、应用加密签名等。持续集成可以定期执行,也可以在代码发生变更时执行。
单元测试可用于建立软件开发流程和指导软件开发。在编写代码本身之前,可以先编写这些测试,也就是我们常说的“测试驱动开发”。通常,测试是在功能开发完成之后编写的。编写单元测试和开发功能可以由不同的程序员担任。有时,由其他开发人员提供的测试更容易发现错误,因为他们可能会做出不同的假设。
我们可以将测试集成到某些功能或应用程序中。例如,应用程序在启动时运行一些测试。在这种情况下,测试就成了分布式代码的一部分。但是,更常见的做法是不公开单元测试。毕竟单元测试面向的只是程序员,不会影响应用程序的功能。特别是,它们不会造成安全风险,也不会影响应用程序的性能。
有经验的程序员通常认为测试与代码同等重要。因此,将一半的工作时间花在编写测试上的情况并不少见。虽然会影响编写代码的速度,然而从长远来看,测试是一项投资,因此通常会节省时间。通常没有经过良好测试的软件的更新难度会更大。测试可以减少代码变更或扩展的不确定性。
测试应该方便阅读,简单且能快速运行,而且使用的内存也不会很多。
然而,我们很难准确定义测试的质量。常见的统计方法有几种。例如,我们可以计算测试覆盖的代码行数。这里,我们不得不讨论一下测试覆盖率。100%的覆盖率意味着所有代码都被测试到了。然而在实践中,覆盖率无法很好地表现测试的质量。
package main
import (
"testing"
)
func Average(x, y uint16) uint16 {
return (x + y)/2
}
func TestAverage(t *testing.T) {
if Average(2,4) != 3 {
t.Error(Average(2,4))
}
}
在 Go 语言中,我们可以使用命令 go test 运行测试。上述代码针对 Average 函数进行了相应的测试。对于上述示例,测试的运行非常成功,覆盖率为100%。
然而,Average 函数的正确性可能达不到我们的预期。如果传递进去的参数为整数(40000,40000),那么我们期望返回的平均值为 40000。但是两个整数 40000 相加不能用 16 位整数(uint16)表示,因此结果会变成 (40000+4000)%65536=14464。因此这个函数将返回 7232。是不是觉得有点惊讶?下面的测试会失败:
func TestAverage(t *testing.T) {
if Average(40000,40000) != 40000 {
t.Error(Average(40000,40000))
}
}
如果有可能,而且速度足够快,我们可以尝试更详尽地测试这段代码,比如在下面这个示例中,我们使用了另外几个值:
package main
import (
"testing"
)
func Average(x, y uint16) uint16 {
if y > x {
return (y - x)/2 + x
} else {
return (x - y)/2 + y
}
}
func TestAverage(t *testing.T) {
for x := 0; x <65536; x++ {
for y := 0; y <65536; y++ {
m :=int(Average(uint16(x),uint16(y)))
if x < y {
if m < x || m> y {
t.Error("error ", x, " ", y)
}
} else {
if m < y || m> x {
t.Error("error ", x, " ", y)
}
}
}
}
}
在实践中,我们很少能做详尽的测试。通常我们会采用伪随机测试。例如,我们可以生成伪随机数,并将它们作为参数。在随机测试中,保持确定性很重要,即每次测试运行都使用相同的值。为此,我们可以为随机数生成器提供固定的种子,如下例所示:
package main
import (
"testing"
"math/rand"
)
func Average(x, y uint16) uint16 {
if y > x {
return (y - x)/2 + x
} else {
return (x - y)/2 + y
}
}
func TestAverage(t *testing.T) {
rand.Seed(1234)
for test := 0; test <1000; test++ {
x := rand.Intn(65536)
y := rand.Intn(65536)
m :=int(Average(uint16(x),uint16(y)))
if x < y {
if m < x || m> y {
t.Error("error ", x, " ", y)
}
} else {
if m < y || m> x {
t.Error("error ", x, " ", y)
}
}
}
}
基于随机探索的测试是一种通常称为“模糊测试”的策略的一部分。
我们的测试通常可以分为两大类,即正向测试与反向测试。正向测试旨在验证功能或组件是否按照约定的方式运行。上述 Average 函数的第一个测试就是正向测试。反向测试检验的是,软件能否在意外情况下正确运行。我们可以通过提供随机数据(模糊测试)执行反向测试。如果上述程序只能处理小整数值,那么我们的第二个示例就可以视为反向测试。
如果修改代码,则上述测试都无法通过。在此基础之上,我们还可以采用更为复杂的测试方法,比如随机修改代码,并确认这些修改会导致测试失败。
有些程序员选择根据代码自动生成测试。这种方法可以测试组件并记录结果。例如,在上述计算平均值的示例中,Average(40000,40000) 得出的结果是 7232。如果随后代码发生变更,导致结果发生变化,则测试会失败。这种方法可以节省时间,因为测试是自动生成的。我们可以快速轻松地实现 100% 的测试覆盖率。然而,这样的测试可能会产生误导。特别是,这种方法可能会记录下不正确的行为。此外,这样的测试只保证了数量,却无法保证质量。对验证软件的基本功能没有帮助的测试甚至是有害的。不相关的测试会在后续版本变更时浪费程序员的时间。
最后,我们回顾一下测试的好处:测试可以帮助我们组织工作流程,测试是质量的衡量标准,可以帮助我们记录代码,避免回归错误,有助于调试,还可以帮助我们编写更高效的代码。
组织
设计一款复杂的软件可能需要付出数周或数月的辛苦。大多数时候,我们会将工作分解为一个个独立的单元。在最终产品到手之前,我们都很难判断结果。在开发软件时,编写测试有助于组织我们的工作。例如,某个组件在编写完成,并通过测试后,才会被视为完整。如果没有编写测试的过程,则更难估计项目的进度,因为未经测试的组件可能还远未完成。
质量
测试还可以表现出程序员对工作的投入程度。我们可以通过测试,快速评估软件程序的各种功能和组件,通过精心编写的测试则表明相应的代码是可靠的。而没有测试过的功能可以作为一种警告。
有些编程语言非常严格,并且可以通过编译验证代码。而有些编程语言(Python、JavaScript)则给程序员留下了更多的自由。有些程序员认为,测试可以克服不太严格的编程语言的限制,向程序员施加一重额外的约束。
文档
软件开发通常应具备清晰完整的文档。然而在实践中,文档通常是不完整的、不准确的,甚至是错误的,或压根不存在。因此,测试就成为了唯一的技术规范。程序员可以阅读测试用例,然后调整对软件组件和功能的理解。与文档不同,如果测试定期运行的话,则一般都是最新的,而且非常准确,因为测试都是用编程语言编写的。因此,测试示范了代码的使用方法。
即使我们想编写高质量的文档,测试也可以发挥重要的作用。为了说明计算机代码,我们经常需要使用示例。每个例子都可以变成一个测试。因此,我们可以确保文档中包含的示例是可靠的。如果代码发生变更,并且需要修改示例,那么测试示例的过程会提醒我们更新文档。这样,我们就可以避免文档中出现过时的示例,给读者带来不良体验。
回归
程序员会定期修复软件中的缺陷。相同的问题也有可能因为不同的原因而反复出现:原有的问题没有得到根本性的解决;代码某处的变更会导致其他地方返回错误;添加新功能或优化软件导致返回错误或出现新 bug。当软件出现新缺陷时,我们称之为回归问题。为了防止此类回归问题,重要的一个举措是针对每个错误修复或新功能执行相应的测试。运行这种测试,我们就可以在回归问题出现时立即注意到。理想情况下,修改代码后,运行回归测试,就能发现回归问题,这样就能避免回归问题。为了将错误变成简单有效的测试,我们应该将错误简化为最简单的形式。例如,对于上述求平均值的示例,我们可以在附加测试中添加检测到的错误:
package main
import (
"testing
)
func Average(x, y uint16) uint16 {
if y > x {
return (y - x)/2 + x
} else {
return (x - y)/2 + y
}
}
func TestAverage(t *testing.T) {
if Average(2,4) != 3 {
t.Error("error1")
}
if Average(40000,40000)!= 40000 {
t.Error("error2")
}
}
错误修复
在实践中,广泛的测试套件可以更快地识别和纠正错误。这是因为测试缩减了错误的范围,并为程序员提供了一些保证。从某种程度上说,编写测试所花费的时间可以缩短发现错误的时间,同时减少错误的数量。
此外,编写新测试也是识别和纠正错误的一种有效策略。从长远来看,这种方式比单步调试代码等其他调试策略更有效。事实上,在调试完成后,除了修复 bug 之外,你还应该添加新的单元测试。
性能
测试的主要作用是验证功能和组件能否产生预期的结果。然而,也有很多程序员使用测试来测量组件的性能。例如,测量函数的执行速度、可执行文件的大小或内存使用情况。这些测试能够检测代码变更造成的性能损失。你可以比较自己的代码与参考代码的性能,并使用统计测试检查差异。
总结
所有计算机系统都有缺陷。硬件随时可能出现故障。即使硬件可靠,程序员也几乎不可能预测软件在运行中遇到的所有情况。无论你是谁,也无论你多么努力地工作,你的软件也不会完美。尽管如此,我们还是应该竭尽所能编写正确的代码:能够满足用户的期望。
虽然无需编写测试也可以编写正确的代码,但是测试套件的好处在难度或规模较大的项目中是切实可见的。许多有经验的程序员都会拒绝使用未经测试的软件组件。
编写测试的好习惯可以帮助你成长为一名更好的程序员。在编写测试的过程中,你会进一步意识到人类的局限性。在与其他程序员和用户交互时,如果有一个测试套件,就可以更好地思考他们的反馈。
推荐书单
James Whittaker、JasonArbon、Jeff Carollo,《How GoogleTests Software》,Addison-Wesley Professional 出版,第 1 版(2012 年 3 月 23 日)
Lisa Crispin、JanetGregory,《Agile Testing: A Practical Guide for Testersand Agile Teams》,Addison-Wesley Professional 出版;第 1 版(2008 年 12 月 30 日)
https://lemire.me/blog/2022/01/03/how-programmers-make-sure-that-their-software-is-correct/
—点这里↓↓↓记得关注标星哦~—
一键三连 「分享」「点赞」「在看」
成就一亿技术人