我目前正在为一个非常传统的企业Java开发人员团队教授为期两周的“敏捷开发实践”速成课程。将社区15年的进步浓缩成8门半天的实践课程是非常具有挑战性的:在严格的时间限制下,可以教授哪些思想和实践,为这些开发者的职业生涯提供最大的帮助?经过几天断断续续的思考,我得出了至少一个结论:传统上向新人介绍的测试驱动开发(TDD)不会出现在我的课程中。TDD介绍的常见问题是,它们经常让学习者走上通往明显目的地的道路,但实际上并没有展示如何到达那里。这种现象非常普遍,我决定给它起个名字:“WTFnow,伙计们?”(WTF现在,伙计们?)图1—说真的,这到底是怎么回事??我认为这种情况可以解释为什么开发人员之间对TDD存在如此多的分歧。当一个开发者这样抱怨:“到处都是mockobjects,太可怕了”,另一个正在爬更高山的开发者可能会回复“哈?到处都是mockobjects多好!”事实上,这是人们谈论TDD时出现的典型情况,我相信问题的出现是因为我们使用相同的词汇和工具来描述完全不相关的实践。对于山上的人来说有意义的TDD问题对于探索山的另一边的人来说可能完全没有意义。如果我是对的(阅读本文后您可以自己判断),我认为这种观点揭示了为什么许多曾经对TDD的承诺和初步体验感到兴奋的开发人员最终感到失望。通过代码套路来教授“经典TDD”(编码练习)让我们先看看如何使用代码套路来教授TDD。首先,我将简要演示如何测试驱动一个返回任何斐波那契数的函数。我一直对自己说,“这一整天的例子虽然不是很实用,但至少可以说明‘红-绿-重构’的开发节奏”。稍后,我们将复习鲍勃叔叔的保龄球得分型。当天培训的重头戏是学员两人一组实现了罗马数字到阿拉伯数字数组的转换功能。第二天,我会站在白板前,让同学们总结一下他们体验到的TDD带来的好处。不出所料(但这很重要),所有学生都将TDD视为与正确性相关:“代码没有缺陷”、“自动回归测试取代手动测试”、“修改代码不用担心破坏原有功能”等等。当我评论他们的回答“TDD的主要好处是改进我们的代码设计!”时,他们感到措手不及。当我告诉他们TDD带来的任何回归测试安全性充其量只是副作用,最坏的情况是一种幻觉时,他们开始环顾四周,希望确保他们的老板没有听到我要说的话。这听起来不像他们一开始所希望的那样。假设地,我只是提供编码练习,就像我每天做的那样,而忽略了它们只是简单的练习题这一事实。当学生发现他们在TDD编程练习中学到的知识对他们平时的工作没有帮助时,他们是多么失望。错误#1:鼓励巨大的代码单元对于初学者来说,如果你的目标是让每个测试直接帮助解决你的问题,那么***你最终会得到越来越多的功能代码单元。第一次测试会得到一些直接解决问题的程序代码。第二个测试会带来更多。第三个测试将使您的设计更加复杂。TDD实践本身不会随时告诉你需要改进实现本身的设计——将大段代码拆分成小段。图2—考虑上图,如果出现新需求,大多数开发人员会想到为现有单元增加额外的复杂性,而不会预料到新需求需要通过添加新单元来实现。防止代码设计变得一团糟成为留给开发人员的一项练习。这就是为什么许多TDD支持者呼吁在测试通过后添加一个“大量重构步骤”,因为他们意识到开发人员需要介入该过程,让他们停下来并看到简化设计的机会。每次测试通过后重构是TDD支持者的原则(毕竟“红灯-绿灯-重构”是必须遵循的),但实际中很多开发者经常错误地跳过这一步,因为TDD过程没有任何Intrinsic规则迫使人们重构,直到最好的代码变得一团糟。一些培训师会告诉开发人员,严格的重构可以体现纪律和专业的美德,希望能解决这个问题。这对我来说似乎不是解决方案。与其质疑那些为实践TDD付出巨大努力的人的专业水平,我更愿意质疑工具和练习是否旨在鼓励人们在他们的工作流程中做正确的事情。错误2#:鼓励费力的重构提取假设当代码单元开始变大时,您将主动提取重构。图3—将部分单元职责提取到一个新的子单元中。保持原始测试不变,以确保我们的重构没有破坏任何东西。请注意,提取重构通常很痛苦。提取重构通常需要仔细分析和全神贯注,以将复杂的父对象梳理成整洁的子对象和不太复杂的父对象。引用BrandonKeeper的话“将两个纱球编织成一个结比将一个打结的纱球分成两个要容易得多”。错误3#正确代码的特性测试即使重构工作成功完成,还有很多工作要做!为了确保系统中的每个单元都有相应的设计良好的单元测试(我称之为“对称测试”),你需要设计新的单元测试来描述新的子对象行为。这种方法是有问题的,因为特性测试是处理遗留代码的测试工具,不应该出现在真正的测试驱动开发中。同时,如果我们将“功能测试”定义为“向一个没有测试来验证其行为的单元添加测试”,那恰恰描述了我们正在做的事情:为已经实现但未实现的单元编写测试'有相应的单元测试。因为新测试不是按照正常的TDD节奏编写的,开发人员面临着与“实施后添加测试”情况相同的风险。也就是说,因为代码已经存在,所以您的特性测试无法确保验证新子单元的完整行为。因此,即使您做了所有这些额外的(值得称赞的)工作来覆盖新单元,您可以达到的测试质量上限也总是低于从头开始进行TDD的情况。这个结果表明,这种活动实际上是一种浪费。图4——为新的子单元行为添加特性测试。我们需要谨慎对待测试的健壮性,因为它是“开发后添加测试”的产物。错误4#冗余测试覆盖但是现在您的系统正面临另一个测试陷阱:冗余测试覆盖!在两个地方覆盖相同的行为可能会让TDD新手感到舒服,直到改变的成本开始失控。假设一个新的需求来改变提取的子对象的行为。理想情况下,这需要三个更改(所有这些更改都是开发人员可以预测的):用于验证新功能的集成测试、用于描述新行为的单元测试以及单元代码本身。但是在我们的冗余测试示例中,父单元的测试也需要修改。更糟糕的是,实施更改的开发人员不知道父对象的单元测试会失败。也就是说,最好的情况是开发者面临一个意想不到的“惊喜”:父单元的测试失败,需要额外努力根据子对象的行为重新设计父单元的测试。最坏的情况可能是开发人员可能没有意识到测试失败实际上是由于业务变化导致的误报,而不是真正的bug,这将导致大量时间浪费在查找父单元测试失败原因上。图5—子对象的修改导致父对象的测试失败,需要重新设计父对象的测试,即使父对象本身没有被修改。假设子对象在两个地方使用——甚至10个地方!对依赖单元的简单修改可能会导致对依赖单元进行数小时的痛苦测试和修复工作。错误5#在牺牲回归效率的情况下删除冗余如果我们想避免冗余测试最终导致的痛苦,重构以实现一个简单的提取器方法需要我们重新设计父单元的测试。请注意,父单元测试最初是保证正确性和回归安全的,因此原作者可能不喜欢我为删除冗余所做的工作——用它们的测试替身替换父单元中的子单元实例。图6—用测试替身代替父单元测试,而不是使用实际的子单元实例。“现在这些测试没有多大意义,它们实际上并不能验证任何东西!”原作者可能说过。按照写这些代码的初衷(TDD是在保证完全回归安全的情况下迭代解决问题),他们的意见是绝对正确的。可以用“但是这些单元已经有独立的测试”来反驳他们的论点,但原作者的担心不无道理,因为缺乏额外的集成测试来确保这些单元正确地协同工作。在这一点上,我见过很多团队走入死胡同。有些人会喜欢使用mocks,有些人则非常反对mocks,但没有人真正理解这场辩论只是一种表象。它的根源是经典的TDD。我们提供的错误假设。错误6#Mock的滥用虽然我通常建议团队使用mock,但他们使用这样的mock并不是一个好主意。首先,用测试替身替换子单元会使父单元的测试复杂化:部分测试代码将描述父单元的逻辑行为,部分测试代码将描述父单元和子单元合作的预期方式。除了上面两个方面的处理,测试还绑定了父子单元如何协作的细节,因为任何调用都必须匹配父单元的实现逻辑。像这样同时描述逻辑行为和单元协作的测试很难阅读、理解和修改。这种恐怖可以渗透到大多数使用测试替身的测试中。难怪我一直听到关于单元测试中模拟过多的抱怨,最近这件事一直困扰着我。要解决这种滥用问题,还有很多工作要做。父单元需要重构,只指导其他单元的协作,本身没有任何实现逻辑。这需要将父单元中以前未提取到子单元中的行为现在需要提取到另一个新单元中(包括到目前为止讨论的所有耗时活动)。最终,父单元的原始测试将被丢弃,新测试将只包含确保各个子单元之间交互所必需的协作的描述。哦,由于现在根本没有集成测试来确保父单元正常工作,我们需要再添加一个。天哪,维护干净的代码、可理解的测试和快速构建需要付出如此多的努力和纪律,难怪很少有团队最终实现他们希望使用TDD实现的目标。#p#成功应用TDD的方法因此,我希望提供一门全新的课程,介绍与上述完全不同的TDD工作流程。首先,考虑上述痛苦曲折过程的最终产品:一个依赖两个子单元实现逻辑功能的父单元一个描述两个子单元交互的父单元测试两个子单元,每个子单元由一个单元测试描述他们的各自的职责如果这是我们要达到的最终目标,为什么不首先朝这个方向迈进呢?我的TDD方法考虑到了这一点,并且可以是还原论的应用。我的流程是这样的:(1)拉入一个新的特性需求,要求系统完成一些新的功能。(2)对功能表面上的复杂性感到恐慌。想一想您最初为什么要从事编程。(3)找到功能的切入点,从建立一个公共接口合约开始(例如:“我将在控制器中添加一个返回给定年份和月份的利润值的行为”)此时,公共合约也是写入集成测试的好机会。本文与集成测试无关,但我推荐在自己单独的进程中运行的测试,它们可以像真实用户一样与应用程序交互(例如通过HTTP请求)。如果一开始就加入集成测试来保证回归安全,我们的单元测试就不需要考虑太多集成测试了。(4)为切入点写单元测试,但不要试图马上解决问题,有意识地延迟写实现逻辑!正如它应该的那样,通过假设您已经拥有一些您需要的对象来简化问题(例如“如果此控制器仅依赖于一个按月获得收入的对象和一个按月获得支出的对象,则它必须是简单的”)。由于此步骤本身鼓励使用小型、单一功能的单元,因此它可以改进您的设计。(5)使用TDD实现切入点代码,并编写测试,就好像那些虚构单元已经存在一样。在入口点要使用的依赖对象处注入测试替身,并在测试中描述它与依赖的交互。交互测试描述了那些只负责控制其他单元的使用并且本身不包含逻辑的“协作”单元。此步骤可以改进您的设计,因为它使您有机会发现新依赖项必须具有的API。如果交互难以测试,则很容易更改函数签名,因为这些依赖项尚未实现。(6)对每个新想到的对象重复步骤4和5,以发现更多更细粒度的协作对象。在这一步恐慌是人的天性(“我们最终会得到无数个小类!”),但在实践中,通过良好的代码组织,它是可以管理的。因为每个对象都很小,易于理解,并且用途单一,所以通常不会因为需求的变化而在对象层次结构中删除一个未使用的单元或对象的整个子树。(我不幸拥有一段代码,其中包含如此多的巨大对象,这些对象随处可见,几乎不可能删除它们,即使它们不再用于最初的目的)。(7)最终,直到工作不能再细分。这时候最后一点逻辑是在这些对象图中的叶子节点的对象中实现的,然后回到树的顶端开始下一次修改。这个过程的目标是发现尽可能多的协作对象,使得叶节点只需要实现最简单的逻辑。“逻辑单元”的测试详细描述了有效的行为,可以让作者有理由相信单元测试是完整和正确的。逻辑单元的测试可以保持简单,因为不需要使用测试替身——只需针对不同的输入验证相应的输出结果。我喜欢将这个过程称为“Fakeituntilyoumakeit”(使用假对象直到你制作它),虽然这实际上是GOOS一书中的尖锐观点,但它强调简单性。我还发现区分“协作单元”和“逻辑单元”很有价值,这既可以使测试更清晰,也可以提高代码的一致性。另请注意,采用这种TDD方法不需要大量的重构步骤。抽取和重构操作成为一个特例而不是一个常规行为,这意味着可以完全避免上面详细描述的抽取和重构带来的后续额外成本。改变我们教授TDD的方式我花了四年时间才完全理解我使用TDD的挫败感,并将这些反思写进了这篇文章。在徘徊和思考这些问题很久之后,我可以说最终我认为TDD是一种高效且愉快的实践。尝试为所有项目在TDD上投入那么多时间是不值得的,但TDD是一种有效的工具,可以帮助我们在构建一个有望长期生存的系统时克服焦虑和复杂性。分享这些给大家的目的就是告诉大家,这才是真正的测试驱动开发。经典TDD的简单假设给新手带来的痛苦并没有教给他们太多东西。让我们一起找到一种方法来向学生传授更有效的TDD工作流程,以便他们可以立即使用这些有效的工具将令人困惑的大问题分解为可管理的小问题。原文链接:testdouble翻译:伯乐在线——如果我治不好你,我就不是兽医翻译链接:http://blog.jobbole.com/64431/
