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

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

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

1.1 走进现代

单元测试是 FORTRAN 时代的主要内容,那时候函数就是一个函数,有时候值得进行功能测试。 计算机执行计算,而函数和过程代表了计算的单位。 在那些日子里,主导的设计过程是从较小的构造块组合成复杂的外部功能,而这些小块又由更小的块编排而成,依此类推,向下直到非常容易理解的原语。每一层都支持其上方的层。 实际上,你很有可能将底层事物的功能(称为函数和过程)追溯到在人机界面上产生它们的要求。 人们通常期望优秀的设计者能够理解特定功能的商业目的。这是可能的,至少从结构良好的代码中推断出调用树是可能的。你可以在代码审查过程中用头脑模拟代码的执行。

面向对象慢慢地风靡世界,并颠覆了设计领域。首先,设计单元从计算的产物变成称为对象的小型异构复合体,这些对象将一些编程构造(包括函数和数据)组合到一个包装器中。 对象范式使用类将一些函数,以及对于这些函数的整体数据规范包装到一起。该类成为一个饼干机,对象在运行时从中创建出来。 在给定的计算上下文中,要调用的确切函数是在运行时确定的,无法像在 FORTRAN 中那样从源代码中推断出。这使得仅通过检查来推断代码的运行时行为变得不可能。你必须运行该程序,才能对于哪些事情正在发生得到一个模糊的印象。

所以,测试再次出现了。这是一次单元测试的回归。对象社区发现了早期反馈的价值,这是由机器速度的提高和个人计算机数量的增加所推动的。设计变得更加聚焦于数据,因为对象的形式更多地是由其数据结构——而不是其方法的性质——所决定的。由于没有任何显式的调用结构,因此很难将单个函数的执行放在其执行的上下文中。这样做的可能性很小,它们被多态性带走了。因此集成测试出局,而单元测试留下来了。系统测试仍然在后台的某些地方运行,但似乎变成了他人的问题。或者更危险的是,由那些编写代码的人当作成熟版本的单元测试来运行。

类成为分析的单元,某种程度上也是设计的单元。 CRC卡片(通常表示类,责任和协作者)是一种流行的设计技术,其中每个类由一个人来表示。面向对象成了拟人化设计的同义词。此外,类还成为管理、设计焦点和编程的单元,他们的拟人化特性使每个类的主人都渴望测试它。并且,由于很少有类方法带有和 FORTRAN 函数类似的语境化,程序员不得不在执行方法之前提供上下文(请记住,我们不测试类,甚至也不测试对象 - 功能测试的单位是单个方法)。单元测试为按照特定节奏调用方法提供了驱动力,而模拟(Mock)则为环境状态以及被测方法所依赖的其他方法提供了上下文。测试环境提供了一些工具,使得每个对象在准备测试时处于正确的状态。

1.2 治愈比生病更糟糕

当然,单元测试不仅仅是面向对象编程的一个问题,但是面向对象、敏捷软件开发以及工具和计算能力的提升,这些因素的结合使它变得寻常。作为一名顾问,我经常会收到关于单元测试的疑问,包括我最近的一位真实客户,Sogeti 的 Richard Jacobs(Sogeti 荷兰 B.V.):

我的第二个问题是关于单元测试的。 如果我没记错的话,你说过单元测试是浪费。首先,我对此感到惊讶。 然而今天,我的团队告诉我,测试比实际代码更复杂。(该团队并不是编写代码和单元测试的原始团队。因此某些单元测试会让他们感到意外。目前的团队更加资深也更有纪律性。)在我看来,这就是浪费......当我以前每天都在编程时,我确实为了可测试性而编码,但我几乎没有编写任何单元测试。然而,我以代码质量和几乎无 bug 的软件而闻名。我想知道为什么这对我有用?

你会从典型的学校教育中学到,你可以将任何程序建模为图灵机磁带,程序可以做的事情某种程度上与与该程序开始执行的时候该磁带上的位数有关。如果要彻底测试该程序,你需要包含至少相同数量的信息的测试,即至少具有相同位数的另一个图灵磁带。

在实践中,编程语言的多变使得在测试中难以实现这种表达的紧凑性,因此要进行完整的测试,单元测试中的代码行数需要比被测单元的代码行数有数量级的提高。很少有开发人员承认他们只进行随机或部分测试,很多开发人员会告诉您他们确实基于假设的愿景进行了完整的测试。这些愿景包括类似下面的概念:“每行代码都已触达”,从计算理论的角度看,对于了解代码是否实现了它们该做的工作方面,这纯属无稽之谈。我们将在下面更详细地讨论这个问题。 但是大多数程序员就是这样看待单元测试的,这意味着它从一开始就注定要失败。

对于您的单元测试可以实现的目标,请保持谦逊的态度,除非您对被测单元有一个无法拒绝的外部需求“神谕”。单元测试不太可能在一个合理的测试周期内测试任何给定方法的功能的万亿分之一以上。请适应这一点。

(万亿在这里并不是夸大,而是基于平均对象大小为4个单词的各种不同的可能状态、并且使用16位长的保守估计)。

1.3 为了自身的测试,以及设计过的测试

我有一个北欧的客户,那里的开发人员需要为级别 1 的软件成熟度提供 40% 的代码覆盖率,为级别 2 提供 60% 的代码覆盖率,为级别 3 提供 80% 代码覆盖率,而有些人希望获得 100% 的代码覆盖率。没问题!你会觉得包含分支与循环的一个相当复杂的过程会带来挑战,但这不过一个分解的问题。虽然对大型函数覆盖 80% 是不可能的,但分解成许多小的函数之后,80% 覆盖率就微不足道了。这提高了公司在一年内对其团队成熟度的整体度量,因为你肯定会得到你所奖励的东西。当然,这也意味着函数不再封装算法。由于这些代码行不再与您关注的代码行相邻,因此不再可能根据执行前后的行来推断代码行的执行上下文。这个序列转换现在发生在多态函数调用中———一个超越星系的 GOTO。但如果你只关心分支覆盖率,那就没关系了。

如果您发现测试人员正在分解功能来支持测试过程,那么您将破坏系统的架构和代码的可理解性。请在更大的粒度级别进行测试。

这仅仅是代码量。你可以减少应用程序代码的数量,但这是通过在一个狭小的空间封装许多行代码来“欺骗”信息理论的。这意味着测试必须至少与代码一样复杂。您不仅有大量的测试,而且要运行很长时间。在一个简单的函数中测试任何合理的循环索引组合,可能需要数个世纪。

考虑一下这个问题的计算复杂度。我将 100% 覆盖率定义为已检查了所有类的方法、所有可能路径的组合,在执行路径的每个机器语言指令中再现了这些方法可访问的任何可配置的数据位。任何其他方法都是一种启发式方法,绝不能正式申明其正确性。通过函数可能执行路径的数量是适中的:我们假定是 10 条。这些路径与所有全局数据的可能状态配置(包括从在方法范围内是全局的实例数据)和形式参数的叉乘数量确实非常大。而该值与类中方法可能序列的叉乘是可数无限的。如果代入一些典型的数字,你会很快得出结论,只要你能得到比 1/10^12 更好的覆盖率,你就是幸运儿。

对此问题的一种强力攻击是持续运行测试。人们将自动化测试与单元测试混为一谈,以至于当我批评单元测试时,人们会指责我在批评自动化。

如果你编写了一个测试来覆盖尽量多的可能性,那么您可以投入一排机器,全天24小时、每周7天运行测试,来跟踪最近的签入。

但是请记住,自动化的垃圾仍然是垃圾。 如果你有一个相关的精益项目,你可能会注意到丰田生产系统的基础、也就是Scrum的基础,是反对智能任务自动化的(http://www.computer.org/portal/web/buildyourcareer/Agile-Careers/-/blogs/autonomation)。在探索性测试中更明显的一点是,让人参与其中会强化其能力。如果你想要自动化,请自动化值得投入的东西。你应该把日常琐事自动化。通过自动化集成测试、bug 回归测试和系统测试,你可能会获得比自动化单元测试更好的投资回报。

更聪明的办法是通过正式的测试设计来减少测试代码量:这就是说,进行正式的边界条件检查,更多的白盒测试,等等。这要求被测单元的设计具备可测试性。硬件工程师就是这样做的:设计人员提供“测试点”,可以读取芯片 J-Tag 引脚上的值,以访问芯片的内部信号值————相当于在计算单元的计算过程之间访问它们的值。我主张将测试重点放在系统级别(这是测试应该关注的焦点);我从未见过有人在单元级别实现这一目标。没有这样的钩子,你将只能使用黑盒测试。

如果行为可以形式化,我可能会相信形式化的单元测试设计——也就是说,如果存在一些绝对的、形式化的正确性“神谕”,可以从中派生出测试结果。更多信息请参考下文。否则,这就只是程序员的猜测。

测试应该非常经过非常谨慎的设计。业务人员——而不是程序员——应该设计大多数功能测试。单元测试应限于那些可以根据某些“第三方”成功标准而进行的测试。

1.4 认为测试比代码更聪明的信念传达了潜在的恐惧或糟糕的过程

程序员有一种默认的信念,即通过编写代码的同时编写测试,他们可以更清晰地思考(或猜测得更好),或者认为测试中包含了比代码更多的信息。这只是正确的废话。心理学观点在这里具有指导意义,并且它很重要,因为驱动大多数开发人员的是心理因素,而不是任何计算属性。

如果你的程序员拥有比代码更多的单元测试行数,那么这可能意味着以下几种情况。他们可能偏执于正确性;而偏执赶跑了清晰的思考和潜在的高质量创新。他们可能缺乏分析性的思维工具,或者习惯了按照纪律来思考,他们希望机器替他们思考。 机器擅长重复性的机械任务,但测试设计仍然需要仔细考虑。也可能是因为流程设计或糟糕的工具方面的问题,您的流程可能无法频繁地集成。程序员正在尽力在他们对自己命运有一定控制权的环境中创建测试,通过这种方式来进行补偿。

如果您有大量单元测试,请评估开发过程中的反馈循环。更频繁地集成代码; 减少构建和集成时间; 削减单元测试,更多地进行集成测试。

或者问题可能在另一端:开发人员没有足够完善的设计技能,或者流程并不鼓励对架构进行思考和深思熟虑的设计。也许需求是如此糟糕,以至于开发人员不知道该测试什么(如果他们必须测试的话),所以他们尝试进行最好的猜测。软件工程的研究表明,消除错误的最具成本效益的地方是在从分析到设计的过渡期间,通过设计自身以及编码的纪律。避免放入错误比拿掉它们要容易得多。

如果您有全面的单元测试,但在系统测试中仍然有很高的失败率,或在现场具有较差的质量,请不要自动归咎于测试(无论单元测试或系统测试)。请仔细研究您的需求和设计方案,以及它们与集成测试和系统测试的关系。

But let’s be clear that there will always be bugs. Testing will not go away.

但是,我们需要清楚,总会有错误。 测试不会消失。

1.5 低风险测试具有较低(甚至可能为负)的回报

我告诉我的客户:我可以猜到,他们的许多测试可能是同义反复的。也许一个函数所作的全部事情就是将 X 设置为 5,我敢打赌该函数会有一个测试是看它运行后 X 的值是否为 5。再次强调,好的测试应当基于审慎的思考和风险管理的基本原则。风险管理以于统计和信息理论为基础; 如果测试人员(或至少是测试经理)在这方面没有至少基本的技能,那么你很可能会做很多无用的测试。让我们分析一个微小的例子。测试的目的是创建有关程序的信息。(测试并不能提高质量; 编程和设计才可以。测试只是提供了团队在进行正确的设计和实现时所缺乏的洞察力。)大多数程序员希望“听到”他们的程序组件可工作的“信息”。因此,当他们三年前为这个项目编写第一个函数时,他们为它编写了一个单元测试。测试从未失败过。问题是:该测试中有多少信息?也就是说,如果“1”表示测试通过而“0”是测试失败,那么这串测试结果中包含多少信息:

11111111111111111111111111111111

根据您的形式化定义,有几种可能的答案,但大多数答案是错误的。幼稚的答案是 32,这是数据的一部分,但不是信息。你可能是一个信息理论家,你会说一个齐次二进制字符串中信息的比特数是字符串长度的二进制对数,在这个例子中是 5。但这不是我想知道的:最终,我希望了解从一次测试中得到多少信息。信息基于概率。如果测试通过的概率是 100%,根据信息论的定义,就没有信息。上述字符串中的所有 1 几乎不包含任何信息。(如果字符串无限长,那么在每次测试运行中信息的比特数将为 0。)

现在,这个测试串中的信息位数是多少?

1011011000110101101000110101101

答案是......多了很多。可能是 32。这意味着每次测试都包含更多的信息。如果我们开始时无法预测测试是通过还是失败,那么每次测试运行都包含了完整的位信息,你无法得到比这更好的结果了。你知道,开发人员喜欢保持测试通过,因为它有利于他们的自我和舒适度。但信息则来自失败的测试。(当然,我们可以采取另一个极端:

00000000000000000000000000000000

这里实际上也没有信息,至少没有关于质量改进过程的信息。)

如果你想要减少测试的数量,你应该做的第一件事是看看一年中从未失败过的测试,并考虑扔掉它们。他们没有为您提供任何信息————往好里说也仅是提供了很少的信息。它们产生的信息的价值可能不值得维护和运行测试。这是第一组要丢弃的测试——无论是单元测试,集成测试还是系统测试。

我的另一个客户也有太多的单元测试。我向他们指出,这会降低他们的速度,因为对功能的每次改变都需要对测试进行对应的改变。他们告诉我,他们以特定的方式编写他们的测试,以便让他们在功能改变时不必更改测试。这当然意味着那些测试并不是在测试功能,所以不管他们在测试什么,实际上并没有什么价值。

不要低估你的员工的智慧,但不要低估许多人在复杂领域共同工作的集体愚蠢。你可能认为你永远不会做上面的团队所做的事情,但我总是发现越来越多这样的事情,简直令人难以置信。你壁橱里可能有一些这些骷髅(不可告人的秘密)。把它们赶出去,好好嘲笑自己,修正它们,然后继续前进。

如果你有这样的测试,那就是第二组要丢弃的测试。

同义反复是第三组要丢弃的测试。我看到的这种情况比你想象的更多——特别是在遵循他们称之为测试驱动开发的人群中。(顺便说一下,在方法入口测试 this 非空不是重复测试————它可以提供丰富的信息。然而,与大多数单元测试一样,将其作为断言比在测试框架种添加这类检查要更好。更多信息请参考下文。)

在大多数业务中,唯一具有业务价值的测试是源自业务需求的测试。大多数单元测试都源于程序员关于函数应该如何工作的幻想:他们的希望、固定的印象,或者有时候对事情该如何发展的预期。那些并没有可证明的价值。20 世纪 70/80 年代的方法基于可追溯性,试图将系统要求一直分解到单元层次。一般来说,这是一个NP难题(除非你正在进行纯粹的程序分解)所以对那些声称他们可以做到这一点的人,我持怀疑的态度。因此,对于每个测试都应当问一个问题:如果此测试失败,那么哪些业务需求会受到影响?大多数时候,答案是“我不知道”。如果您不知道测试的价值,那么理论上测试可能没有商业价值。测试确实有成本:维护、计算时间、管理等。这意味着测试可能有净负值。这是要删除的第四类测试。这些测试虽然可能会进行一些验证,但不进行确认。[注1]

如果你不能判断单元测试失败是如何导致产品风险的,你应该评估是否要抛弃测试。在缺乏正式的正确性标准(如探索性测试和蒙特卡罗技术)的情况下,有其他更好的技术来攻击质量缺陷。(这些都很棒,但我将它们视为与我在这里论述的不同种类的技术。)不要使用单元测试进行此类验证。

请注意,某些单元和测试可以对于业务价值问题有着清晰的答案。其中一组测试是回归测试; 但是,它们很少在单元级别,而是在系统级别编写的。我们知道如果回归测试通过构建失败,就意味着 bug 重新出现了。此外,一些系统具有关键算法——如网络路由算法——可针对单个 API 进行测试。正如我上面所说,有一个正式的神谕可以从这些 API 推导出这些测试。所以这些单元测试是有价值的。

请考虑一下,您的大部分单元测试应该是那些由“第三方”神谕来定义成功的、测试关键算法的单元测试,而不是由编写代码的同一团队所创建的。这里的“成功”应该反映一个业务任务,而不是团队中某些被称为“测试人员”的意见,因为他们的意见是独立的,所以才受到重视。当然,独立的评估视角也很重要。

1.6 复杂的事情很复杂 [注2]

这里存在一个两难问题。在某些软件中,大多数有趣的质量数据都分布在测试结果的尾部,而传统的统计方法会告诉你错误的东西。因此,测试可能会在 99.99% 的时间下通过,但上万次测试中的一次测试失败就会让你丧命。再一次借鉴硬件世界的经验:您可以针对给定的故障率进行设计,或者您可以进行最坏情况分析(WCA)将故障率降低到任意低水平。硬件人员通常在异步系统设计期间使用 WCA 来防止在每 1 亿次中偏离设计参数的信号到达中的“毛刺”。 在硬件方面,这样的模块据称会将 FIT 率保持到一亿次中间出现 10 次。

我在本文开头提到的客户对测试在他的团队中不起作用感到困惑,因为它们在他早期的工作中是有效的。我发给他这篇论文的早期版本,他回答说,

阅读它是一件很愉快的事情,因为它清楚地说明了为什么有些东西对我(以及团队的其他成员)有效。您可能知道,我是一名航空电子工程师,其职业生涯始于嵌入式软件开发人员,涉足硬件开发。这就是我如何开始用硬件思维测试我的软件。(这是一个四人小组: 3 名来自代尔夫特大学的电气工程师(包括我,专攻航空电子)和 1 名软件工程师(海牙大学)。当我们为银行、监狱、消防局、警察局、紧急服务机构、化工厂等部门的安全系统工作时,我们非常自律。它必须在任何时候都要一次通过。)

在合理的假设下,您可以在硬件中执行WCA,这主要是因为因果关系很容易跟踪: 我们可以查看布线,看看是什么原因导致了存储元素的状态变更。冯•诺依曼计算机中的状态由于函数执行的副作用而变化,通常不可能跟踪给定状态变化的原因,甚至无法跟踪到给定状态。面向对象使情况更糟。对于程序中某个状态值的特定用法,无法确定最后修改该状态的是哪条指令。

多数程序员认为源代码行覆盖——或者至少分支覆盖——就足够了。不。从计算理论的角度来看,覆盖到最坏情况意味着调查每一个可能的机器语言序列组合,确保每条指令均可达,并证明您已经在程序计数器的每个值上复制了程序中每一个可能的数据位配置。(仅为包含被测试函数或方法的模块或类复制状态空间是不够的:通常,任何地方的更改都可以显示在程序的任何其他地方,并且需要重新测试整个程序。有关正式的证明,请参阅论文: Perry & Kaiser,“充分的测试和面向对象编程”,《面向对象编程学报》2(5),1990 年 1 月,第 13 页。对于一个较小的程序,我们的测试空间已经远远超过了宇宙中分子的数量。(我对代码覆盖率的定义是:您的测试套件产生的{程序计数器,系统状态}所有可能配对的百分比;其他所有东西都是启发性的,你可能很难找到任何合理的解释。)大多数计算机科学本科毕业生会认识到这种练习中大多数变体中的停机问题,并知道这是不可能的。

1.7 少即是多,或者:你不是精神分裂

这里还有一个问题,特别是关于我的客户最初提出的问题。天真的测试人员将试图通过保留所有测试、甚至添加更多测试来从反面梳理数据。这就导致了我的客户发现自己所处的境地:测试比代码具有更高的复杂性(要么是更多代码,要么是选择性的度量)。被测试的类是代码。测试是代码。开发人员编写代码。当开发人员编码时,他们大约在每千行代码中会插入三个影响系统的 bug。如果我们随机地在客户的代码库——包括测试,也包含这一类的 bug——我们会发现测试将代码导向到错误的结果,这种情况比真正的 bug 将导致代码失败的几率更大!

有些人告诉我这并不适用于他们,因为他们在编写测试时比编写原始代码更加小心。首先,这只是胡扯。(真正让我发笑的是,那些人告诉我他们能够忘记他们在编码时所做的假设,并为他们的测试工作带来一个全新的、独立的结果集合。要同时做到这两者除非是精神分裂症患者。) 观察开发人员在运行测试套件时所做的事情:他们只是在做,而不是在思考(顺便说一下,就像大多数敏捷宣言一样)。我在丹麦的第一份工作包括一个主要基于 XP 和单元测试的项目。我忠实地尝试在自己的机器上运行软件构建,在与 Maven 和其他工具反复斗争之后,终于成功地获得了一个干净的构建。当我发现单元测试没有通过时,我崩溃了。我去找我的同事,他们说:“哦,你必须用这个标志来调用 Maven 来关闭那些测试——这些测试由于代码的变化而不再工作,你需要关闭它们。”

如果你有 200 个测试——或 2000 个,或 10000 个——你不会花时间去仔细研究和(咳咳)重构每一个失败的测试。最常见的做法——这是我在 2005 年曾经工作过的一家初创公司中看到的——是用新结果覆盖旧的测试镀金(完成给定测试后的预期输出或计算结果)。从心理上讲,绿条就是奖励。今天的高速机器给人的错觉是能够取代程序员的思维;它们的速度意味着没有时间去思考。在任何情况下,如果某个客户报告了错误,而我假设实际的错误在哪里,并且更改了它,使系统行为现在是正确的,那么很容易让我相信我做出修复的功能现在是正确的。因此,我为这个函数镀了金。但这只是一种拙劣的科学,其根源在于“相关性就是因果关系”的巫术。有必要重新运行所有的回归测试和系统测试。

其次,即使这是真的——即由于更好的过程或者注意力的提高,测试具有比代码更高的质量,那么我建议团队改善他们的过程,把智慧投入编写他们的代码,而不是测试。

1.8 为维护测试 - 和质量而付出!

重点是:代码是系统架构的一部分。测试是模块。不交付测试并不能减轻随更多模块带来的设计和维护负担。一种经常与单元测试混淆的技术是测试驱动开发,它使用单元测试技术。人们相信它改善了耦合和内聚性的度量,但是经验证据表明并非如此(用经验基础揭穿这一概念的几篇论文之一是 Janzen 和 Saledian,“测试驱动开发真的能提高软件设计质量吗?” IEEE Software 25(2), 2008年3月/4月,第77 - 84页。更糟的是,您在每个模块和相应的测试之间引入了耦合——协调的变更)。您还需要测试作为系统的模块。在发货之前删除它们并不会改变它们的维护行为。(在发货前删除它们甚至可能是一个坏主意——但稍后会详细说明。)

当我查看大多数单元测试时——尤其是那些用 JUnit 编写的单元测试——它们都是伪装的断言。当我编写一款优秀的软件时,我会在其中添加一些断言,这些断言描述了我期望函数的调用者履行的承诺,以及函数对其客户作出的承诺。这些断言与我的代码的其它部分在相同的制品中共同演化。多数环境都提供了在发布时对这些断言进行中性化管理的规定。

一种更加专业的方法是,在发布时将断言保留在代码中,并代表最终用户自动提交错误报告,还可能尝试在断言失败时重启应用程序。在我上面提到的那家初创公司,我的老板坚持我们不这样做。我向他指出,断言失败意味着程序中的某些东西已经存在严重问题,程序很可能会产生错误的结果。在我们正在开发的软件中,即使是最微小的错误也可能导致客户 500 万美元的返工损失。他说,更重要的是,公司要避免让人觉得做错了什么,而不是在得出错误结果之前就停止行动。我离开了公司。也许你是他今天的客户之一。

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

几乎在最后,有些单元测试只是复制系统测试、集成测试或其他测试。在计算的早期,计算机运行非常缓慢,单元测试会给开发人员提供更多关于更改是否破坏了代码的即时反馈,而无需等待系统测试的运行。如今,随着电脑变得更便宜、功能更强大,这种说法就不那么有说服力了。每次我对 Scrum Knowsy® 应用程序进行更改,我都会在系统级别进行测试。开发人员应该不断地集成和进行系统测试,而不是专注于他们的单元测试并推迟集成,即使只有一个小时。因此,请去除那些和系统测试作用重复的单元测试。如果系统测试级别过于昂贵,则创建集成测试的子单元。Rex 认为“测试的下一个重大飞跃是设计单元测试、集成测试和系统测试,从而消除无意的割裂与重叠。”

检查您的测试库存,以防止重复;你可以在你的精益项目下为其投资。创建具有良好的特性覆盖率(而不是代码覆盖率)的系统测试————请记住:对错误的输入或其他非预期条件的适当响应,是您的特性集的一部分。

最后:我曾经从某人那里听到一个借口。他们需要一个单元测试,因为无法从任何外部测试接口来运行那个代码单元。如果您的测试接口设计良好,能够重现您在现实世界中看到的系统行为,并且您发现这样的代码在您的系统测试方法中是不可达的,那么……删除该代码!严肃地说,在系统测试的指引下对代码进行推理是发现死代码的好方法。这甚至比寻找不需要的测试更有价值。

1.9 “这是过程,愚蠢,”或者:永远的绿条

也许单元测试最严重的问题是它们关注于修复 bug,而不是系统级的改进。我已经太多次看到程序员低着头,努力试图通过测试并出现绿条。测试人员形成一个假设:在他/她的孤立环境中,无法轻易地获得足够的信息来验证或反驳它。因此,他/她只是开始尝试一些东西,看看它们是否能让你更接近绿条——或者让你一直抵达那里。

测试中有两个潜在的目标。一是将其作为学习工具:更多地了解程序及其工作原理。另一个是神谕。当人们陷入后一种模式时,就会出现故障模式:测试是神谕,目标是正确执行。他们忽略了这样一个事实:目标是广泛的洞察力,而洞察力将提供修复 bug 的关键。

这就是为什么离开终端一段时间是有效的。你从期望绿条出现的巴甫洛夫反应中解脱出来,可以开始整合从测试中收集到的一些见解。如果你得到了足够多的信息,你就得到了对于全景的纤细骨架。如果骨架足够多,bug 就会变得明显。

系统测试几乎立即将您置于反射的位置。当然,您仍然需要详细的信息,这就是调试的用武之地。调试就是利用工具和设备来帮助隔离 bug。调试不是测试。它是临时性的,并且是在单个 bug 的层次上进行的。单元测试可以是一个有用的调试工具。根据我自己的经验,我发现许多工具的组合效果最好。最有效的是数据值陷阱,以及对全局上下文的访问,包括所有数据值和偶尔的堆栈跟踪。

1.10 包装起来

回到我在 Sogeti 的客户。一开始,我提到他曾说:

当我每天都在编程时,我确实为了可测试性而编代,但是我几乎没有编写任何单元测试。然而,我以我的代码质量和几乎无 bug 的软件而闻名。我想知道为什么这个对我有效?

也许 Richard 是少数几个知道如何思考,而不是让计算机替他思考的人之一——无论是在系统设计还是底层设计。我更倾向于在东欧国家发现这一点,那里缺乏广泛可用的计算设备,迫使人们思考。那里根本没有足够的电脑可用。当我 2004 年第一次访问塞尔维亚时,FON (一个学习计算机的学院)的学生可以每周使用一台计算机访问互联网一次。失败的代价是很高的:如果您的代码无法工作,您必须再等待一周才能再次尝试。

幸运的是,我是在这样一种编程文化中长大的,因为我的代码写在打孔卡上,你把打孔卡交给操作员,他们排队提交到机器,24小时后你再收集输出。这迫使你思考——或失败。来自 Sogeti 的 Richard 也有类似的成长经历:他们有一周的时间来准备代码,但每周只有一个小时来运行代码。他们必须一次就把它做对。无论如何,一个学习性的项目应该评估成本障碍,并在每次迭代中消除另一个障碍,专注于不断增长的价值。不过,我最喜欢的一句玩世不恭的话是:“我发现数周的编码和测试可以为我节省数小时的计划时间。”快速失败文化最让我担心的不是失败,而是快速。我的老板 Neil Haller 多年前告诉我,调试不是坐在程序前,用调试器进行工作;而是你向后靠在椅子上,盯着天花板,或者和团队讨论 bug。然而,许多被认为是敏捷的书呆子把过程和 JUnit 放在个人和交互之前。

最好的例子是我去年从一位同事那里听到的,她叫 Nancy Githinji,曾与丈夫在肯尼亚经营一家电脑公司,他们现在都在微软工作。上次她回家(去年),她遇到了一些孩子,他们住在丛林里,正在写软件。他们每个月进城一次,可以使用电脑并试用它。我想雇那些孩子!

作为敏捷的家伙(原则上),我不得不承认 Rex 说的是对的,然后☺,这让我有点受伤。但他很有说服力:“关于'快速失败' 的文化有些很草率的方面,它鼓励不假思索地往墙上扔一堆意大利面……部分是由于对于单元测试能够降低风险的过度自信。”快速失败文化可以在非常严格的纪律下、并通过健康的怀疑主义的支持很好地工作,但在一个动态的软件企业中,很难发现这种态度仍然存在。有时候,失败需要思考,而思考需要的时间比快速失败所能提供的时间还要长。正如我妻子 Gertrud 刚刚提醒我的那样:没有人希望失败花很长时间……

如果您雇佣了一个专业的测试经理或测试顾问,他们可以帮助您在更大的测试场景中解决问题:集成测试、系统测试,以及适合于它们的工具与流程。这很重要。但不要忘记 Scrum 中产品负责人、或者是商业分析师和项目经理的视角:风险管理是他们的工作的直接中心。这可能是为什么 Jeff Sutherland 说,产品负责人应该规划(最好是设计)系统测试,作为 Sprint 计划之前或执行期间的输入。

至于互联网:它是可悲的,坦率地说,很可怕,但我们这里涉及很少。存在很多建议,但是很少有理论、数据、甚至是一个模型来支持你为什么应该相信一个给定的建议。好的测试需要怀疑。怀疑自己:度量、验证、重试。看在上帝的份上,对我抱怀疑的态度。请在 jcoplien@gmail.com 上留言给我,并将 Rex 复制到本通讯前面的地址。

总而言之:

  • 将回归测试保留一年左右——但是其中大部分应该是系统级别的测试,而不是单元测试。
  • 保持那些用于测试关键算法的单元测试,这些算法应该具有广泛、正式、独立的正确性神谕,,并且有可赋予的业务价值。
  • 除了前面的情况,如果 X 具有业务价值,并且您可以使用系统测试或单元测试文本 X,那么使用系统测试——上下文就是一切。
  • 用比设计代码更谨慎的态度设计测试。
  • 将大多数单元测试转换为断言。
  • 扔掉一年以内没有失败过的测试。
  • 测试不能取代良好的开发:高测试失败率意味着您应该缩短开发周期,也许是从根本上,并确保您的体系结构和设计方案有效
  • 如果您发现正在测试的单个函数很琐碎,请再次检查您激励开发人员的效能的方式。奖励覆盖率或其他无意义的度量可能导致架构的快速衰退。
  • 对于测试可以实现的目标保持谦虚。测试不能提高质量:而开发人员可以。

备注

  1. 原文:though they may even do some amount of verification, do no validation. 可以理解为,verification 是简单检查功能和算法,而 validation 是满足用户层面的要求。

  2. 原文:Complex Things are Complicated。 它们分别侧重于物理层面或心智层面的复杂性。原文应该是想表达:物理上复杂的东西对于头脑来说也很复杂。具体含义请参见内容。

翻译系列: