翻译:《为什么大多数单元测试是浪费 - 续》

版权声明:所有博客文章除特殊声明外均为原创,允许转载,但要求注明出处。

前言:本文是 James O Coplien 的文章《为什么大多数单元测试是浪费》第二部分的中文翻译。原文地址:Segue。关于该翻译的介绍与背景,请参考 索引 部分。对于其中部分可能不太容易理解的地方,我尝试自己加入一些说明,您可以参考最后部分的脚注。

2.1 介绍

我想是时候放出第二期了。我之前的文章一开始只是给客户的一个随意的回复,但当 Rex Black 把它发布在他的网站上时,它就像病毒一样迅速传播开来————在Reddit上排名第三,并出现在其他社交网站的显著位置。从那时起,我很荣幸地看到了对话的展开。它的范围从深刻到愚蠢,可悲的是,大部分属于后者。看起来似乎有一个非常广泛的神话,可能是由行业的希望所推动、并且被急于证明自己存在理由的学术项目和顾问所刺激。

对于我在前一篇文章中所阐述的观点,几乎没有什么真正的论据,但有很多情感上的分歧。在第二轮对话中,我从第一轮对话中获得了灵感,并提供更多的见解和意见。请原谅我在这一轮的观点比第一轮多了一些,但我之前已经讨论了大部分实质性的问题。

在这里,我将提出一些反对单元测试的基本论点,从理论上讲,这些论点本该出现在第一篇文章中,如 Weinberg 的分解定律(Gerald Weinberg, 2001)。我将提供一些模型来帮助我们在一般意义上推导问答,比如关于测试机会的维恩图。我会进一步查看联系簿来找到关于单元测试的其他味道,如 Magne Jørgensen 的在制品,以及那些来自丰田、会挑战我们对测试的常规看法的更广泛的视角。最后,我会关注从第一轮的旁听席上发出的一些典型的回复,以及我的分析。这些为我们的行业中典型的误区提供了一个很好的横截面。

在跳进第二章的社交媒体讨论之前,请先回顾第一章。您可以在 http://www.rbcs-us.com/documents/why-most-unit-testings-waste.pdf 找到前面的章节。

2.2 Weinberg 分解定律

Jerry Weinberg 将自己的名字写进了一系列“定律”中,每一条都对事物的本质提供了怪诞但却深刻的洞见。你们可能都知道温伯格的合成定律,可能是通过另一个名字或口头禅:整体大于部分之和。我们都在软件开发中经历过这样的事情,并且,作为概念,它是知识渊博的人对设计的信念。

温伯格分解定律更为微妙。它说的是,如果你根据某种功能或复杂性标准来度量一个系统,然后对其进行分解,并对最终得到的结果进行度量,那么各部分之和大于整体。嗯?这似乎与合成定律相矛盾。这是一个视角和解释的问题。让我们使用一个特定的、与单元测试有关的例子来说明。

假定您的代码需要将一组名字中的每一项与对应的电话号码相关联。我们通常将该功能划分到一个称为 map 的模块或对象。事实上,在我们的程序中可能有好几个这样的 map 对象存在。首次面对这样的需求时,您为它创建了一个类。该类在程序的单个或多个部分中实例化为对象。这并不是说 map 成为某个场景的私有属性,而是它可以一次又一次地参与到程序里更广泛的执行网络中。(这不是面向对象特有的: Pascal 过程也是如此。)

您可能知道在即将发布的版本中,基于客户的 map 应该有多大,但是在那之后还会有更多版本,并且您希望类能够合理地防止更改。因此,您允许 map 根据需要动态地创建其所需的任意多的内存。目前的名字是 ASCII 字符串和八位的电话号码,但是你使得该 map 更加通用一些(或许因为你的国家不使用 8 位数字)——或许你会创建一个模板而不是类,也可能你坚持让对象遵循一些声明过的接口。

现在你要为 map 类的方法编写单元测试。除非您有完整的可跟踪性,否则您无法确切地知道程序将提供何种类型的数据,也无法知道它将以什么顺序调用这些方法,所以您要测试合理数量的组合——而合理数量的组合非常大。如果在程序中几个不同的位置使用 map,这就更重要了,因为我们希望尽可能多地覆盖到它的可数无限的使用场景。

map 甚至可能有一个名为 count 的方法来报告它包含了多少关联。我们也需要测试它。这似乎是 map 应该做的一件很自然的事情。我们测试了映射的整个接口。也许有一种方法可以替换某个关联,或者删除一个。或者把某一项作为数据对取出来。这取决于程序员有多优秀,不是吗?

但是应用程序实际使用的接口比例是多少呢?应用程序真正需要多少通用性的工程?也许应用使用 map 只是为了跟踪过去一小时终端用户打来的电话,所以 map 的系统场景要求它持有不超过最多 5 项关联,尽管事实是我们为了数以百万计的关联目标而设计(并测试它)。也许 count 从未被调用。即便接口允许我们将一个新号码与现有的名字关联起来,但应用程序可能从来都不需要这个。

独立的 map 在其测试中需要经受的考验,比应用程序在其生命周期中的任何时候都需要的多!

对 map 接口进行一般化有许多强有力的论据,其中最引人注目的社区对于 map 应该做什么(简化了发现过程)的期望,以及遵从该期望所带来的易理解性。这里也有一些很好的重用依据。借用温伯格的另一个悖论,一般的解决方案比具体的解决方案更容易推理。

但是 map 是通用的并不意味着我们在本地测试它:我们仍然在执行的上下文中测试它,在系统中,使用系统级测试。为什么?无论如何,我们不能在单元级别上测试所有东西。要彻底地测试 map,需要花费太长的时间。

让我(逐字地)重现一下我之前关于该问题计算复杂性的一个论点。我将 100% 覆盖率定义为:通过所有类的所有方法的所有执行路径的任何可能的组合,重现这些方法可访问的每个可能的数据位配置,以及执行路径上的每条机器语言指令。其他任何东西都是一种启发式,绝对不能对其正确性作出正式的声明。函数中可能的执行路径的数量是适中的:比如说10条。这些路径与所有全局数据的可能状态配置(包括实例数据,在方法范围内它们可视为全局的)和形式参数的叉乘确实非常大。而该数与一个类中可能的方法序列的叉乘是可数无限的。如果代入一些典型的数字,你会很快得出结论,如果你能得到比 1/10^12 更好的覆盖率,那将是极其幸运的。

理论上,如果我们有一个水晶球来确定方法执行的顺序、参数值等等,那么我们可以减少这个数字,类的对象将在现场处理它们。事实上,我们不能确定哪些测试忠实地再现了明天的系统行为。我们对配置值的等价集合收集了大量的论据,但是我们很少证明它们。在具有多态性的 OO 系统中,我们甚至可能无法证明给定的方法会被调用,更不用说知道调用的上下文了!在 OO 的世界中,对于这种形式化证明,所有的赌注都是不确定的(尽管在 FORTRAN 中还有希望)。

人们可以为测试单元提出意识形态上的争论,然而事实是 map 作为一个单元、以一个单元进行测试,要比作为系统的一个元素大得多。通过在用例级别测试系统,而不是在编程接口级别测试单元,您通常可以减少测试量,而不会降低质量。系统测试将许多方法调用组合到一起,所有这些方法都必须正常工作,调用才能通过。(我知道这有点过于简化,但它作为一种概括是有效的。)系统测试成为信息压缩的一种形式,而这在单元级是无法实现的。

这就是温伯格分解定律的概括。

更糟糕的是:单元测试的行为可能导致 map 的接口以在整个交付的程序中不可见的方式增长。我和 Felix Petriconi 一直在邮件中讨论单元测试的问题,今天他写信给我说:“你是对的。例如,我们在应用程序中引入了很多接口来进行(单元)测试,从我的角度来看,可读性降低了。” David Heinemeier Hannson 将这种现象称为“测试导致的设计破坏”:出于让测试更加方便的目的,代码和质量却退化了( http://david.heinemeierhansson.com/2014/testinduced-design-damage.html )。Rex Black 补充说,这种权衡在系统级和单元级同样存在。

有一些来自精益软件设计的技术(例如,“用于敏捷软件开发的精益架构”)可以帮助实现这一点。一般来说:

使用面向未来的、良好的领域设计技术来设计类和 API。编写接口代码来暴露设计。只实现那些支持已知的、即将交付的用例所必需的数据结构和方法。如果系统用例发生了变化,您可以继续演进各个方法和私有数据,而不影响整体体系结构。

2.2.1 一个必然的结果

温伯格组成定律告诉我们,为了显示一个 bug 已经被缓解,我们需要的远远不止该单元。

由于该原因,大多数回归测试应该是集成测试或系统测试————而不是单元测试!正如我们上面所展示的,Weinberg 分解定律表明,如果您对回归套件使用单元测试的话,那么您是在进行不必要的过度测试。如果对于被测算法的结果有正式的商业“神谕”的话,您可能会在回归测试中包含单元错误(参见第1章)。

2.2.2 一些历史背景

历史说明之一:我曾听到有人为 TDD 和单元测试辩护,他们认为需要充实这样的类接口,以支持长期重用。虽然 TDD 是 XP 的宠儿,但它也警告我们关于YAGNI:“您不需要它。”简而言之,除非你有一个用例,否则不要编写代码。即使您推崇重用并放弃 YAGNI,使用领域建模技术来形成产出也要比使用测试来好得多。测试仍然必须做,而且应该做,但是在系统级别: 那里才有价值。

历史说明之二: CRC卡(“一个教授面向对象思维的实验室”,Kent Beck, OOPSLA ' 89会议记录;参见http://c2.com/doc/oopsla89/paper.html) 曾经是从最终用户场景创建基于类和对象设计的强大方式。在系统场景的角色扮演中,每个团队成员代表一个或多个类或对象的利益,使用配方卡片来表示每个明确命名的对象;名称出现在卡片的最上面一行。卡片其余部分被分成两半:左半部分列出对象或类对系统的职责,右半部分是对象为完成其工作而需要其支持的合作者或助手(其他卡片)。今天,CRC 的首字母缩写表示候选对象、责任和协作者。如果卡片放在桌子上,它就是一个类; 如果它在一个场景讨论中处于活跃状态,负责它的人把它举在空中,那么我们把它当做一个对象。(实际上,卡片代表的是角色,而不是类或对象,但这是另一个讨论话题。)

CRC Card

CRC 技术并不是分析技术(尽管它提供了很好的分析洞见),而是一种设计技术。它关注如何提供业务功能。CRC设计是最小化的,因为向卡片添加职责的惟一方法是支持用例或场景。

然而,最近我很少看到 CRC 卡被使用,可能是因为业界放弃了社交生成的设计,转而使用像JUnit 这样的工具,从而测试可以驱动设计——这就是 TDD。大多数焦点放在类而不是对象上,这些类通常来自于“查找名词”的练习,或者从领域分析/领域驱动设计(DDD)而来。在那里,目标是通用性和广泛的适用性。

Neil Harrison 评论道:“静态设计(如:查找类)和动态设计(如:对象如何在一起工作?)之间有一个有用、但不完美的区别。CRC 设计的重点实际上是动态设计,尽管它在静态设计方面也做得不错。类的布局只是一个帮助——真正的问题在于程序执行的时候。在我看来,单元测试的重点是静态设计。因此,根据定义,它是不充分的,也比较次要。”

如果您使用单元测试作为设计软件的辅助工具,请尝试使用 CRC 卡来替代。CRC 卡片组是一个运行中的程序,并且使用一种有趣的语言,它包含了一直到单元级别的所有细节。如果它能在角色扮演过程中运行,那么它就很可能会在自然环境下也能运行。在角色扮演练习之后,就只是编码的工作了。它让集体团队发挥头脑,而不是仅仅让你和工具交流。

2.3 单元级别的 bug 很少

在第一章中我曾指出,测试任何没有正确行为的独立“神谕”的东西都是浪费。许多应用程序包含这样的算法,并且这样的算法可以独立地进行测试:例如,内存管理(感谢 Felix Petriconi)、网络路由,或图操作。这些算法本身具有业务价值:例如,网络路由算法有助于降低拥塞、降低延迟或提高可靠性,并且测试它与产品价值具有一等的、可跟踪的联系。要想让许多对象实例方法进行同样的表达,需要大量的想象力、手势或间接推理。

如果代码没有按照程序员的意图执行,我们称之为错误(fault)。如果程序员不够优秀的话,代码可能最终没有反映程序员的意图,并导致一个错误。如果程序员用某种方式解释需求,而客户却用了另一种方式,那么程序员也可能在代码中引入错误。这是单元测试无法捕捉到的一类错误。由于不恰当的沟通和错误的假设导致编码之前的需求与设计就已出错,这种情况占软件故障的45% (http://www.isixsigma.com/industries/softw-it/defect tpreventi-reducing-cost -and-enhancing-quality/)。

测试——无论是在单元、系统还是集成级别——在最好的情况下只能捕获软件中出现的一小部分错误。团队应该使用广泛的质量保证技术组合来消除缺陷。

单元测试的存在是为了发现错误,以便程序员能够向下跟踪,并消除错误出现的根源误。bug 是由人所插入的代码逻辑,可能导致错误。一些正式定义将术语 bug 的应用限制为仅适用于交付前发现的问题,并将术语 error 应用于仅在现场触发的问题; 另一些人则把 error 看作是心智上的错误。从质量角度看,唯一重要的是那些可能出现在现场的哪些——-当然,我们应当在交付之前找到它们中的大部分。测试背后的想法是,在产品发布之前每发现一个 bug,都将减少现场发现错误的机率。(另一方面,缺陷(defect)在打印机或屏幕输出缺少像素时可能会出现。参见:http://www.differencebetween.info/difference-betweenfault-and-failure)

故障(failure)是无法满足需求。多数错误都会导致故障,但并非所有的故障都是由错误引起的。故障可能是由于上下文的一个意外的顺序,且故障总是需要考虑上下文。在我的纳税申报程序中,可能会有一个程序错误导致了错误的值,但除非税务机关发现了它并抓着我纠正错误,否则就不会有故障。任何与现实世界相连接的系统都可能经历设计者无法预见的一系列事件。考虑到事件的序列是可以无穷无尽的,因此故障是无法预见的,因此不属于错误。

有些故障在实践中很难测试,也有许多故障根本无法测试。(想想仅在某个定时窗口内发生的同步错误;在一个有多个时钟的系统中,导致错误的机会窗口可能是无穷小的,这意味着你需要无穷多个离散测试来探索失败的可能性空间。大多数软件故障来自于对象之间的交互,而不是孤立的对象或方法的属性。

无论您在单元测试上投资了多少,您仍然需要其他 QA 措施来消除错误。

故障是坏的: 我们想要避免故障。错误只有在造成故障时才是坏的。如果我们从给定方法的测试无法识别到它所支持的需求,那么查找 bug 就没有价值。

一些作者将单元测试的期望与代码行为之间不一致称为反常(anomaly),而不是 bug。如果测试数据是坏的,或者实际上测试中存在 bug(这些 bug 很可能与代码中的错误相同;参见第 1 章),这就有可能发生。从实际业务的角度看,反常只有在导致故障时才有意义。如果没有需求或者与其他单元的交互作为上下文,在单元级别查找失败是一个危险的提议。

假设您的单元测试发现了堆栈库的一个问题: 当您推入超过 215 项时,堆栈上除一项外的所有内容都丢失了。这当然是一个 bug:我们在单元测试中寻找的东西。这当然也是个错误。但是我们发现应用程序在任何时候都不会在任何堆栈上推送超过三个事物。从定义上讲,它不是故障:它与产品价值无关。修复这个 bug 很可能是浪费:也就是说,我永远不会看到修复它的投资回报。甚至测试它也是浪费。

实际上,反常的最大来源之一是 Weinberg 分解定律:在它们根本无关紧要的地方发现错误。因为考虑到应用程序中隐含的上下文化,代码中的大多数路径从未被执行过。我们往类中添加一个方法是因为我们相信某一天我们会需要它,但由于应用程序并未调用它,则在其中找到 bug 是没有什么意义的。使用最终用户的计算机内存来运行这些代码,并向他们收取费用,这在最好的情况下也是草率的,最坏则是欺诈。测试它是一种浪费。

功能测试的投资回报要高于单元测试:在这里投入您的精力。功能测试发现的 bug 通常是单元测试的两倍(请参阅本专栏后面的 Capers Jones 数据),尽管敏捷开发人员将大部分时间花在单元测试的级别,包括 TDD。(http://www.ambysoft.com/essays/agileTesting.html)

总之,敏捷团队似乎将大部分精力投入到质量改进领域,而得到的回报却最少。

回到单元测试。

所有这些额外的负担确实为更多的书呆子工作提供了一个出路!假设我们在单元级别进行设计,而不考虑应用程序如何使用这些代码的细节,那么我们最终会创建大量死代码。一些组织在代码覆盖率的帮助下找到这些代码,然后删除了它们,并骄傲地称之为“重构”。(George Carlin 的脱口秀节目《如何处理冰箱里的剩菜》(how to deal with leftovers in your电冰箱里的剩菜)带给我们一些联想。)考虑到单元测试在过去十年中变得如此流行,重构和代码覆盖率正处于鼎盛时期也就不足为奇了。

如果我们跨越类层次重构方法或数据,单元测试就会失败。请记住:测试也是架构的一部分,并且这是一个协调的变更。这种耦合使系统在面临变更时变得脆弱——如果您将测试包含在系统中。但是重构导致这些测试失败得如此频繁,以至于很多人都像 Andreia Gaita (@sh4na)一样:“我发现我扔掉/重写了很多/经常失败的测试,它们太脆弱了,对大型团队/系统没有用处。(https://twitter.com/sh4na/status/480785585791971328, 2014 年 6 月 22 日)。我猜她保留了有效的测试。在第一章中,我已经描述了信息论是如何缠住 Andreia 这样的人的。

2.4 测试和释放潜在 bug 的道德规范

您的软件有时会在现场出现故障。请克服它。您进行单元测试,这样当代码不可避免地失败时,您至少可以由于已经尽力了而感到安慰。没有人会因为你的努力而责备你,对吧?唯一的问题是,除非你在一个压迫性的工作环境中,有人拿枪指着你的头逼你做单元测试,或者阻止你进行更广泛的测试,否则单元测试并不是你能做出的最好选择。无论如何,单元测试对于您认为无法实现的组织或流程编程永远不会有所帮助。

也许除了那些制作厨房菜谱程序的人,我们大多数人都在复杂的领域工作,我们永远无法达到完美。问题是:这种情况下我们该怎么做?让用户去处理崩溃,或者祈祷我们的错误不会破坏他们的数据,这很容易。然而,测试背后的理念是,它为我们提供了更智能地处理这一挑战的工具。为什么我们不更充分地锻炼他们?这确实是一个奇迹,这就是我们将在这里探讨的。

作为背景,这里有一股强大的心理力量在起作用。测试揭示了故障——或者至少是好的故障。故障是件坏事,不是吗? 把我们的故障藏在我们的实验室里是 OK 的,这里我们控制着自己和代码的命运。我们中的一些人甚至会由于减少了故障数量而得到了提升。我们不将冗余的检查(测试逻辑的运行时转换)放入代码中。我们创建单独的模块来引发错误,小心地将它们隔离在代码之外,并称之为测试。这样做是出于下面两个原因。

第一个原因是我们通常考虑得不够全面:我们做的是单点测试,而不是范围测试。单点测试向系统发送一个或多个固定的值集合,并在响应中寻找固定的预期响应的集合。范围测试更加动态,并且使用冗余计算、数据或上下文(而不是固定值)来验证结果。我见过的大多数单元测试通常都是单点测试。请检查一下你自己的。

第二个原因是——恐惧!如果我们让代码检查自身,那么代码可能会在现场失败。更好的方法是让它运行,尽管会出现一些小故障,但是代码的总体设计将会获胜,系统将正常工作。是的,不管怎样,它还是很好用的。或者我们向用户弹出一个错误窗口,然后继续,就像什么都没有发生一样;这是用户的问题。和你的学生或程序员聊聊,看看他们是不是这么想的!

我们通常认为测试是一些刺激或锻炼的组合,以及一个比较实际结果和预期结果以检测错误的“神谕”检查器。虽然我们通常将软件分为测试驱动程序、被测试的软件和"神谕"检查器,但是将这两个测试组件结合起来可以帮助我们将质量的概念延申到发布后的软件中。这并不是什么理想化的梦呓,而是今天通过意志与设计就可以实现的。

测试工作于在这样的假设之上:适当地执行代码,加上检测与预期结果的偏差的工具,可以创建足够强大的反馈,来消除重要的缺陷,否则它们会泄露到现场。我们在部署之前同时删除了缺陷和测试。在这个假设中隐含的是,我们对代码的执行不仅代表了现场将要发生的事情,而且它以复制或者扩展(例如,通过范围测试)的方式,已经重现了部署时可能发生的状态与执行路径。。如果这是真的,那么根据推断,发布的代码必须是正确的。

这充其量是一个不可靠的假设,原因有二。首先,花在测试上的时间通常只占系统实际运行时间的很小一部分。即便为一个微不足道的系统复制所有的场景也需要数年的时间(或者,在某些情况下,需要数百年甚至是永恒的时间),而我们只是没法花那么多时间。这些场景会在客户时钟运行时出现,而不是在供应商时钟运行时。仅仅是一个粗略的分析就表明,在实地系统的场景组合将比我们在实验室中重现的场景大得多。其次,我们无法编写代码来捕获所有可能的缺陷条件。

让我们考虑一下我们为了测试而在实验室中、在现场运行,以及损坏(显示故障)的场景。(这里的“场景”指的是重现所有必需的输入与功能调用的协作。)让我们看看每一种组合:

a 这些测试没有检测到任何错误,甚至从未使用过,所以没有添加任何价值
b 在现场使用时,这些场景正常工作
c 希望我们能在实验室中发现这些错误并加以修正
d 我们浪费时间来修复这些:它们只是理论上的 bug,因为它们从未被使用过
e 我们从未测试它们(也许是因为它们很琐碎),但是可以工作
f 这些是我们发布的 bug
g 坏的代码,它们从来没有实际运行过

好的分析可以减少工作量,避免测试那些现实生活中永远不会出现的序列((a)、(d)和(g))。(试图增加实验室测试场景和现场运行的场景之间的重叠,这被称为测试保真度——谢谢,Rex!)我们对客户的了解越多,我们就会越少地交付那些工作不正常或从未使用过的东西。更重要的是,良好的分析还可以减少现场故障((c)和(f)),而这正是返工的成本所在。

通过测试实现的质量保证不能保证任何东西:质量保证通过测试确保没有意识到:它只度量质量(脱帽感谢 Rune Funch Søltoft)。不要依赖测试来保证质量,要特别注意好的分析。优秀的测试人员具有洞察力,能够挑战因果关系的合理性分析并让结果更进一步(基于优秀测试人员身上常见的“超前思考”的天赋)。

最后一种情况(未预料到的现场故障(f))的问题是,在测试中可能无法检测到故障。代码可能只是默默地生成错误的结果,然后继续。测试交付价值的唯一场景是那些我们已预料到、测试到,实际上在现场运行,但在实验室中显示出失败(c)的那些。这就是说,测试只对六种组合中的一种产生价值。我们之所以得到价值,因为我们把正确的场景生成器和正确的“神谕”校验放在一起。

测试可以用两种方式编写: 要么是覆盖一般性的真理“神谕”,要么是是检查单个预期的结果。我们可以用后一种方式来测试累加器,通过评估 1+1 是否确实生成了 2。但是,我们可以对任何 i + j = k 形式应用更一般的测试。这些测试在某种意义上比特定值的测试要弱,但它们仍然有意义。我们可以检验 k 是否比 i 和 j 都要大。我们可以检验结果是否为偶数,除非 i 或 j 中恰好只有一个是奇数;这些可以用底层的位逻辑来计算,位逻辑很可能独立于语言、库或者计算环境中的算术装置。我们可以看到它们的值是否落在常识验证过的操作范围内。

这些类型的检查不需要逐个实例的值“神谕”。因此,它们可以在现场运行:我们可以通过生产代码交付它们。好消息是,随着时间的推移,这些检查可以减少(f)的大小!根据我的经验,它们也减少了对集合(a)、(b)、(c)和(d)场景的测试工作的需求——尽管它需要仔细的测试设计和计划来利用这一优势。在测试世界中有很多已知的技术可以做到这一点。(减少错误并不是因为将错误推到现场,而是通过使用替代机制在内部找到它们。)

当然,正如我们在第 1 章中已经介绍的,这种论证只是代码中使用断言的另一种证据。断言是强大的单元级保护,它在两方面战胜了多数单元测试。首先,每个断言都可以完成大量(可以想象是无限的)基于场景的单元测试的工作,这些单元测试将计算结果与“神谕”进行比较。其次,通过将测试扩展到产品的生命周期中,它们将测试运行时扩展到更广泛的上下文和更详细的场景变化上。

因此,请再一次感受:

将单元测试转换为断言。使用它们在高可用性系统上提供容错的架构。这解决了维护大量额外的软件模块的问题,这些模块评估执行并检查正确的行为;这是单元测试的一半作用。另一半是执行代码的驱动程序:请依靠您的压力测试、集成测试和系统测试来实现这一点。

电信系统长期以来一直采用这种方法,它们甚至更进一步。当其中一个测试失败时,他们使用独立编写的软件将系统恢复到已知的安全状态,同时尽可能保留正在进行的工作。这是容错计算的基本构件之一。最后,这些“测试”会改变你的质量观念。如果我的浏览器无法连接到某个站点,它不仅显示错误消息。该“测试”会深入探究原因。如果由于互联网处于断开连接的特定阶段而无法访问该页,它将对请求排队,并在网络重新联机时自动代表我重试它。卓越的质量保证将枯燥的测试变成了取悦客户的手段。

我非常喜欢TestFlight (http://www.testflightapp.com) 这样的框架,它将您的开发力量扩展到了现场。如果没有它,您就把孤立的 bug 留给了客户。相反,你应该对你所孕育的每一个 bug 负责。

对我来说,如果知道自己在没有事先通知客户的情况下向他们交付 bug ,那就是不道德的。当我交付软件时,我对缺陷容忍度是零。将 QA 代码扩展到该领域提高了 QA 真正含义的标准。

2.5 单元测试:我们所知道的最糟糕的方法

当第一章在 RBCS 网站上发布时,推文像夏天的蒺藜一样在它周围盛开。每个人都想参与讨论,尤其是那些如果我是对的,可能会损失某些东西的人。我开始从推文、博客文章、咆哮和其他围绕单元测试的提议中看到一些趋势。这些在 Keith Pitty 发表的一篇题为《测试的唯一方法》(the Only Way to Test!) 的小型演说达到了高潮。他回顾了一些单元测试和TDD权威人士的观点,包括 David Heinemeier Hansson、我、Bob 大叔、Gary Bernhardt、Correy Haines,以及通常的TDD 团体。他总结了他们的论点。

令我印象深刻的是, 对于单元测试和 TDD,大多数争论的形式是这样的:“无所畏惧,你会更快,” 或已知的说教如“目标是在 300 毫秒内得到测试反馈。” 在该演讲中,看起来只有 Correy Haines 对于关注哪些方面能得到回报给出了合理的建议。我也认为,在第1章中,我努力给出了醒目的模型,这些模型本质上是引导开发人员进行合理实践的思维实验。我的观点是,在我的警告背后是有科学依据的,或者至少是有原因的。其他许多“论点”要么是情绪化的,要么是信条。

但我们所有人都感到羞耻:在我们的辩论中,没有人给出确凿的数据。我更愿意给出模型,并封装在一个故事中,因为任何独立数据总是与上下文相关的。但优秀的研究人员能够发现可量化的趋势;在我的职业生涯中,我看到过很多这样的例子,它们给了我提出自己观点的信心。但是,说实话,我已经对一些软件工程的重量级人物如何在单元测试中发挥作用做了一些简单的研究。我从 Capers Jones 的研究发现了一些有趣的数据。单元测试是我们所知道的最低效的 bug 清除步骤(“编程生产力”,1986,表3-25,第179页)。缺陷消除技术的一个很好的度量方法是效率:它能够检测和消除问题的百分比。该表显示了一系列发展的结果:

移除步骤 效率
最低 中等 最高
正式的设计 35% 55% 75%
建模或原型 35% 65% 80%
现场测试 35% 50% 65%
非正式的设计评审 30% 40% 60%
正式的代码检查 30% 60% 70%
集成测试 25% 45% 60%
功能测试 20% 35% 55%
桌面代码检查 20% 40% 60%
桌面设计检查 15% 35% 70%
单元测试 10% 25% 50%
总计 93% 99% 99%

我知道这有点过时了。在同一作者 2012 年发表的一篇论文中(“软件缺陷的起源和消除方法”,2012年12月28日,第5稿,http://wwwifpug.org/Documents jonessoftwaredefect toriginsandremovalmethodsdraft5.pdf),我们发现这些数据是最好的产品。单元测试充其量也只是一种平庸的方法,比系统测试低得多:

移除步骤 效率
正式的检查 93%
静态分析 55%
系统测试 36%
功能测试 35%
组件测试 32%
单元测试 32%
桌面检查 27%
原型 20%
验收测试 17%
回归测试 14%
总计 99.96%

2.6 更深入地思考一个层面

Keith Dahlby (@dahlbyk)回应了我们应该扔掉那些一年内没有失败的测试的想法:“除非被测试的代码不会改变——这是愚蠢的预测,所以我永远不会删除测试。(https://twitter.com/dahlbyk/status/479092758012248064, 2014年6月18日)有趣的是:在我看来,如果你改变了代码,你应该改变测试,如果代码希望保持不变,测试应该保持不变。

“保留测试只为了更改代码”的心理学是这样的:我需要一个安全网来捕捉对程序行为的无意更改;比如说,我在重构过程中所做的。这假装我们可以以一种只改变其部分行为的方式来修改函数: 也就是说,我们可以改变一个函数,而不改变它的大部分外部可见行为。更重要的是,它假装我们可以有很高(但不是完美的)信心,我们可以编写测试,只检查在代码更改期间保持不变的函数行为的那部分。这意味着好的单元测试设计必须预见代码可能会如何更改。这反过来暗示:不仅能够在演化过程中将方法的价值分割为不相交的割集,而且能够展示代码——或者至少是代码的测试——是如何映射到这些割集的。我认为程序员相信他们可以做到这一点的想法是在欺骗自己。不幸的是,没有更多手段可以防止这些假设出错,而不是阻止变更本身的正确性存在缺陷。因此,这是一个有严重缺陷和危险的观点。

修改代码意味着重新编写测试。如果你改变了它的逻辑,你需要重新评估它的功能。如果将其移动到另一个类,则需要为模拟测试重新创建语境化。如果对代码的更改不需要对测试进行更改,那说明你的测试太弱,或者不完整。如果您在代码中重构它,却没有带来新的价值,那就是另一种形式的浪费。你应该在无论如何都必须变更代码的时候再去重构,而不是出于其本身的原因。重构就像让露营地在你离开的时候比你发现它时更干净——而不是对露营地进行一次清理之旅。

Andreia Gaita (@sh4na) 在推特上写道:她说的“不是针对修改代码的测试,而是针对大型应用程序中相关系统的其他测试。”没关系。Kaiser 和 Perry 在 1990 年的论文中展示了,对派生类的更改预示着基类方法行为的更改,所以关于测试不变性的所有赌注都是错误的。他们的分析对系统中任意两段代码之间的交互都是适用的。封装在管理上是一种有用的虚构,但从正规的计算模型角度来看,它是一种错觉。

正如我前面提到的,她还写道:“我发现我扔掉/重写了很多/经常失败的测试,它们太脆弱了,对大型团队/系统没有用处。”如果它们是单元测试(这是讨论的语境),那么它们位于系统需求的语境之下,而可能在 API 的局部视图语境之上。最容易测试的函数是返回常量值的函数。通过丢弃经常失败的函数,你可以以一种可能产生同样多数据、但信息更少(至少是关于程序中的错误)的方式来减少测试库存。

小块代码与聚合行为的动态特性不同,这是需求级测试与单元测试在质量上有差别的原因之一。好的Smalltalk 方法大约有三行代码:好的对象方法非常短,它们的行为类似于对象上的原子操作。Clean Code (Bob Martin, 2008)说,一个方法应该很少增长到20行以上。他将 Kent Beck的 Smalltalk 代码描述为包含 2、3 或 4 行长的方法。Trygve Reenskaug(MVC 的发明者 )最近计算了他的代码中一个典型大型应用的平均方法长度:同样,每个方法大约有三条语句。

考虑将这样一个方法用来进行测试。为它写十个单元测试。对方法的某一行进行更改,并尝试证明其中五个测试仍然有效: 这就是说,单行更改不会更改函数对测试的响应方式。另外两行被很好地封装在已更改的行中的可能性非常小,因此对代码的更改几乎总是意味着对测试的更改。这将导出单元测试规则:

仅为不变的代码保留测试。如果代码发生了变化,替换那些测试——旧的测试仍然通过的可能性非常小。但是不要仅仅因为频繁的更改会导致测试失败,就放弃更改代码的测试。

然而,考虑到第 1 章中关于粒度的所有争论,这并不能解决问题。最好在系统测试级别进行推理,并在该级别从需求重新派生出测试集。测试并不检测代码:代码几乎总是完全按照要求执行。测试真正要检测的是驱动代码的设计,以及从分析到设计再到代码的转换:它是否符合需求。最后,测试试图测试流程(这与精益人员对流程和流程改进的迷恋背后的基本原理紧密相关)。这些问题通常无法在单元级别表达,而是在系统级别。

在更新测试时,忽略代码变更的级别:让需求变更驱动测试中的变更。需求当然也应该驱动对测试的设计。在社会化的需求、以及从它们到清晰的设计的层次上上进行测试。

2.7 自动化不会带来知识

我在第一章中提到,丰田生产系统中的精益和 Scrum 基础对自动化持有一定的怀疑态度。这种怀疑的基础是,改进来自于人类的思想和社会互动,一个人在工作中可以不断适应和提出额外的改进。通过自动化引入改进只会带来一次性的好处,而不像有思想的员工那样会一直付出。即使在引入自动化时,也应该以不预先引入手工过程的方式进行(例如,在机器可能停止工作的情况下)。这意味着机器不应该比人类更大或更强: 如果它们比人类更大或更强,那么一个人就无法介入这项工作。

丰田最近重新树立了对这些理念的信心,甚至用个人来取代装配线上的机器人。查看 2014 年 4 月 qz.com上的文章:

http://qz.com/196200/toyota-is-becoming-more-efficient-byreplacing-robots-with-humans/

2.8 廉价、早期测试的神话

过去我曾很荣幸地与 Magne Jørgensen Simula 在在奥斯陆的 Simula 中心进行互动。他最近揭穿了许多人多年来珍爱的一些软件工程神话。其中一个神话是单元测试的常见借口: 测试越早,成本越低。我们相信,测试越接近来源,在成本效益上就越有优势。Jørgensen 挑战了这一说法:“软件工程中的神话和过度简化”,研究论文,simula.no, http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.259.5023)

报告指出(第5-4页)“……无论什么时候引入错误,在开发过程的下游修复错误的代价总是更高。一种假设是,不增加错误检测的成本,而是在它们被引入的地方发现更多错误时减少修正的成本,会毫不奇怪地从更好的测试基础设施中节约大量成本。但是如前所述,该假设有证据支持,很可能是不正确的。

2.9 结论

我希望您从本章学到的最重要的一点是,大多数 bug 不是发生在对象内部,而是发生在对象之间。Adele Goldberg 曾经说过:“这种事总是发生在其他地方。”面向对象编程的关键设计决策发生在墙外。单元测试让我们回到了模块的前对象时代,它可能更适合称为面向类编程,而不是面向对象编程。单元测试检查完全相同的“墙内”的小设计决策,这些设计决策不需要浪费额外的代码,也能通过下列手段轻易捕获: 代码审查、净室、代码评审和结对编程(我想象到的)。

真正的语义在于对象之间发生了什么。(参见“DCI 范式:将面向对象引入架构世界”,Babar, Brown, 和 Mishkin, eds., Agile Software Architecture, Morgan Kaufman: 2013 年 12 月)。单元测试只能让您猜测。正如 Capers Jones 的数字所指出的,回报是微不足道的。

开发人员从这微薄的好处中大做文章,也许是因为他们觉得这是在他们控制之下的事情。更广泛的测试需要跨其他软件模块的合作和团队精神。内向的书呆子更愿意和 JUnit 坐在一起,而不是围坐在桌子旁玩 CRC 卡。他们无知的老板把后者只看作一次会议,而把前者看作是真正的工作,这无疑是火上加油。

请深入思考。

2.10 致谢

感谢 Felix Petriconi, Neil Harrison, Brian Okken 和 Rex Black。

翻译系列: