目前来看,推行TDD的障碍大约有如下几点:
1. 开发人员的质量意识;
2. 分析需求并进行任务分解的能力;
3. 将测试作为开发起点的开发习惯;
4. 开发人员的重构能力,包括如何识别坏味道和如何运用重构手法;
5. 单元测试的基础设施,尤其是测试数据准备;
开发人员对于软件质量,常常偏重于软件的外部质量,体现在他们的工作效益上,就是被测试人员发现的缺陷数。而惯常的软件开发思想,总是认为开发人员不适合做测试,因为他们总是站在自己的角度去看待问题,从而可能忽略真正需要测试的用例。这种思想给了开发人员一个错误信号,认为自己不应该写测试,即使写了测试,也写不好。殊不知,由开发人员编写测试带来的收益,最重要的一点不在于测试本身,而在于它能促进开发、测试以及需求分析人员的交流与沟通。而测试先行的方式也能让开发者跳出实现的窠臼,而从业务角度去看待问题,从消费者角度去思量接口。此外,由于开发者总是惫懒地将测试职责委派给了专门的测试人员,于是渐渐会产生一种依赖心理。测试人员的精确测试当然可以保障质量,但这种测试通常是黑盒测试,这里保障的质量主要还是外部质量。而且,这种测试带来的反馈总是慢于开发进度,一旦发现缺陷,修复缺陷的成本也会变得更高。
软件质量除了外部质量之外,内部质量同等重要。软件成本等于开发成本与维护成本之和,而维护成本的增加主要就归咎于内部质量的糟糕。这里讲的内部质量包括:代码的可读性、可重用性、可扩展性等。当我们让开发人员为原有代码编写单元测试时,总是觉得举步维艰。分析原因,主要问题在于代码的可测试性不好。要测试一个类,竟然连简单创建它的对象都变成了不可能完成的任务。我们为这样的代码编写单元测试,就好像在触及蜘蛛网,一旦被这些网丝给牵住,缠住,就可能无法摆脱。除非我们能够快刀斩乱麻,那对于这个系统而言,就不是维护,而是重写了。测试先行的开发至少在一定程度规避了这样的问题。即使代码的内部质量仍有所欠缺,但在足够覆盖率的保护下,我们要进行重构也变得更为简单。
然而,这些好处都不是短期能够见到成效的,且团队若不能达成共识,只靠一二人坚定地践行TDD,在测试覆盖率不够的情况下,改进仍然有限。多数开发者在维护别人的丑陋代码时,可能会骂声连连,殊不知同时作为骂者自身,其实也在重复被骂者的故事。
我不是说没有采用TDD,代码质量就一定不高;但我可以说采用了TDD,代码质量至少有了可以改进的基础。
需求分析能力常常是开发人员的短板。开发人员养成了一个习惯,看什么事情都会从技术实现的角度去思考。要实现一个网页,就会想到如何编写JavaScript来响应用户的动作,如何编写CSS,却不会去思考用户体验和操作的流程。要完成一个数据分析,总会想到数据的属性,转换和提取数据的算法,却不会想到分析数据的价值以及合理的流程。
而且对于繁琐的需求描述,我们总没有耐心去深入研读,而是会在掌握了大体意思后,就开始匆匆进行开发与实现。TDD要求我们在编写测试之前要做好合理的任务分解。若没有很好地理解需求,任务分解就无法顺利的进行。
这就带来了团队协作的问题。若我们能从需求的源头上进行改进,或许TDD会变得更容易。例如,我们对故事的拆分更合理,较好地遵循了User Story的INVEST原则,那么我们所要实现的Story在测试性、独立性方面都会有很好的改观。如果BA能够非常明确地编写出验收条件(Acceptance Cretiria),进行任务分解就变得更加容易了。
更进一步,若BA能够参考甚至遵循Specification By Example,并采用Given-When-Then的模式来描绘各个用例场景,再要进行任务分解,不就变得轻而易举吗?因此,有时候推行TDD非常艰难,或许最大的原因是我们仅仅将目光放到了开发者身上,而忽略了BA扮演的关键角色。正所谓:问渠那得清如许,为有源头活水来。
我一直强调任务分解是有层次的。分析需求时,不能一个猛子就扎进繁琐的实现细节。要从用户价值出发,先梳理出最外层的需求任务,然后抽丝剥茧,条分缕析地层层递进,如此方能理清思路,掌控复杂逻辑。基本上,任务分解可以分为三个层次,即业务价值——>业务功能——>业务实现。并且这个层次是一种“递归”的状态,视需求的复杂度而定。
再说说开发习惯的问题。这种改变显然不是一朝一夕可以完成的。以我个人的经验以及我所观察到的情况来看,固然是习惯的力量在作祟,然而主因还是因为对TDD方法的掌握程度以及一些误解导致。
前面已经述及,任务分解应该是TDD的起点。多数开发者未能形成任务分解的习惯。因此在改变为测试先行的时候,错以为应该一上来就写测试。因为思路没有理清,脑子里是一片乱麻,再加上本身对TDD不够熟悉,于是编写测试就变得举步维艰,总觉得束手束脚,就好像被绑了一只手,又好像是在泥沼中挣扎。许多时候,甚至发挥不出自己的哪怕三分的功力。
一贯以来,我们都在强调测试先行,测试先行……容易产生一种错觉,就是认为TDD必须一开始就写测试,“简单设计”嘛,于是就没有了设计。这让那些习惯于事先设计的开发者更难以接受。
以下是我对于“TDD是否需要事先设计”的个人观点:
Martin Fowler的文章Is Design Dead?其实就是对此问题的正本清源。我个人认为,视场景而定,测试驱动开发仍可进行事先设计。设计并不仅包含技术层面的设计如对OO思想乃至设计模式的运用,它本身还包括对需求的分析与建模。若不分析需求就开始编写测试,就好像没有搞清楚要去的地方,就开始快步前行,最后发现南辕北辙。测试驱动开发提倡的任务分解,实际上就是一种需求的分析。而如何寻找职责,以及识别职责的承担者则可以视为建模设计。测试驱动更像是一种培养设计专注力的手段,就像冥想者通过盘腿静坐的手段来体悟天地一样,测试驱动可以强迫你站在测试的角度(就是使用者的角度)去思考接口,如此才能设计出表现意图的接口。但编写测试自身并不能取代设计,正如盘腿静坐并不等于就是冥想。
在开始测试驱动开发之前,做适度的事先设计,还有利于我们仔细思考技术实现的解决方案。它与测试驱动接口的设计并不相悖。解决方案或许属于实现层面,若过早思考实现,会干扰我们对接口的判断;但完全不理会实现,又可能导致设计方向的走偏。举例来说,如果我们要实现XML消息到Java对象的转换。一种解决方案是通过jaxb将消息转换为Java对象,然后再定义转换映射的Transformer,通过硬编码或者反射的方式将其转换为相关的领域对象。然后在执行了业务操作后,再将返回的结果转换为另一个Jaxb对象。而另一种解决方案则是通过引入模板,例如StringTemplate或者Velocity,定义转换的模板,然后进行替换实现。这两种解决方案的区别,直接影响了我们划分任务的方式。所以在运用TDD时,先不要一巴掌拍死,可以先抱着开放的态度尝试尝试。何况,TDD并非一招鲜,吃遍天,总要有适合它的场景。例如UI的开发,交互协作的控制逻辑,数据库开发,并发处理,都不是运用TDD的太好场景。
TDD的核心是红——绿——重构。这意味着重构是TDD非常重要的一环,它直接关系到TDD开发出来的代码质量。没有好的重构能力,TDD就会有缺失。若说代码的内部质量是生命的话,重构就是灵魂,缺少了它,代码就没有灵性了。多数时候实施TDD,都会因为重构能力的缺乏而陷入困境。
重构的关键首先在于如何识别代码的坏味道。这需要代码阅读的千锤百炼,而非死记硬背老马总结的坏味道。当这些坏味道变成你的一种直觉,甚至就像与生俱来的一种能力时,你就会降低对糟糕代码的容忍度。在你眼中,这些烂代码就是垃圾,必须清扫,否则无法“安居”。
重构手法与代码坏味道一一对应。若有测试保障,重构就变得安全。但尽可能地,我们还是希望运用工具提供的自动重构功能,这既提高了重构效率,也在一定程度下确保了重构的安全。
当然,重要的是要找到重构的节奏感,即小步前行,每次重构必运行测试的良好习惯。若能结合分布式版本管理系统,做到原子提交,就会更加方便。即使重构出现问题,我也可以快速地回到前面的版本快照。
在TDD过程中,若能结对自然是上佳选择。当一个人在掌控键盘时,另一个人就可以重点关注代码的可读性,看看代码是否散发出臭味。两个人的眼睛终归要更锐利一些,至少视野的范围更广泛。
及时重构是重构诸多实践中最重要的一点。不要让重构成为你在未来偿还债务的杀手锏。越拖到后面,偿还债务的成本就越高。以重构而论,可能需要的重构能力就更强,因为重构变得复杂了。当然,只要你的代码能够保证足够的覆盖率,以及较好的松散耦合,重构依旧可行。采用TDD,基本能满足这两条要求。但以成本而论,小步前行才是重构之道。
最后说说单元测试的基本设施。很多时候,这可能不是问题;但很多时候,这可能会成为大问题。面对诸如测试数据准备等问题,需要认真分析,找到应对方案。原则上最好能找到一些开源的测试框架,包括生成测试数据,模拟测试行为等……多数情况下,这些开源框架都已经提供了。因为你遇到的问题,别人可能早已遇见过。这个世界上有很多聪明而又乐于分享的程序员,不要局限在自己公司一隅。睁大眼睛看看满世界吧。所谓“君子生非异也,善假于物也”。好程序员,也要这样。
说不定,你会抛弃TDD,因为你找到了更好的适合你的做法。