这本书2009年10月就出来了,当时没来得及细看,只是把它放入了我的待读列表中。后来查到2010年8月也出了中文版,书名叫《测试驱动的面向对象软件开发》。看完全书后,我发现本书重点谈的还是软件培养问题。Growing这个词出现在书的标题中,非常吸引我的思路。

  在前言中,作者开宗明义,讲了本书要强调的五个问题:1.如何让测试驱动开发适应我的工程;2.从那里开始做TDD;3.如何写单元测试与端对端测试;4.测试驱动开发的“驱动”是何意;5.如何测试某一个困难的功能。

  第一部分是简介。导言部分说了,TDD不仅是XP(极限编程)的核心实践,而且也被Crystal Clear,Scrum等敏捷开发方法才用。敏捷和非敏捷项目都可应用TDD,甚至是纯研究项目都可以。

  第一章重申TDD的纪律——“在没有一个失败的用例之前,不要写任何功能”。其后提出,将传统的“测试-实现-重构”小迭代外面,包上一个验收测试(Acceptance Test)。验收测试是测试整个系统是否能工作的。与之区别的是用于测试现有代码与我们不能改变其代码的模块是否能配合工作的集成测试(Integration Test),以及测试对象本身功能的单元测试(Unit Test)。最后强调了单元测试能够给我们一个发现不良设计以及重构代码的机会。

  接下来第二章讲述了“值类”和“对象类”的区别。针对不变的数量和度量进行建模,就是“值类”,它有些类似“不可变类”(Immutable Class)或者无状态(Stateless)类。如果针对行为进行建模,则是“对象类”了。最后讲述了要使用单一的“告知型”调用代替请求式的一连串调用,例如用“master.allowSavingOfCustomisations();”来代替“((EditSaveCustomizer)master.getModelisable().getDockablePanel().getCustomizer()).getSaveItem().setEnabled(Boolean.FALSE.booleanValue());"。第三章介绍了一些TDD的基本工具。

  第二部分讲述了TDD的具体过程。第四章讲了如何确定测试驱动开发的第一个迭代周期。提出先实现最少量的真实功能以便可以自动地构建、部署、端对端测试。这叫做“行走骨架”(Walking Skeleton),具体的说,是通过“理解问题->设计草图->自动化构建,部署,端对端测试->普通迭代”这个流程来做的。其后说道,测试先行的工程早起会引入混乱,但是迅速降低,因为找到了发展的方向,预见了可能发生的错误。相反,测试后行(或到发布期限前才集成)的工程在最后会出现大量的混乱。这可以作为是否选取TDD的一个参考标准。而“行走骨架”的好处就是,能让我们在仍有时间、预算和解决问题的意愿时,去解决问题,而不至于到了发布前才发现工程已经失控。

  第五章讲了如何维护测试驱动周期。作者提出,将衡量进度的测试(针对新功能的验收测试)和用以捕捉“功能破坏”(Regression)的测试(已有的验收测试、整合测试、单元测试)分隔开来。“笔者注:广义的回归测试即为了保证正在开发的新功能不破坏既有功能而写的测试,狭义的回归测试专指前述的验收测试。”此后提到两个问题,一是不要着急写单元测试,以免整合时发现功能不符合需要,二是要学会“倾听测试”——难于测试的功能往往意味着设计需要改进。如不改进,如果功能增多,则该有问题的设计会更难于修改。

  第六章讲述了如何在TDD中使用面向对象的风格。作者讲述了Cockburn的“端口与适配器”架构,业务领域的代码应该同技术设施,例如数据库,UI等,分离开来。不要让技术概念泄漏入应用程序模型。所以要通过接口将应用程序核心业务与每个技术领域桥接起来。这就是Eric Evans在《领域驱动设计》中所说的防腐层(Anticorruption Layer)。其后又讲了封装和信息遮掩的区别。封装主要是相对于“对端”(Peer)来说的,强调访问只能通过API进行。而信息遮掩主要是针对上层业务来说的,意在使得高层逻辑不需关注低层细节。封装和信息遮掩中的两个常见问题即是通过API返回内部实现而产生的“别名”效果。以及没能提供直接的API调用,导致客户代码通过自己组合API来完成任务,就像前述的一连串调用那样,暴露了过多细节。接下来讲了达成“单一责任原则”的一个口诀:不用任何连词(和,或,但)去描述对象。如果有从句,那么应该按照从句,把大对象拆分成一个个互相合作的小对象。这对于我们检视自己的设计很有帮助。作者将设计中一个对象的协作者,分为三种角色模板。即依赖其服务方能运转的“依赖物”(Dependencies),用于通知事件而不关注其具体身份的“被通知物”(Notifications),以及利用其调整自身行为以符合系统需求之“调整物”(Adjustments)。其后作者讲了“组合体对象”(笔者注:即组合各种对象来完成自身任务的功能模组,不同于设计模式中的组合体模式)所提供的API要比其各自部分的API总和简单。它封装了组件的存在及其内部互动,为其对端展示出一个更简单的抽象接口。本章的最后讲述了“环境独立性”的重要。“环境独立性”规则帮我们判断一个对象是否隐藏的太多或者隐藏了错误的信息。当执行环境变化时,环境独立的对象是易于改变的。其运行大环境可以通过构造器(如果对于该对象是贯穿生命期的)或需要环境的方法(如果是瞬态的)传入。

  第七章继续讲述如何达成面向对象的设计。首先讲述TDD对OO设计的帮助:1.我们必须先描述我们要做什么,而后才是怎么做;2.为了使得单元测试易懂(由此变得可维护),我们必须限定它们的范围(笔者注:如果单元测试过长或者setup阶段太繁复,则意味着受测的那个大对象需要拆解);3.我们必须将其依赖物传给它,这意味着我们必须知道它依赖的都是些什么东西。再说了接口和协议的作用:接口描述了两个组件是否互相适配,而协议则描述了他们是否能一起运转。又讲了测试可以帮助我们发现设计中的问题:一个杂乱或不清晰的测试暗示着我们暴露了太多实现,而且我们应该重新考量该对象及其临接物件的责任分配。在讲到值类和对象类的设计时,作者提出了三个技巧:打散(将一个大对象分割成一组互相协作的小对象)、剥离(定义一个对象所需的新服务,增加一个提供该服务的新对象)、捆绑(将相关对象藏入一个容器对象中)。最后在谈到接口问题时,作者提出了两个观点。一个说道:针对某一个接口的实现而写的Impl类是没有意义的。如果实现类真的没有一个好名字,那可能意味着接口的命名或者设计很糟。可能它因为有太多的责任而丧失了重点;也可能它是以实现的角度命名,而非以其在客户代码的角度上;又或者它是一个值而非一个实体对象——这种不协调有时会在写单元测试时呈现出来。(例如MyInteger和MyIntegerImpl这种接口分离就是很糟糕的)另一个说:应该根据需要合并或者拆分接口。在发现实现类的结构不清晰时,应考虑接口是否没有侧重点,需要拆解。

  第八章讲如何在第三方代码之上构建自己的工程。作者建议写一个适配器对象层,其使用第三方API实现它们的接口。我们用有重点的集成测试去测试这些适配器,以确认我们理解了第三方API是如何工作的。

  第三部分讲了一个例子,用开发一个捕捉拍卖行情而自动出价的竞拍器,来说明如何以测试为指导,去培养OO软件开发。本部分跨越了十一章。在这个过程中,穿插着对前两章所讲原则的实例化运用。第十一章演示了如何用最少的代码搭建起来可以执行的端对端测试。在本例中,仅有一个测试用空壳服务器,一个Swing窗口(最少的代码),主程序向“服务器”发送加入消息,核实服务器确实收到消息;服务器关闭竞拍,窗口显示失败消息,核实窗口确实显示了失败消息(可执行的端对端测试)。第十二章对如何组织测试提出了小建议:将测试放在一个不同的包中。防止通过包级别的后门去测试,同时方便在IDE中浏览。第十五章讲述了修改命名的重要性:重命名代码中的若干功能,这是开发进程的一个重要部分,就像我们可以用已经写出的代码来更好地理解结构应该如何发展一样,我们也通过用已经修改过的名字去编程以更好地理解这些名字的意义。我们可以理解类型和方法名是如何互相配合起来工作的,以及概念是否清晰,这都会激发我们发现新的想法。如果一个功能的名字不对,唯一能做的明智事情就是改变它,以免过后阅读代码的人花了数不清的时间也弄不清代码在干什么。第十七章说到静态设计和动态设计的问题:重构非常关注于静态结构(类与接口),以至于很容易忽略应用程序动态结构(实例与线程)。有时我们需要退一步考虑,去画一个类似互动图那样的东西。其后的第十八章说道TDD应该和其它的建模手段结合起来使用,并且不要把其它的建模技巧当作一种目的,而是要理解它们,并且把它们当作支持与指导开发的一种手段。最后第十九章说我们必须知道如何渐进式的改变代码,尤其是使得代码结构良好,以至于我们可以根据需求的改变把代码带我们想去的地方。

  第四部分标题“可持续的测试驱动开发”,意为教大家如何提高测试的质量,以便让测试能够更及时、更好地提供关于设计缺陷的反馈。第二十章讲了几个知识点。当我们在测试驱动开发的过程中提取一个接口,我们就必须想出一个名字来描述刚刚发现的关系。我们觉得这使我们深入思考领域问题,并梳理出可能会错过的概念。只覆写可见的方法(即保护的和公有的)。这个规则防止了仅仅为了测试能覆写而暴露了内部方法。在谈到模仿对象(Mock Object)时,作者说有两个试探法可以决定一个类是不是像值类从而不值得去仿造它。第一,它的值是不可变的;第二,我们想不出来一个有意义的名字来描述把这个类当作接口之后,实现它的那个类。在对付膨胀的(参数过多的,过于复杂的)构造器时,作者提出可以提取出隐含的组件。其要寻找两个条件:经常在类里一起使用的参数;拥有相同生命期的参数。当我们刚好找到了这种情况时,就得努力想出一个好名字来解释这个概念。一个做得好的设计,其标志之一即是这种改变可以很容易集成进来。我们坚持依赖物应该通过构造器传入,但是被通知物和调整物可以设置为缺省值,稍后再行配置。当一个构造器太大了,并且我们不认为参数中隐含有一个新的类型时,我们可以用更多的缺省值,仅在有特殊的测试用例时才覆盖掉它们。在谈到期望陈述时,作者说要避免太多的期望。如果我们有很多期望,要么就是视图测试一个过于庞大的单位,要么就是锁定了太多的对象交流行为。作者还谈到了倾听测试给我们带来的四个好处:使领域知识局部化;将对象间的关系抽象并命名成类;通过定义类型和角色而带来更多的名字,就意味着带来更多的领域信息;与其提供数据,不如提供行为。本章最后总结说:测试驱动开发是低容忍度的。低质量的测试会使得开发速度变得非常慢,而且受测系统内部代码质量低的话,会导致测试的质量也跟着变低。

  第二十一章讲了测试的可读性问题。以下五种情况应当改进:1.测试名称没有清晰的描述出每个测试用例的重点,以及它和其余测试用例的差别;单一的测试用例执行了多个功能;测试用例间的结构差异很大,以至于读者不能通过速读来理解它们的意图;过多的设置和异常处理代码,将业务逻辑淹没其中;测试使用了不明其意的字面值(魔法数字)。在讲到如何命名测试时,作者介绍了TestDox约定法,即使每一个名字读起来像一个句子,其隐含主语即为测试目标,例如:A List holds items in the order they were added. A List can hold multiple references to the same item. A List throws an exception when removing an item it doesn’t hold.即可翻译成三个测试方法的名称:holdsItemsInTheOrderTheyWereAdded(),canHoldMultipleReferencesToTheSameItem(),throwsAnExceptionWhenRemovingAnItemItDoesntHold()。在变量的命名上,作者强调我们应该用能够显示这些值或者对象在测试中所扮演的角色以及他们同目标对象的关系的名字来命名。

  第二十二章讲了如何构建复杂的测试数据。测试数据构建器的一个好处是,我们可以写出易于阅读且便于发现错误的测试代码,因为每个构建器方法都指明了它的参数的意图。

  第二十三章讲述了如何从测试失败信息中演进工程代码。作者说,就算是发生在和我们现在所做无关的领域里面,未预期的测试失败,也可能是有价值的。因为它们揭示了代码中我们所未注意到的隐含关系。如果一个失败的测试清楚的解释了失败的东西和原因,我们就可以快速的排查并修正代码。同时作者建议,“经常同源代码库同步——可以到隔几分钟就一次的频度——以便一旦一个测试突然失败了,你不需要花费多长时间就能撤销最近的修改,并去尝试另一个方法。……比起一直查错,有时候回滚代码并以一个清晰的头脑重新思考“如何去写”,可能会更快。”

  第二十四章讲了测试的灵活性。如果一个对象因为有太多的依赖物或者其依赖物是隐藏的,从而很难从它的环境中解耦,那么当系统的某个偏远角落改变了,测试就会失败。

  最后的第五部分讲述了一些高级话题,第二十五章讲述了持久化。作者提出将影响持久化状态的测试孤立开来。将执行持久话操作的测试和针对被持久话对象进行的测试分开来做。并且提及一个小技巧:不要以模式来命名类或者接口,它们与系统其它类之间的关系才是重要的。当它们的工作方式改变时,这样做会使得名称具有误导性。第二十六章讲述了单元测试和线程。作者提出,并发是一个系统级别的关注点,我们应该在需要执行并发任务的对象“功能对象”之外来操控它。最后第二十七章讲述了测试异步代码的问题。作者指出,一个测试可以有两种方式观察系统:采样可被观测的状态或者监听它发出的事件。同时还指出了异步测试的一个注意点:测试可能会在系统之前运行以至于没有测试任何有用的东西。这会造成貌似正确的结果:错误的代码看起来好像能正常运行。还提出,采样测试与监听事件测试的一个明显区别是,轮询可能会错过被新近状态所覆盖掉的状态更新。解决办法是,可以查找记录。触发一个刺激事件,并等系统状态稳定再查询。作者又提出,我们经常采用一个命名方案去区分同步与断言。例如waitUntil()是等待某一个受测系统稳定(同步代码),而assertEventually()则是断言某个事件最终会发生。本章最后作者讲述了测试排期事件的问题。通过将事件排期机制从系统中解耦,可以使得系统的行为具有确定性从而更易测试事件。我们可以将事件的生成抽取成一个由外部驱动的共享服务。

  本书的跋很值得一读。写了整个jMock从构想,初创,演进到巩固的过程。起初是为了方便测试某一个对象内部的功能机制是否如我们预期,后来逐渐把关注点从参数的值转移到了对象之间的信息沟通上。现在jMock已经成为一个单元测试和验收测试中进行期望陈述与断言的常用库了。读者有必要熟悉并掌握它。最后的两个附录讲了jMock2库和Hamcrest匹配器的使用方法,如果对书前面的范例代码中的用法不太熟悉,可以参考。

  总的说来,在读此书的过程中我非常惊喜,发现尽管TDD有很多令人诟病的缺点,但是仍然有人和我想像一样,用不瘟不火的稳健心态来创造性的加入“培养”要素,以使得TDD对工程开发有更大的促进作用。测试是一个良好的反馈来源,可以真实的反映出我们在设计中考虑不周到之处,以及时督促我们改进产品代码的设计。要想让测试能够如此培养软件的开发,就必须着力于测试代码的先行性、正确性、可读性与灵活性。同时要注意用验收测试来催生新的迭代周期,在修改代码时不仅要运行单元测试,更要及时运行验收测试以获得更多回馈。我想信,用心于测试的努力,必能在产品代码的研发中产生加倍的回报。

本文为原创,如需转载请联系作者(Email eastarstormlee@gmail.com 微博 http://weibo.com/eastarlee