多年以前,我曾经参与过一个政府项目的开发。一天傍晚,我和另一位同事正在这个政府机关的机房里做例行检查。一切看起来都运行正常,我们打算做完检查就下班回家。这时客户走了进来。
“网站上有一个数据显示得不太好看,你们能不能调一调?”他问我们。
看起来不是一件难事,那又何必要拖到明天呢?我们打开笔记本电脑,调出源代码。不过是改变一个数据的显示,三下五除二弄好了就部署上去,客户一定很开心,我们这样想着。但很快我们就发现事情没有那么简单:客户需要的数据实际上涉及到后台的一系列计算,而我们现有的领域对象并不能很容易地完成这样的计算。幸运的是,我这位同事素有“快手”美誉,写起代码来真是不含糊。几分钟功夫,他就修改了以前的领域对象,算出了客户想要的数据。
编译,打包,发布——倒霉。网站上出现了好几处古怪的数据,即使不运行QA测试脚本我也能看出,一定有什么东西被我们弄坏了。“没问题,”这位同事自信地说,“分分钟搞定。”只见他鼠标如飞,在Eclipse里打上几个断点,紧跟着就进入了调试视图。一行行代码被高亮显示,一串串上下文信息出现在检视窗口上。他的脸上露出胸有成竹的表情,那是高手宣告“一切尽在掌握”的表情。一个小问题,几分钟就可以搞定,只不过再一次展现高手的功力而已,不是吗?
三十分钟过去了,他的表情变得紧张。修改过的代码越来越多,而出现的问题似乎在随着代码的修改量成正比上升。“真的没问题吗?”身为初哥的我怯怯地问道。“放心。”简练有力的回答,然后是……又一个三十分钟。他的额头开始渗出密密的汗珠,而我已经跟不上他的思维了——我甚至怀疑他是否还清楚自己的思维,因为他在调试器中的操作越来越频繁地出错。噢,天啊,我们怎么会落到这步田地?
最终客户拯救了我们——虽然当他走进机房、诧异地说“你们怎么还没弄好”时我尴尬得无地自容,但当他说出“明天再来弄吧”我还是长舒了一口气。说来也怪,第二天我们轻轻松松就把这个修改完成了,前后真的只用了十分钟。谁知道呢,人都有赶上这寸劲的时候。
也许是这一次的经历让我印象太过深刻,从那以后,我一看见调试器就有点手发抖心发怵。这时读者要问了:你身为程序员有这种心理障碍,不就好像大厨握不住菜刀、侠客抓不紧宝剑么?你还怎么能吃这碗饭呢?原因是,自从那一次刻骨铭心的经历之后,我就一直坚持着……
测试驱动开发
这是个酷词,换成它的英文缩写(TDD)就更显得酷,所以人们都喜欢把它挂在嘴边,还喜欢把它说得倍儿复杂、倍儿深奥。其实在我看来,测试驱动开发这件事非常简单,总共就3步:
- 红:动手做任何一件事之前,先写一个测试来描述你将要做的事情;既然你还没有做这件事,所以测试无法通过,测试状态为红色。
- 绿:写尽可能简单的代码,让测试通过,状态条变成绿色。
- 扫扫地:刚才写的代码够不够漂亮?看着不爽的话就把它整理一下(更酷的说法叫“重构”),整理完之后测试状态应该仍然是绿色。
(有人跳起来喊了:难道这就是你的工作方式吗?别说没有做设计了,连实现都是“尽可能简单的”。你要怎么保证以后的可扩展性呢?
对于这个问题的标准答案是:以后的事以后再说。任何更高级更复杂的设计,你都有可能不需要它。用客户花钱的时间来做一个可能不需要的功能或者设计,说得重了就是缺乏职业道德。可不要忽视了简单的力量:越简单的代码越不会出错,越简单的代码越容易扩展。)
“一红,一绿,扫扫地”。这几年的程序人生,我就念着这句口诀,一小步一小步走了过来。奇妙得很,这一路上,还真是没怎么开过调试器。要知道个中缘由,得从一种传染性的心理疾病说起……
Test Infected
这是Erich Gamma大师发明的词,照我理解就是不问三七二十一,每天嘴里不停念叨着“一红,一绿,扫扫地”……的强迫症早期症状(例如像我这样^_^)。据我所知,很多人像我一样,主动选择患上“测试驱动强迫症”(很酷的缩写叫TDO),就是为了逃避“调试器焦虑综合症”(也有一个很酷的缩写叫DAS)。一项非官方调查数据显示,程序员每次打开调试器所需的时间平均是4分钟——“这不可能!”有人喊道,“我的调试器只要1秒钟就能跳出来。”没错,但你需要时间来设置断点、执行程序、找到适当的运行上下文、检视需要的变量……更要命的是,中间一个误操作就会让你这次调试付诸东流。所有这些让调试器成了一个相当耗费时间的工具。
(另一项调查数据显示,在没有实施TDD的情况下,程序员每小时平均打开调试器8次。啊哈……)
于是问题来了:我们为什么需要调试器?因为我们要知道程序运行中的状态。很好。然后下一个问题是:我们为什么要知道程序运行中的状态?呃……因为程序模块内部太复杂,以至于我们无法一目了然地看懂,不是吗?因为程序模块之间有太多的耦合,以至于一处修改很容易引发各处无法预知的变动,不是吗?承认吧,烂代码是需要调试的第一原因,并且我们都会写出烂代码。
而“测试驱动强迫症”的一大好处是它让你更难写出烂代码——因为你首先就很难给过于复杂、或者与别的模块有太多耦合的程序模块编写测试。还记得IoC模式吗?(对那些错过三年前的Spring热潮的读者说抱歉^_^)对于TDO患者而言这个模式——至少在使用Java时——简直是顺理成章、不言自明的,否则你怎么能把一堆纠结不清的对象摘得干干净净分别测试呢?至于复杂得难以一眼看清的方法,TDO患者们更是想都不会想要写出这种东西来,因为那意味着他们必须首先写出一个同样复杂(甚至更加复杂)的测试。谁会跟自己过不去呢?
直接的结果是,作为一种强迫症,TDO强迫你写出短小易懂、功能内聚、耦合松散的代码,并把你从DAS的痛苦解救出来。并且很多TDO患者曾经是DAS患者这一事实有效地促进了TDO对于代码质量的提升作用:这些人在遇到复杂的情况时,会优先选择把现有代码重构得清晰易懂,以尽量避免打开调试器。
TDO症状分析
(以下是对TDO患者病情的一些分析,我希望读者能理解一个强迫症患者的苦衷……)
复杂的操作——例如与外部系统之间的I/O——是人们打开调试器的一个理由,但这些操作恰恰是需要并且应该被良好封装起来的。如果不能有效地用mock对象或者测试沙箱来编写测试,你可能面临一个比DAS更严重的问题:你的系统很可能正在与当前使用的操作系统、甚至当前使用的这台机器紧密耦合。TDO强迫你在自己的系统和外部系统之间划清界限,这对于未来的部署和移植都有很大的帮助。
更复杂的操作——例如多线程和事务——是打开调试器的更强烈的理由。但有趣的是,这些操作往往很难调试(如果不是根本无法调试的话)。反倒是通过精心编写测试,你可以控制程序以你希望的方式精确运行,从而有效地诊断和解决问题——并保证同样的问题不再出现,因为测试会不断被运行。
很多编程语言——例如JavaScript、Ruby和PHP——不像Java那么容易调试。就拿JavaScript来说,要调试它需要用到Firebug之类华丽的工具。(顺便说一句,我愿意出10块钱跟任何人赌他不喜欢用Firebug来调试。)在这种情况下,你通常会倾向于把每个模块的复杂度都控制在可以用一条输出语句看清其内部状态的程度,并依靠测试来单独诊断每个模块。
TDO患者还倾向于让测试在失败时输出尽可能有意义的信息,例如“expected: 100, actual: 99”就比“false is not true”要好。(读者可以试着研究一下你的测试框架,看它在什么情况下会输出这两种失败信息,这有助于你了解TDO患者的心理状态。)
频繁运行所有测试是TDO患者的症状之一:如果不能每过15分钟就把所有测试都运行一遍并看到绿条,他们就会表现出轻度躁狂症状。出于对自己脆弱心理的保护,他们会小心地避免在测试中执行网络操作或数据库操作,其副作用是把对外部系统的依赖有效地封装起来,从而有效地减少需要打开调试器的机会。
当然在确实有必要的时候(这种情况极其罕见),TDO患者还是会打开调试器。不过正如前面说过的,很多TDO患者曾经是DAS患者,所以他们会让自己在DAS发作之前离开调试器,并通过编写更多的测试、做更多的重构来避免再次打开调试器。在DAS无可避免地即将发作之前——这大概是进入调试器5分钟之后——他们会撤销刚才所做的一切修改,从前一个绿条的状态开始重新做这一次的任务。
所有人都知道,如果没有麻烦,我们是不会打开调试器的。不过在一个TDO患者看来,如果打开调试器,那就意味着你真的有麻烦了。即便你——暂时地——还不是DAS患者,也许你也应该记住这个忠告。