关于测试不可能的思考

2020-09-11 21:22:32

当我看到人们如何测试他们的代码(尤其是单元测试)时,我总是有点目瞪口呆。我常常找不出一套连贯的规则来编写测试。

从实用的角度看,测试似乎是有效的,我们有证据表明它是有效的,但是不同的人用不同的方式定义测试。我非常担心,有些人所说的测试与那种不严格但务实的做法完全无关,这种做法可以让我们免受生产缺陷的困扰。

为了展示我与测试相关的武断的形式主义,我认为值得看看一些案例:

#测试乘法函数func multiply(int a,int b)->;intfunc test_1():assert 2*5==multiply(2,5)assert 10*12==multiply(12,10)func test_2():assert 10==multiply(2,5)assert 120==multiply(12,10)函数test_3():assert type(multiply(2,5))==int assert type(Multiply)==funy。

#测试图像分类算法函数CATEGIFY(arr[int]image)->;strfunc TEST_1():Assert img_classification_nn(to_arr(';cat.jpg';))==CATEGRY(TO_ARR(';cat.jpg';))Assert img_classification_nn(to_arr(';dog.jpg';))==CATEGRY(TO_ARR(';dog.jpg';))Func TEST_2():Assert';CAT';==classfy(to_arr(';cat.jpg';))assert';dog';==classfy(to_arr(';dog.jpg';))func test_3():Possible_Targets=[';cat';,';cheta';,';Tiger';,';dog';,';其他';]在Possible_Targets中断言分类(TO_ARR(';rand_img_1.jpg';))在Possible_Targets中断言分类(TO_ARR(';rand_img_2.jpg';))。

对于这两个示例,test_1显然没有意义。您正在使用另一个执行相同操作的函数测试函数的功能。如果用来断言正确功能的函数更好(即,如果*比Multiply好,或者IMG_CATEGRATION_nn比CATEGORY好),那么只需使用该函数即可。如果它们同样有缺陷,那么测试就是多余的。

这种测试是一种建立质量错觉的戏剧性表演。通常出现在企业级代码库和喋喋不休地谈论TDD的团队中,我会将其归类为冗余检查。

接下来,test_2更有趣,它将函数的结果与人类期望的函数结果进行比较。在这里,我认为测试对于分类函数是有意义的,但是对于乘法函数是没有意义的。

计算机擅长繁殖,而人类在这方面做得很糟糕。因此,用任意数量的人类答案来测试这个函数,更有可能暴露出人类答案的缺陷,而不是计算机。由于该函数非常简单,最好花时间检查实际代码,以确保它没有错误。

另一方面,计算机在图像分类方面并不是很在行(尽管它们已经在这方面取得了进展),但人类很在行。因此,使用人类的答案来验证视觉算法是非常有意义的。

最后,我们来看一下test_3,它表面上看起来没有其他两个那么有缺陷。与其说它是一个测试,不如说它更像是一项健全性检查,我们不是在检查给定情况下函数的实际返回值,而是检查更高级别的行为。

这些测试在动态语言的代码库中更为常见,这是有充分理由的。在我们的第一个示例中,我们只是验证函数的签名,编译器将在任何静态类型的语言中隐式地为我们做这件事。

在第二个示例中,我们反而绕过了类型系统的限制。我们正在检查我们的函数的结果(字符串)是否包含在7个不同的值(我们的函数应该将图像分类为的可能值)中,我们可以通过使用支持枚举类型的语言并编写classfy以使其返回这7个类的枚举来隐式测试这些值。

基本上,这些类型的测试可能是有用的,但它们是编译器可以自动处理的事情,当它们出现时,它们通常指示程序员正在为项目使用的语言中存在错误。我们将这些编译器规则称为。

显然,这个系统是相当武断的,这只是我在查看了各种代码库并与不同的人讨论了他们的测试后得出的结论,但我发现它工作得相当好。

编译器规则很棒,但是当它们泄露到您的测试中时,这不是一个好兆头。

一定数量的编译器规则泄漏到测试中是无能为力的。在Rust和/或用于安全多线程的性能良好的库出现之前,测试线程安全性应该是许多代码库中的一项要求。类似地,在现代类型系统和分配技术出现之前,人们应该假设各种现在毫无意义的内存检查在代码库中可能运行良好(我假设在某些嵌入式设备上仍然如此)。

更重要的是,切换到更现代的语言和编译器通常比仅仅编写一些测试困难得多,但我认为这些测试应该被视为对当前使用的语言的警示信号。如果它们只覆盖了代码库的一小部分但很关键的一部分,那么一切都很好,如果它们覆盖了大部分,那就是使用了错误的编译时工具的标志。

这些测试的一个困难之处可能是发现它们,一个没有经验的团队可能正在编写这些测试的负载,而没有意识到他们正在使用的编译时工具链有更好的替代方案来取代对它们的需求。

多余的检查通常是毫无意义的工作,但在某些情况下,它们可以发挥有价值的作用。

给定同一函数的两个版本,一个经过良好验证,另一个实验函数,其中实验函数在性能或通用性方面有一些好处,使用经过良好验证的方法测试实验函数似乎是合理的。尽管如此,这里的开销是如此严重,我发现很难想出一个现实世界的例子来应用这一点。

给定同一函数的两个版本,一个在新代码库中,另一个在旧代码库中,使用旧版本测试新版本是有意义的。从这个意义上说,回到编译器规则,冗余检查在重构过程中可能很有用,特别是对于重大更改,如更改构建项目所基于的语言或核心框架。然而,这似乎只是一种临时措施,而不是项目的永久固定措施。

话虽如此,我敢打赌,正如前面提到的那样,大多数属于这一类别的测试只是填充性的,表明一个团队从事忙碌工作的一个更大的潜在问题。如果我曾经接受过这样的测试,我会需要大量的赞誉评论来解释它们为什么存在,以及什么时候可以删除它们。

人工验证是一个人可以执行的最明智的测试类型,它可以被好的实践或好的语言所取代。人工验证的问题来自于这样一个事实,即在某些情况下,人类智能不能解决软件设计要解决的问题,或者不能涵盖软件将遇到的所有边缘情况。

以银行软件为例。很容易想象,一款为数十名客户、数百笔交易和一些监管限制提供服务的银行软件会发生什么。那里的所有逻辑都可以写到验证我们软件的测试中。然而,软件本身必须扩展到数百万客户、数万亿交易和数千个监管限制。为了写测试,人脑无法连贯地理解的东西。

尽管如此,银行软件仍然是一个简单的例子,因为各种组件可能是可测试的,即使考虑到所有的边缘情况,人类也可能完全理解它们有限的逻辑。这是因为软件的输入定义得非常好,使用银行API可以做的事情只有这么多,这是一个有限的空间,当适当地分解时,人类的大脑可以穷尽地探索。

当我们的软件具有非常宽和/或定义很差的输入和/或输出空间时,一个真正的问题就会出现。以下是几个这样的例子:

从广泛的网站(例如搜索引擎使用的网站)提取信息的抓取器。

机器学习算法,用于处理广泛的数据(例如,决策树或SkLearning等库中的梯度提升分类器实现)。

必须与用户提供的代码扩展配合良好工作的任何软件(例如,想一想必须支持MODS的类似视频游戏的Skyrim)。

人们可以将它们分成具有输入-输出空间的组件,这些组件很容易被人类思维所理解,但一些遭受上述问题困扰的组件肯定会继续存在。它还引入了以便于测试组件的方式编写代码分离的问题,而不是将重构、扩展、速度或可读性作为主要关注点。

假设我们设计的软件有许多人类可以理解的组件,那么成本问题仍然存在。人类可理解是一个模糊的术语,有很多东西,只要有足够的时间,一个人就可以写出详尽的测试,但在实践中,这往往需要比我们分配的时间更多的时间。此外,在某些情况下,即使在输入和输出空间有限的情况下,编写生成逻辑(将输入映射到输出)可能仍然比我们自己想出映射更容易。

在某些情况下,可以进行详尽的验证,但成本可能高得令人望而却步。银行再次成为实践中这样做的一个很好的例子。很多事情都依赖于他们(相对简单)的软件,以至于他们实际上可以为每个组件支付这种详尽的验证费用,但即便如此,灾难性故障也很容易发生(例如,汇丰银行(HSBC)在2019年和2016年的故障)。

人们最多可以将软件分解成组件,以便其中的一小部分具有足够有限的输入和输出空间,供人类进行测试。但要想让这一切物有所值,可测试的组件应该是软件中最关键和或最容易出错的部分,否则我们不会在提高可靠性方面做很多工作。此外,这将我们置于一个范例中,在该范例中,我们正在为测试构建代码,而不是为代码构建测试。

我怀疑答案就在冒烟测试、测试普通用户流、测试我们怀疑软件会失败的边缘情况,以及测试失败是不可接受的关键组件(例如,这将导致我们的保险不承保的生命损失)的组合中。

我看到的大多数测试要么是多余的,表明团队使用了错误的语言,要么是设计起来极其困难,以至于(在可能的情况下)设计成为比编写实际软件更大的挑战。

最初,我计划写一篇关于这个主题的更大的文章,在那里我更深入地研究我认为可能的解决方案。看看好的试探法,它可以构建一个连贯的图景,说明应该为任何给定的产品编写什么类型的测试,但是我写得越多,问题看起来就越难。

最后,我构建了上述枚举的不同表达式,作为后续启发:

在A系列的中途,我发现自己身处无人涉足的领域,被迫选择了测试方法。

啊,我!说出我认为自行车有多无聊是一件多么困难的事情,这让我更难把它修好。

然后,我的恐惧有点平息了,想起我已经实现了许多长期有效并为我稳定服务的工具。

但是,我的团队正在向前逃亡,他们把它们变成了更标准的东西,我担心这些东西只会是浪费。

在拒绝这么多公关的同时,在我的眼前出现了一个人,他在手艺和精神上似乎是一个实用主义者。

通过我,方式是去企业代码库,通过我,方式是耐用规格,通过我,方式是空覆盖。

现在,你是著名的吉尔·罗森,现代科学编码的牧羊人吗?";我害羞地额头回答他。

他把我带到一片荒地,没有创造的东西,只有腐败的公司代码的海洋,放弃所有的连贯性,那些进入这里的人;

这位中层经理眼里洋溢着喜悦的神情,要求他们进行无穷无尽的考验,为落后的人进行重组。

因此,我们陷入了第一种方法,编写测试就像一个编译器验证类型并断言签名一样。

高级单口相声大师站在那里可怕地站着,根据覆盖率要求更多的评委和等级公关,当他们缺乏的时候就责骂他们。

这些测试的存在是为了建立一种软件质量的错觉,尽管它有缺点,但类型系统永远不会取代它们。

唉,这里将没有编译器,除了那些长期被遗弃的编译器,所以也许最好还是保留这些测试。

我们要下岸的地方,曲折曲折,没有尽头。一条迷宫横七竖八地伸展着,不断地上上下下。

然而,在我尊贵的向导的直升机的帮助下,我们到达了我们的第二种方法的平原,这种方法暴露了一个我将翻译成多余的名字。

多余的检查是毫无意义的工作,尽管它们可能有稀疏的价值,而且这种方法分成了两个分支。

其中我们的代码更具实验性,我们的测试是验证类似创建的众所周知的实现。

我们努力追求速度、性能、美感,但我们知道,古老而丑陋的事物往往蕴含着真理,因此我们的道路保证了我们的道路是正确的。

然而,这种方式往往会误导人们,呼吁对两个并行代码库进行毫无意义的维护,这同样是错误的。

在第二个流程中,我们的代码库有了新的逻辑,但这只是为了杀死旧的。

然而,为了确保这场斗争是公平的,年长的人可以用所有剩余的精力进行还击,警告我们错误和缺点。

旧的逻辑很慢,但对测试很好,新的逻辑很快,但还没有被证明,因此前者是有效的,而后者是生产的。

然而,大多数测试用例都是在这里出现的,仍然不应该假设有一种用法是在错误严格的鞭子下编写的。

就在我们以为地平线消失的时候,我们看到了远处的一座小山,从那里我们看到了一堵用代码筑成的墙。

功能代码、命令性代码、OO代码功能、不活动的代码,以及语言混乱的代码令人难以想象。

这是所有方法中最明智的一种,它座落在务实的城市,第三座,也就是人类验证的城市。

它是有效的,但在神秘的范围内,银行、会计和客户关系管理都是微不足道的。

然而,人们仰望模拟器和编译器,他们看到的是通用算法和VM Agast天空中充斥着内核和创造性软件。

对于所有这些,输入和输出对于人类来说太大了,只有代码本身才能真正理解。

我看到成堆的测试人员写得很快,不断地拆分成更小的组件,这样我们的表单就可以理解了。