为什么极力支持测试驱动开发TDD

国内的一个很流行的博客上面讨论了TDD的一些问题,并且顺带批评了某咨询公司的咨询师不够脚踏实地。我在那个博客留言表达过我的不同意见,前几天随着另外一篇对TDD质疑的文章发表我和文章的作者在Twitter上论战了一番,不过论战是不能解决问题的(之前InfoQ上的虚拟讨论也没有解决问题),所以我再简单的整理一下我的意见。

首先,我声明我是一个笃信极限编程对『编程』有巨大价值的人。而TDD是极限编程XP里面的实践之一,在讨论XP的过程中我们一般都倾向于使用『实践(practice)』这个词,而不是『方法论(methodology)』,原因是极限编程描述的这些『方法』实际上是很多做事的具体方式,而不是一种『理论』。

我不应该咬文嚼字,不过有些时候这很重要。我需要从极限编程这个名字开始讨论这个问题。以前的一次OpenParty上,o6z问我『你知道极限编程为什么叫极限编程么?』,我当时真的不知道答案,只是隐约觉得极限编程的很多个体实践都是一种极限的挑战。而后o6z说『其实极限编程就是指程序员公认一些最佳实践,他们致力于不断的改进、优化这些最佳实践,最后把他们推向极致,这就叫极限编程』。这个答案可能是官方的,也可能是坊间流传的,不过我觉得它特别贴切,解释了极限编程的价值观。我当前公司的CEO上次调侃过『价值观』这个词,他说价值观说出来就不灵了,就成狗屁了……他说真正的价值观体现在你做每个决策的时候左右你的那种抽象的直觉。极限编程的每个写下来的『实践』其实就是写下来的价值观的体现,在实践的过程中最重要的就是不断的去磨练你的直觉,让价值观内在化。OK,极限编程我说道这里。

TDD,测试驱动开发。我们按照刚才阐述极限编程的涵义推演一下TDD是怎么来的。我们在写程序的时候发现不对程序进行细粒度的验证就很容易产生Bug,逐渐的整个编程社区有了一个最佳实践『单元测试』,我们也知道『单元测试』是相对于『集成测试』和『系统测试』的,我们提高程序内在质量的时候这些测试工具我们都要使用,只是有时其它测试会通过那种被叫做QA的程序员编写。为了不扯到另外一个话题,我们继续,当大家公认测试是最佳实践的时候,极限编程社区把测试推向极致。极致的测试应该具有细的粒度,高的覆盖率,有意义的验证条件,全面的边界条件,不脆弱等等,这些指标单个都不产生价值,但是在某个平衡的状态它具有最高的价值。极限的TDD的目的就是找到那个平衡。

我们退一步说,其实目前我们的程序员社区绝大部分人面临的都不是是否可以做好TDD,找到那个最佳的平衡点的问题。现在的主要问题还是是否可以写出有意义的测试,如何写测试的问题。其实质疑TDD的朋友经常的理由是『只要写好单元测试就可以了』,这其实正是我现在说的我们大多数程序员的困惑。也就是说『使用TDD』的对面是『能够写好单元测试但是不做TDD』,我认为这是一个伪命题。因为TDD的目的就是把测试这种最佳实践推向极限,这是一个过程,我可以把它分成两个阶段:

  • 第一个阶段是通过TDD强制从不写测试向写测试转变,因为绑定了写测试和写代码的节奏,它可以保证你写出的代码是可以测试的;
  • 第二阶段是通过不断实践和优化TDD让你能够写好测试。因为测试不只是有单元测试,还有系统测试和集成测试,随着对TDD的熟悉你会发现可以用不同层级的测试来驱动你的设计。使用越高层级的测试越有难度,这也是极限的一种体现。现在比较普遍的BDD其实就是将领域模型驱动DDD这种建模的方式与TDD结合的产物, Spec的描述形式让它不仅可以组织好单元测试,也可以组织好系统测试(如验收测试驱动设计,ATDD)。第二个阶段可以一直优化,永远没有极限,这个过程是最有价值的;

这种阶段化的实践其实内置了一些积极的意图:

  • 先写测试后写实现其实是把同时写测试和实现更近一步的产物。同时写测试的一种形容就是『可测试性驱动程序开发』,前几年老赵就写过这方面的博文,并且从可测试性上论证了TDD的积极意义。因为只有同时写测试和实现才能最好的保证你的代码是可测试的。而解决可测试性的难题的关键点是有可以检查验证条件(而不是足够细的粒度,细粒度既不是充分条件也不是必要条件,只是细粒度容易找到验证条件而已),也就是测试结果需要可验证。如果没有已知的可检查验证条件,那么可测试性就无法保证。所以先构思一个测试验证点再写代码是这个逻辑的体现。
  • 测试准备耗时费事,但是如果我们偷懒不做,那么后期可能面临的是完全无法做。保证测试容易准备需要在设计上多加考虑,如慎用Singleton等。这些思考可以影响你的设计,帮助你着力思考系统中的哪些状态是可变的,哪些是不变的,帮助你强化设计出无副作用或者少副作用的代码(因为有副作用的代码更难准备测试环境,环境的组合会多变),这帮助你函数化思考。
  • 红绿的节奏和小步前进可以帮助你减少对调试的依赖。调试和测试都是我们验证Bug的工具,不过我们最好在难以复现的场景使用调试这种终极武器,在编码阶段反复的进入调试说明你的测试有问题。编码阶段调试的常见原因就是测试没有跟上,因为我们知道最最常见的代码错误就是拼写错误。测试和调试找到拼写错误的代价是完全不同的。小步前进配合现代版本控制工具可以让我们完美的通过折半查找找到出问题的代码所在,如果有自动化测试套件的话折半查找还会事半功倍。这一条是说TDD的节奏所鼓励的小步前进的好处。
  • 测试驱动的测试需要有一个明确的名字。在寻找名字的过程你会重新思考这条验证的目的,让你整理需求的思路,也就是提醒你经常的问『为什么?』。细粒度的问为什么,并且为这些需求设计测试场景,这对每个想要贯彻『具体问题具体分析』的程序员非常重要。

这样的王婆卖瓜的理由我还有很多,多说无益。其实这里有一个关键的问题需要澄清,我们说TDD有这么多积极的意义,但是我们不能说『所有的代码都要TDD』,因为它很容易让TDD成为不现实的『生产力毒药』。当初比我经验丰富的一位同事就和我说『Spike(技术验证)的时候不需要TDD』,我发现在需要自由翱翔的时候放弃TDD的确是很舒服的事情,不过每当我们看这些Spike产生的代码时我们会发现TDD的重要性。因为这些 Spike出来的代码经常惨不忍睹。另外一位资深的同事就又和我说『Spike完成后,你应该删掉那些代码。然后重新TDD去实现它们,因为严格测试、精心设计过的代码才是为生产环境准备的』。我举的这个例子不能绝对化,不过我想表达的是,如果你想知道不做TDD的后果,那么一定要先做好TDD,回去对比观察没有TDD代码的不是。大部分关于TDD的批评大都来自那些还没有完全掌握TDD的人,工作流还没有很好的优化,此时对比『写单元测试』的自己就开始觉得TDD让自己混身不适了,『把最佳实践推向极限这个行为』要在完全掌握『最佳实践』的前提下才可以继续修炼。

我这里放一个比喻,我不知道是否贴切:有一种修行是爬看不到顶峰的山,视力可及的半山腰上有一片开满鲜花的平台。有些人爬到平台就下结论『爬到山顶也不过如此,也许还没有这么多鲜花呢』。但是另外一些人则继续攀登,以致山下都看不到他们的身影了。半山腰的人也许会开始质疑那些继续攀登的人的动机,说这完全是一种宗教。对于继续攀登的人来说,的确是一种信仰让他们坚持下去,那就是极限编程。关键的问题在于,对于那些山脚下的『沉默的大多数』人而言他们应该听谁的呢?是应该相信半山腰的人说『那些持续攀登的人走火入魔了,其实半山腰这里就最好了』?还是跟随那些持续攀登的人所走过的路走下去?其实,持续攀登的人会告诉所有山下的人你随时可以转身回到那个平台去,所以我们的行为是安全的,不过如果你持续攀登,那山上一定有一个更好的世界。有些人听了前者的话停留在山下徘徊,因为他们绝得那半山腰也不过如此,山下的日子很好过。但是有些人听了后者的话爬上了半山腰,还有一些也成为了后者。

这种比喻可以写的很华丽,不过这不是什么论据,它只是一种修辞而已。《思考的技术》这本书的第二章叫「逻辑打动人心」,我摘一些句子:

“但是”、“然而”这种话,对于改善经营而言,有白害而无一利

这是借口中常用的词,它会给出一个反面的评价,而后是中庸的『具体问题具体分析』。极限编程不是这样,它是单向的夸张,把最佳实践推向极限。所谓『写好单元测试就好了,要具体问题具体分析』其实是不写测试的一个好借口。如果你想让自己积极的去实践,请给自己一个极限的理由,当然,你随时都可以转身回到那个平台的。

如果给客户的药方,只是没有什么感觉的营养剂,客户的经营状况将无法改善

这是我想说明极限编程的极限的意义。这些推向极限的过程不是邪教,让你以为最后可以看到神迹而葬身途中,它的目的是给你一剂猛药,让你更好的走到那个平台,甚至到下一个层次上去(第一个平台的人兴许都不知道后面还有其它平台吧?)。

其实说到这里咨询公司的秘密也揭开了。咨询公司的医生不是包治百病的,他们的大部分都是希望给你一个最佳的『盗梦空间中的术语,植入想法,Inception』,期望这个植入可以帮助你向积极的方向前进,不过你的行为依然是你自己控制的。好的咨询公司不会利用这个机会『洗脑』,因为他们自己也在不断追求极致的过程中。这更像把酿造啤酒的技术推向极致的修士们(Trappist,非常著名的修道院啤酒的修士们通过几代人不断的优化酿造他们认为完美的啤酒,具有稳定而微妙的口味)所做的修行,他们自己在不断优化自己所做的事情,并通过咨询把这些Inception植入客户的思想。这里还要澄清一个问题,不是修道院中的所有修士都有崇高理想,有些新进来的修士不会酿酒,有些隐藏在修士中的南郭先生可能没有追求极致的精神,这非常正常,我们都是不完美的,我们的组织也不会完美,不过我们依然可以有追求完美的组织。

写到这里,我重复一下我在OpenParty的朋友中经常说的一句话『要把积极的影响施加给身边的朋友们』,我不像Cleverpig信仰巴哈依教,不过我坚持积极做人,积极影响人。极限编程和其中的TDD都出于同样的动机,施加给所有的程序员积极的愿望,不断的优化自己的工作流,以期达到最终的『极致』。谢谢观赏。

内文的一些链接我会稍后添加

后记:最近工作家里都忙,有了一个小公主需要伺候,所以没有太多时间更新Blog。我写博客不是让人围观的,而是写给自己和我所爱的家人朋友。我在Twitter上口水战还有写这篇博文主要是『质疑TDD和某咨询公司』与我的价值观冲突,所以我才不得不写这篇博文表达我的观点。我和我的朋友们还在努力组织好OpenParty的Unconference活动,我们的理想主义可以在这个活动上得到满足,我们给大家一个自由的分享与获取知识的机会,并且更重要的是我们要把积极的态度植入到参与活动的朋友的意识中,我想这是让我们生活更美好的最佳途径。

DDD重构初步

所做的系统是一个连接到外部信息发送和搜索引擎调用服务的Web前端系统。系统与外部的接口使用的是一层Service外观进行包装,原先的设计目的是使用服务层剥离对外部系统的强依赖──解耦,同时还希望使用Service将系统的商业逻辑集中存放──提高复用的可能。但是实际上我们发现Service这样的抽取方法并没有提高代码的复用度,反而造成数据结构和其算法的大量重复。经过分析,发现由于使用了大量的Hash结构存放接口间的返回结果,造成数据与其Service内部的行为分离,传递后商业逻辑就出现了重复(这样的结果是引入大量Quick but dirty的解决方案所欠下的技术债造成的)。所以我和我的Pair绝对对其进行DDD(领域模型驱动设计)的重构。

领域模型驱动设计和面向对象设计在代码的抽象上很相似,它们都推荐让数据结构(状态)与商业逻辑(行为)统一管理。这样数据的状态与行为就不会分离,这样可以很大的减少由于商业逻辑分散造成的代码重复。这可以让我们实现我们非常重视的DRY(Don’t repeat yourself)。
一开始我们尝试的是找到重复的商业逻辑和其对应的数据结构,然后尝试让它们映射到我们的领域模型。这样的效果还不错,但是经过了一天的工作,我们发现我们对领域模型的理解有问题,我们居然抽取出了重复的领域模型类。这个时候暴露的问题是我们对系统的领域模型没有统一和深入的认识。

所以下一步我们计划进行领域模型语言的讨论会,目的就是使用自己的“领域语言”描述系统的所有行为,从这样的领域故事中找到我们的领域模型(领域概念模型,不是具体编程的时候的类)。

领域模型讨论会最好由最熟悉领域模型的人起草,可以由这个人把它写在白板上。领域的故事最好能够涵盖系统的主要行为,描述要使用简练的语言。一般来说IT系统的内部行为可能很复杂,但是到了领域高度还是可以用比较简单的语言描述的,如果遇到一块白板不够的话最好首先考虑提高观察的高度,让领域的描述简练一些,其次再考虑扩展到第二块白板。在这个过程中我们要注意消除歧义,比如同样一个“术语”在两个功能区域中出现,那我们就要妥善的给他们各自取一个容易区分的“领域术语”作为名字。还有就是我们应该在写领域故事的时候考虑领域模型(或者理解为系统模块的抽象)之间的交互关系,最好在写领域故事的时候对行为的归属(它会指导行为到底会被建模在哪个领域模型中,可以体现为类的调用关系,哪个类持有交互逻辑)达成共识,这个时候达成的共识比在做具体的OO设计时候分辨商业逻辑归属要更体现商业价值。完成领域故事后我们要找到其中的所有领域模型(也就是前面说的领域对象和领域中的术语)。然后团队最好一起通读这个用户故事,一起讨论是否通顺(行为是否完备,抽象是否合理),是否有遗漏(遗失的领域模型或行为)。对于有外部系统的情况下,即使外部系统是面向消息的(或者说是没有使用领域模型驱动的SOA接口),那么团队最好对消息的内容有一个讨论,对其中设计到本系统和外不系统的领域模型进行认领,然后使用一个适配器来保证数据到达系统后就使用领域模型表示,外不系统的数据(一般没有行为)最好也使用一个领域模型进行约定。

完成了上面这一步后,DDD最重要的一部分就完成了,它可以实现系统领域模型自顶向下的“名正言顺”,减少在自底向上的重构过程中产生的大量重复领域模型。下面的重构过程就是给领域模型写测试,使用TDD(或者也可以用BDD的方式提早对行为做验收测试,用它们来驱动对领域模型的实现,其实对领域模型的TDD和BDD是殊路同归)的方式逐一实现领域模型。而后争取给系统写一些验收测试或者高级别的集成测试,再逐一替换这些领域模型。这样就达到了DDD重构的效果。

在Mac下启动多个Firefox实例方便JsUnit运行

项目中的JsUnit是使用ant脚本运行的,里面需要设置BROWSER_PATH的环境变量来启动浏览器。在本地check in代码的时候,我们会运行一下测试来减少愚蠢错误被提交到代码控制系统。但是在我的mac下Firefox只能启动一个实例,在运行JsUnit test的时候会提醒我已经打开了Firefox,不能打开另外一个实例,这样我必须关闭正在运行的Firefox。而且由于我比较喜欢打开非常多的Tabs来保持浏览状态,所以关闭Firefox让我很不爽,再说,因为重新启动的Firefox里面带了很多的Tabs,所以经常造成实际运行的JsUnit test发生随机性的超时错误,这个就不能容忍了,因为这无法保证我们的信心。

那么,为什么FF不能启动多个实例呢?原因是它们共享同一个Firefox的profile,所以没法多个实例并发访问。但是通过命令行参数是可以创建多个profile给firefox的,简单了。不过遇到的问题是JsUnit的ant任务会检测BROWSER_PATH是否存在,所以如果我把带参数的命令行写到环境变量里面Ant无法检测到这个文件就会报错。那么如果关闭检测可以么?还是不行。因为JsUnit的StandaloneTest里面实际最后会调用DefaultProcessStarter的execute方法,这个方法调用Runtime.getRuntime().exec(command),这个实现非常直接,不过因为parameters如果直接写到命令行里会发生文件无法找到的问题(应该用数组将命令和参数传入)所以没有办法传入,还是无法运行。

放弃hack吧,我可以修改Ant task和JsUnit的方法,但是绝对不好,因为这个hack没有提交回去的意义。
所以换个思路,这样做:我们去写个shell来解决它。

先在终端运行/Applications/Firefox.app/Contents/MacOS/firefox-bin -CreateProfile jsunit,这时候会弹出窗口让你确认创建这个profile,选择一下不使用extensions和各种工具条,这样减少这些设置对测试的不良影响。
然后在你的home目录创建一个firefox.sh,里面写上:

/Applications/Firefox.app/Contents/MacOS/firefox-bin -P jsunit $1

前提是你的Mac使用的是默认的bash,否则修改$1为对应的引用字符。然后chmod firefox.sh 555,让它可以运行。

下面就是修改你的~/.profile:

export BROWSER_PATH=/Users/[User path]/firefox.sh

source ~/.profile让修改生效再运行JsUnit就OK啦。如法炮制想开几个Firefox实例都可以啦。同样方法也适用于让Firefox2和Firefox3共同运行!非常简单。还可以做到开发和浏览分开……以此类推。

回顾一下JsUnit的代码写的不好,如果像Selenium一样能够自动创建一个profile就好了,因为那样可以减少测试之间的影响,还可以让Selenium并行执行。我想,如果有空我可以做一下这个工作:D

写javascript单元测试是挺爽的事,可惜不要在safari上?

对于javascript来说,通过单元测试,你也可以实现TDD。对你非常有好处,一是减少了js变动带来的代码退化问题,另外一方面是TDD可以改变你设计程序的方式。
举个简单的例子,写javascript很多情况下是和BOM(也就是文档模型)和DOM打交道的,这样可以说javascript程序很容易与dom高度耦合,这样的程序运行起来没有问题,但是应对需求变化的能力会比较地。但是根据人的思维方式,javascript很多时候是先出页面,然后根据页面逐渐调试着写出javascript,对于开发者来说,脑子里并不是真正的清楚自己要什么,而是在想界面的结果……这样产生的高耦合代码不容易测试,也难以面对多变的界面。
所以,换一种方式思考。如果用单元测试的方式去写,你就需要考虑程序的可测试性,这会细化你的程序的模块粒度,因为细粒度的抽象容易单元测试。同时由于js单元测试的页面是mock出来的,所以一般都会尽量的简单,这样会减少程序远对界面的依赖。同时由于界面的可测是性问题,也许会减少对element的style的修改,转而使用语义话的css。例如如果一个元素高亮,你可以element.style[‘font-size’]=’bold’;element.style.color=’red’l….,当然你也可以element.addClassName(‘highlight’),然后那些不好验证的界面的约束条件可以放到css里面去,放给可用性和用户验收测试去验证。这样的单元测试的验证条件(assertion)会简单很多,如果写过单元测试的朋友肯定会有感触的。
那么,常见的Javascript的单元测试框架有JsUnit和scrit.aculo.us的单元测试框架两个。前者方便用ant调用和分析结果,适合使用了ant的项目。而后者的优点就是界面好看,直接运行产生的报告清楚漂亮。所以小型项目我倾向后者,而大型项目我倾向前者。当然,由于js的动态特性,其实做个单元测试框架非常简单,所以自己动手也无妨。关键是要写,而且争取做到测试先行。

前面是个引子,其实写这个的原因是今天上午的一个郁闷的事。
前面的blog entry说道我升级了Firefox3,结果遇到了getElementsByClassName问题。但是今天换到另外一个项目组,没有用那个方法,程序也正常。可是我TDD的写一个新的feature的时候却发现可爱的JsUnit的testRunner在Firefox3里面无法工作,从firebug里面看到了一堆安全性问题。估计是firefox3的新安全模型造成的吧。那么,由于firefox3覆盖了firefox2,所以难道我没法写程序了?当然不可以,还有safari嘛。马上开始去写测试了,写好测试执行测试,发现红条。嗯,很满意,因为TDD的红-绿-红-绿的节奏就是这样的。然后我开始去写实现来满足这个测试……结果忙活了一上午就是不行……而且发现一些原来的测试也无法通过了……我仔细寻找问题,diff修改的内容,可是最后实在没有发现任何让它不能通过的原因,因为手工在firebug里面都已经验证了写的实现是没有问题的呀……崩溃。此时我突然想起来我们的持续集成服务器里面没有跑safari的JsUnit测试……也就是说不能确定在safari下全绿(此时的背景是我们的持续集成显示全部绿色,也就是说所有的测试都可以同过,包括windows和linux平台还有IE及Firefox),那么我可能衰了。马上开动camino(靠,一上午都忘记用它执行JsUnit了,因为我的习惯是Camino里面保存to read list),运行一下全绿。很兴奋,但是感觉刚才寻找问题的1个多小时被无辜的浪费了,心疼呀。

那么,请注意啦,我只是想提醒,jsUnit可能不能在safari下正常工作(大部分测试没有问题,少量在其它浏览器正常的测试在safari下无法工作),我用的是safari3.0.4……