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