与过去 70 年间大多数程序员的做法相比,本章描述的实践有着根本的区别。它们强 制进行大量的分钟级甚至秒级、深刻的、充满仪式感的行为,以至于大多数程序员初次接 触时都会觉得荒唐。于是许多程序员做敏捷时尝试去掉这些实践。然而他们失败了,因为 这些实践才是敏捷的核心。没有测试驱动开发、重构、简单设计及结对编程的敏捷只是虚 有其表,起不到作用。
测试驱动开发是一个足够复杂的话题,需要一整本书才能讲完。本章仅仅是一个概览, 主要讨论使用该实践的理由和动机,而不会在技术方面进行深入的讨论。特别说一下,本 章不会出现任何代码。
程序员是一个独特的职业。我们制造了大量文档,其中包含深奥的技术性神秘符号。 文档中的每个符号都必须正确,否则就会发生非常可怕的事情。一个符号错误可能造成财 产和生命损失。还有什么行业是这样的?
会计。会计师也制造了大量文档,其中也包含深奥的技术性神秘符号。而且文档中的 每个符号都必须正确,否则就可能造成财产甚至生命损失。那么会计师是如何确保每个符 号都正确的呢?
5.1.1 复式记账
会计师们在 1000 年前发明了一条法则,并将其称为复式记账。每笔交易会写入账本 两次:在一组账户中记一笔贷项,然后相应地在另一组账户中记为借项。这些账户最终汇总到收支平衡表文件中,用总资产减去总负债和权益。差额必须为零。如果不为零,肯定 就出错了。
从一开始学习,会计师就被教会一笔笔地记录交易并在每一笔交易记录后立即平衡余 额。这使他们能够快速地发现错误。他们被教会避免在两次余额检查之间记录多笔交易, 因为那样会难以定位错误。这种做法对于正确地核算资金至关重要,以至于它基本上在全 世界都成了法规。
测试驱动开发是程序员的相应实践。每个必要的行为都输入两次:一次作为测 试,另一次作为使测试通过的生产代码。两次输入相辅相成,正如负债与资产的互 补。当测试代码与生产代码一起执行时,两次输入产出的结果为零:失败的测试数为零。
学习 TDD 的程序员被教会每次只添加一个行为——先写一个失败的测试,然后写恰 好使测试通过的生产代码。这允许他们快速发现错误。他们被教会避免写一大堆生产代码, 然后再添加一大堆测试,因为这会导致难以定位错误。
复式记账与 TDD 这两种纪律是等效的。它们都具有相同的功用:在极其重要的文 档中避免错误,确保每个符号都正确。尽管编程对社会来说已经必不可少,但我们还没 有用法律强制实施 TDD。可是,既然编写糟糕的软件已经造成了生命财产损失,立法还 会远吗?
5.1.2 TDD 三规则
TDD 可以描述为以下 3 条简单的规则。
有一点编程经验的程序员可能会觉得这些规则太离谱了,就差说愚蠢至极了。它们意 味着编程的周期或许只有 5 秒。程序员先为不存在的生产代码写一些测试代码,这些测试 几乎立即编译失败,因为调用了不存在的生产代码中的元素。程序员必须停止编写测试并 开始编写生产代码。但是敲了几下键盘之后,编译失败的测试现在竟然编译成功了。这迫 使程序员又回去继续添加测试。
每几秒钟就在测试与生产代码之间切换一次,这个循环会对程序员的行为形成有 力的约束。程序员再也不能一次写完整个函数,甚至不能一次写完简单的 if 语句或 while 循环。他们必须编写“刚好失败”的测试代码,这会打断他们“一次写完” 的冲动。
大多数程序员最初会认为这会扰乱他们的思路。三规则一直打断他们,使他们无法静 心思考要编写的代码。他们经常觉得三规则带来了难以容忍的骚扰。 然而,假设有一组遵循三规则的程序员。随便挑一个程序员,该程序员的所有工作内 容都在一分钟之前执行并通过全部测试。无论何时你选择何人,一分钟之前所有内容都是 可工作的。
5.1.3 调试
一分钟之前所有内容总是可工作的意味着什么?还需要多少调试工作?如果一分钟之前所有内容都能工作,那么几乎你遇到的任何故障都还没超过一分钟。调试上一分钟才引入的故障通常是小事一桩,根本不需要动用调试器来寻找问题。你熟悉使用调试器吗?你还记得调试器的快捷键吗?你能全凭肌肉记忆自动地敲击快捷键来设置断点、单步调试、跳入跳出吗?在调试的时候,你觉得自己饱受摧残吗?这并不是一个令人心仪的技能。
熟练掌握调试器的唯一方法就是花大量时间进行调试。花费大量时间进行调试意味着总是存在很多错误。测试驱动开发者并不擅长操作调试器,因为他们不经常使用调试器,即便要用,通常也只花费很少的时间。
我不想造成错误的印象。即使是最好的测试驱动开发者,仍然会遇到棘手的 bug。毕竟我们开发的是软件,软件开发仍然很难。但是通过实践 TDD 的 3 条规则,就可以大大降低 bug 的发生率和严重性。
5.1.4 文档
你是否集成了第三方软件包?它可能来自一个 zip 文件,其中包含源代码、DLL、JAR文件等。压缩包中可能有集成说明的 PDF 文件,PDF 的末尾可能还有一个丑陋的附录,其中包含所有代码示例。
在这样一份文档中,你首先阅读的是什么?如果你是一名程序员,你可能立即直接跳到最后去阅读代码示例,因为代码可以告诉你事实。
当遵循三规则时,你编写出的测试最终将成为整个系统的代码示例。如果你想知道如何调用 API 函数,测试集已经以各种方式调用该函数,并捕获其可能引发的每个异常。如果你想知道如何创建对象,测试集已经以各种方式创建该对象。测试是描述被测系统的一种文档形式。这份文档以程序员熟练掌握的语言编写。它毫无含混性,它是严格可执行的程序,并且一直与应用程序代码保持同步。测试是程序员的完美文档:它本身就是代码。
而且,测试本身并不能相互组合成一个系统。这些测试彼此之间并不了解,也并不互相依赖。每个测试都是一小段独立的代码单元,用于描述系统一小部分行为的方式。
5.1.5 乐趣
如果你曾事后补写测试,你就应该知道,那不太好玩。因为你已经知道代码可以工作,你已经手工测试过。你之所以还要编写这些测试,只是因为有人要求你必须这样做。这给你平添了很多工作量,而且很无聊。
当你遵循三规则先写测试时,这个过程就变得很有趣。每个新的测试都是一次挑战。每次让一个测试通过,你就赢得了一次小的成功。遵循三规则,你的工作就变成了一连串小挑战和小成功。这个过程不再无聊,它让人有达成目标的成就感。
5.1.6 完备性
现在让我们回到事后补写测试的方式。尽管你已经手动测试了系统并且已经知道它可以工作,但你还是被迫编写这些测试。毫无意外,你编写的每个测试都会通过。你不可避免地会遇到难以编写的测试。难是因为在编写代码时,你并未考虑过可测试性,也并未将代码设计得可被测试。你必须首先修改代码结构才能编写测试,包括打破耦合、添加抽象、调换某些函数调用和参数。这感觉很费力,尤其你已经知道那些代码是可以工作的。
日程安排得很紧,你还有更紧迫的事情要做。因此,你将测试搁置在一旁。你说服自己:测试不是必需的,或者可以稍后再写。于是,你在测试套件中留下了漏洞。你已经在测试套件中留下了漏洞,而且你怀疑其他人也都会这么做。当你执行测试套件并通过测试时,你笑而不语、云淡风轻地摆摆手,因为你知道测试套件通过并不意味着系统就可以正常工作。
当这样的测试套件通过时,你将无法做出决定。测试通过后给出的唯一信息是,被测到的功能都没有被破坏。因为测试套件不完整,所以它无法给你有效的决策支持。但是,如果遵循三规则,每行生产代码都是为了通过测试而编写的。因此,测试套件非常完整。当它通过时,你就可以决定:系统可以部署。
这就是目标。我们想要创建一套自动化测试,用来告诉我们部署系统是安全可靠的。
再说一遍,我不想造成假象。遵循三规则可以给你提供一套非常完备的测试套件,但可能并非 100%完备。这是因为三规则在某些情形下并不实用。这些情形超出了本书的讨论范围,只能说它们数量有限,而且有一些方案可以解决。简单的结论就是,就算你无比勤勉地遵循三规则,也不太可能产出 100%完备的测试套件。但是并不一定要 100%完备的测试才能决定部署。90%多、接近 100%的覆盖率已经足够了,而这种程度的完备性是绝对可以实现的。
我创建过非常完备的测试套件,基于这样的测试套件我可以放心地做出部署决定。我见过其他许多人也这么做。虽然完备度没有达到 100%,但已经足够高了,可以决定部署了。
警告
测试覆盖率是团队的指标,而不是管理的指标。管理者不太可能理解这个指标的实际含义。管理人员不应将此指标当作目标。团队应仅将其用于观察测试策略是否合理。
再次警告
不要因为覆盖率不足而使构建失败。如果这样做,程序员将被迫从测试中删除断言,以达到高覆盖率。代码覆盖是一个复杂的话题,只有在对代码和测试有深入了解的情况下才能理解。不要让它成为管理的指标。
还记得那些难以事后补写测试的函数吗?难是因为它与别的行为耦合在一起,而你不希望在测试中执行那些行为。例如,你想测试的函数可能会打开 X 光机,或者从数据库中删除几行。难,是因为你没有将函数设计成易于测试的样子。你先写代码,事后才去考虑怎么写测试。当你写代码时,可测性恐怕是最不会从你脑海中浮现的东西。现在你面临重新设计代码以便于进行测试的情况。你看了一下手表,意识到写测试这事已经花了太长时间。由于你已经进行了手工测试,你知道代码是可以工作的,于是你放弃了,在测试套件中又留下了一个漏洞。
但是,如果你先写测试,事情就截然不同。你无法写出一个难以测试的函数。由于要先写测试,你自然地将被测函数设计成易于测试的样子。如何保持函数易于测试?解耦。实际上,可测性正是解耦的同义词。
通过先写测试,你将以此前从未想过的方式解耦系统。整个系统将是可测试的,所以整个系统也将被解耦。
正因如此,TDD 经常被称为一种设计技巧。三规则强迫你达成更高程度的解耦。
5.1.8 勇气
到目前为止我们已经看到,遵循三规则可以带来许多强大的好处:更少的调试,高质量的详细文档,有趣、完备的测试,以及解耦。但是,这些只是附带的好处,都不是实践TDD 的真正动力。真正的原因是勇气。
我在本书开头的故事里已经说过了,但值得再重复一遍。
想象你正在电脑屏幕前看着一些旧代码。你的第一个念头是:“这段代码写得太差劲了,我应该清理一下。”但下一个念头是:“我不想碰它!”因为你知道,如果碰了这段代码,你会把软件搞坏,然后这段代码就成了你的代码。所以你躲开了那段代码,任其溃烂腐化。
这就是一个恐惧的反应。你恐惧代码,你恐惧触碰它,你恐惧万一改坏的后果。所以,你没能做到改进代码,你没能清理它。
如果团队中的每个人都如此行事,那么代码必然烂掉。没有人会清理它。也没有人会改善它。每次新增功能时,程序员都尽量减少“马上出错”的风险。为此,他们引入了耦合和重复,尽管明明知道耦合和重复会破坏代码的设计和品质。
最终,代码将变成一坨可怕的、无法维护的意大利面条,几乎无法在上面做任何新的开发。估算将呈指数级增长。管理者将变得绝望。他们会招聘越来越多的程序员,希望他们的加入能提高生产力,但这绝不会实现。最终,管理人员在绝望中同意了程序员的要求,即从头开始重写整个系统,再次开始这个轮回。
想象另一个不同的情景。回到充满混乱代码的屏幕前。你首先想到的是清理它。如果你有一个完备的测试套件,当测试通过时你可以信任它,结果会怎样?如果测试套件运行得很快,结果又会怎样?你接下来的想法是什么?应该会是这样:
天哪,我应该重命名那个变量。啊,测试通过了。好,现在我将那个大函数拆成两个小一点的函数……漂亮,测试仍然通过……好,现在我想将一个新函数移到另一个类中。哎呀!测试失败了。赶紧把这个函数放回去……啊,我知道了,那个变量也需要跟着一起搬移。是的,测试仍然通过……
你拥有完备的测试套件时,你就不会再恐惧修改代码,不会再恐惧清理代码。因此,你将清理代码。你将保持系统整洁有序。你将保持系统设计完好无损。你不再制造令人恶心的意大利面条,不再使团队陷入生产力低下和最终失败的低迷状态。这就是我们实践 TDD 的原因。我们之所以实践它,是因为它给了我们勇气,去保持代码整洁有序。它给了我们勇气,让我们表现得像一个专业人士。
重构又是一个需要整本书来描述的主题。幸运的是,马丁·福勒已经写完了这本精彩的书。本章中,我只讨论纪律,不涉及具体技术。同样,本章不包含任何代码。重构是改善代码结构的实践,但并不改变由测试定义的行为。换句话说,我们在不破坏任何测试的情况下对命名、类、函数和表达式进行修改。我们在不影响行为的情况下改善系统的结构。
当然,这种做法与 TDD 紧密相关。我们需要一组测试套件才能毫无恐惧地重构代码,测试套件可以使我们完全不担心会破坏任何东西。从细微的美化到深层次的结构调整,重构期间进行的修改种类繁多。修改可能只是简单地重命名,也可能是复杂地将 switch 语句重组为多态分发。大型函数被拆分为较小的、命名更佳的函数。参数列表被转为对象。包含许多方法的类被拆分成多个小类。函数从一个类搬移到另一个类中。类被提取为子类或内部类。依赖关系被倒置,模块在架构边界之间来回搬移。
并且,在进行所有这些修改时,测试始终保持通过的状态。
5.2.1 红-绿-重构
在 TDD 三规则的基础上再结合重构过程,就是广为人知的“红-绿-重构”的循环(如图 5-1 所示)
(1)创建一个失败的测试。
(2)使测试通过。
(3)清理代码。
(4)返回步骤 1。
我认为,编写可用的代码与编写整洁的代码是编程的两个不同维度。尝试同时控制这两个维度很困难,可能无法达成,因此我们将这两个维度分解为两种不同的活动。
换句话说,让代码正常工作都很难,更不用说使代码整洁了。因此,我们首先聚焦于以粗劣的想法草草地使代码工作起来。然后,一旦代码工作起来且通过测试,我们就开始清理那一团脏乱差的代码。
这清晰地表明重构是一个持续的过程,而不是定期执行的过程。我们不会留下一大摊脏乱差的代码,然后好多天以后才尝试清理它。相反,我们在一两分钟内制造了一团非常小的混乱,然后就立即清理这团小混乱。重构一词永远不应出现在时间表上。重构活动也不应该出现在项目的计划中。我们不为重构预留时间。重构是我们每分钟、每小时软件开发活动中不可分割的一部分。
5.2.2 大型重构
有时,这种情况的需求变更会使你意识到,系统当前的设计和架构并非最优,于是需要对系统的结构进行重大修改。这种修改同样纳入红-绿-重构循环内。我们不会专门建一个项目来修改设计。我们不会在时间表中为此类大型重构预留时间。相反,我们一次一小步地迁移代码,同时继续按照正常的敏捷周期添加新功能。
这样的设计修改可能需要几天、几周甚至几个月的时间。在此期间,即使设计转型并未全部完成,系统仍会持续地通过所有测试,并且可以部署到生产环境中。
简单设计实践是重构的目标之一。简单设计的意思是:仅编写必要的代码,使得程序结构保持最简单、最小和最富表现力。肯特·贝克的简单设计规则如下。
(1)所有测试通过。
(2)揭示意图。
(3)消除重复。
(4)减少元素。
序号既是执行顺序又是优先级。
第 1 点不言而喻。代码必须通过所有测试。代码必须能工作。
第 2 点指出,在代码工作起来之后,还应使其具备表现力。它应该揭示程序员的意图,应该易于阅读和自我表达。在这一步里,我们运用各种比较简单、以修饰为主的重构手法。我们还将大函数拆分为较小的、命名更佳的函数。
第 3 点指出,在使代码尽可能具备描述性和表现力之后,我们将寻找并消除代码中所有的重复内容。我们不希望一件事在代码中重复好几遍。活动期间,重构通常更加复杂。有时消除重复很简单,就是将重复代码移入一个函数中然后在许多地方调用它。另一些情况下,重构需要更有趣的解决方案,例如一些设计模式1:模板方法(Template Method)模式、策略(Strategy)模式、装饰(Decorator)模式或访问者(Visitor)模式。
第 4 点指出,一旦消除了所有重复项,我们应努力减少结构元素,例如类、函数、变量等。简单设计的目标是,只要可能,尽量降低代码的设计重量。
设计的重量
软件系统的设计有非常简单的,也有极度复杂的。设计越复杂,程序员的认知负担就越大。认知负担就是设计的重量。设计越重,程序员理解和操控系统花费的时间和精力就越多。
同样,需求也有不同的复杂度,有些不太复杂,而有些非常复杂。需求的复杂度越大,就要花费更多的时间和精力来理解和操控系统。
但是,这两个因素并非叠加关系。通过采用更复杂的设计,可以简化复杂的需求。通常,这种权衡取舍是划算的:为现有功能选择适当的设计,可以降低系统的整体复杂性。
达到设计与功能复杂度之间的平衡是“简单设计”的目标。通过这种实践,程序员可以不断地重构系统的设计,使其与需求保持平衡,从而使生产力最大化。
多年来,结对编程的实践引起了大量争议和误解。两人(或更多人)可以一起解决同一问题,而且相当有效——很多人对这个概念嗤之以鼻。首先,结对是可选的。不要强迫任何人结对。其次,结对是间歇性的。有很多很好的理由支持独自编写代码。团队应该有大约 50%的时间在结对。这个数字并不重要,它可能低至 30%或高达 80%。在大多数情况下,这是个人和团队的选择。
5.4.1 什么是结对
结对是两个人共同解决同一个编程问题。结对的伙伴可以在同一台电脑上一起工作,共享屏幕、键盘和鼠标。或者他们也可以在两台相连的电脑上工作,只要他们能看到并操控相同的代码即可。后一种选择可以很好地配合流行的屏幕共享软件使用,使不在一地的伙伴也能结对编程,只要双方有良好的数据和语音连接即可。结对的程序员有时分饰不同角色。其中一个可能是“驾驶员”,另一个是“导航员”。
“驾驶员”手持键盘和鼠标,“导航员”则眼观六路并提出建议。另一种配合的方式是:一个人先编写一个测试,另一位编码让测试通过,再编写下一个测试,交还给第一位程序员来实现。有时这种结对方式被称为乒乓(Ping-Pong)。不过,更多时候,结对时没有明确的角色划分。两位程序员是平等的作者,以合作的方式共享鼠标和键盘。
结对不需要事先安排,根据程序员的喜好形成或解散搭档。管理者不应尝试用结对时间表或结对矩阵之类的工具强制要求结对。结对通常是短暂的。一次结对最长可能持续一天,但更常见的是不超过一两小时。甚至短至 15~30 分钟的结对也是有益的。故事不是分配给结对伙伴的。单个的程序员(而不是一对搭档)要对故事的完成负责。完成故事所需的时间通常比结对时间更长。在一周内,每个程序员的结对时间有一半是花在自己的任务上,并得到了结对伙伴的帮助;另一半的结对时间则是花在帮助他人完成任务上。
对于资深程序员来说,与初学者结对的次数应该超过与其他资深者结对的次数。同样,对于初学者来说,向资深程序员求助的次数应该多于向其他初学者求助的次数。具备特殊技能的程序员应经常与不具备该技能的程序员一起结对工作。团队的目标是传播和交换知识,而不是使知识集中在少数人手里。
5.4.2 为什么结对
通过结对,我们能表现得像一个团队。团队成员不能彼此孤立地工作。相反,他们以秒为单位进行协作。当一个团队成员倒下,其他团队成员会掩护他留下的漏洞,并不断朝着目标推进。
到目前为止,结对是团队成员之间共享知识并防止形成知识孤岛的最佳方法。要确保团队中没有人不可或缺,结对是最佳的方法。许多团队报告说,结对可以减少错误并提高设计质量。在大多数情况下,这应该是真实的。通常,最好有不止一人正关注着要解决的问题。事实上,许多团队已经用结对代替了代码评审。
5.4.3 结对当作代码评审
结对是一种代码评审的形式,但又比一般的代码评审方式优越得多。结对的两人在结对期间是共同作者,他们当然会阅读并评审旧代码,但其真正的目的是编写新代码。因此,评审不仅仅是为了确保套用团队的编码规范而进行的静态检查。相反,它是对代码当前状态的动态回顾,着眼于在不久的将来代码的去处。
5.4.4 代价几何
结对的成本难以衡量。最直接的代价是两人共同处理一个问题。显然,这不会使解决问题的工作量加倍;但是,它可能确实需要一些代价。各种研究表明,直接成本可能约为15%。换句话说,采用结对的工作方式时,需要 115 位程序员来完成不结对时 100 个人的工作量(不包括代码评审)。
粗略的计算表明,结对时间为 50%的团队在生产力方面付出的代价不到 8%。另外,如果结对实践代替了代码评审,那么很可能生产力根本不会降低。然后,我们必须考虑交叉培训对于知识交流和紧密合作的好处。这些收益不容易量化,但可能会非常重要。
我和许多其他人的经验是,如果不作正式要求,而由程序员自行决定,结对对整个团队非常有益。
5.4.5 只能两人吗
“结对”一词暗示着一次结对只涉及两个程序员。尽管通常是这样的,但这不是死规定。有时 3 人、4 人或更多人决定共同解决某个问题。(同样,这也是由程序员决定的。)这种形式有时也被称为“聚众式编程”(mob programming)。
5.4.6 管理
程序员常常担心管理者会反感结对,甚至可能要求中止结对、停止浪费时间。我从未见过这种情况。在我编写代码的半个世纪中,我从未见过管理者如此细节地干预。通常以我的经验,管理者很高兴看到程序员进行协作和合作,这给人以工作正在取得进展的印象。
但是,如果你作为管理者,因为担心结对效率低而倾向于干预,那么请放心,让程序员们自行解决这个问题。毕竟,他们是专家。如果你是一名程序员,而你的管理者告诉你中止结对,请提醒管理者:你自己才是专家,因此你必须对你自己的工作方式负责,而不是由管理者来负责。
最后,永远、永远不要请求管理者允许你结对,或测试,或重构,或者……你是专家。你决定。
5.5 结论
敏捷的技术实践是任何敏捷工作中最本质的组成部分。任何敏捷实践导入的尝试,如果不包含技术实践,就注定会失败。原因很简单,敏捷是一种有效的机制,它可以使人在匆忙中制造出大混乱。如果没有保持高技术质量的技术实践,团队的生产力将很快受阻,并陷入不可避免的死亡螺旋。
本文摘自《敏捷整洁之道 回归本源》
1.回顾敏捷的历史,重述敏捷最初的用意,阐述敏捷的本质;
2.澄清长久以来人们对敏捷的误解与混淆,让敏捷回归正途;
3.正本清源,为软件行业从业者讲述敏捷的价值观与原则;
4.敏捷宣言提出20年后敏捷开发人员面临的关键问题的实用答案。
《敏捷宣言》签署近20年后,软件开发界的传奇人物罗伯特·C. 马丁(“鲍勃大叔”)重出江湖,为新一代软件行业从业者——不论是程序员还是非程序员——讲述敏捷的价值观与原则。马丁著有《代码整洁之道》等极-具影响力的软件开发指导性著作,也是敏捷最初的奠基人之一。如今,在本书中,他澄清了长久以来人们对敏捷的误解与混淆,重述敏捷最初的用意。
马丁明确地阐述了敏捷的本质:敏捷虽然是一种帮助小团队运作小项目的小方法,但它对整个IT 行业有着巨大的影响,因为任何大项目都是由若干小项目组成的。他将自己50年的从业经验融入平实的文字,展示了敏捷如何帮助软件行业从业者达到真正的专业水准。