一、为了单元测试而写单元测试
最近笔者曾经做过一次“程序员在项目开发中编写单元测试的情况”的调查。
调查结果显示:
1. 几乎没有严格在项目中执行TDD(,TDD)。
2. 为大部份业务方法编写单元测试,并保证方法测试通过,占16.6%。
3. 偶尔编写单元测试,一般情况下不写单元测试,占58.3%。
4. 为了应付项目检查而写单元测试,但并不保证方法是否测试通过, 占8.3%。
5. 从来不编写单元测试,占16.6%。
虽然调查的结果有一定的片面性,但是占58.3%比例的确高的惊人,同时,从来不编写单元测试16.6%人层也基本反映国内程序员编写单元测试的状况,很少有程序员能够比较认真地去编写单元测试。那么,到底又是什么原因导致程序员不编写单元的测试的?根据笔者参与的多个讨论,主要有下面几种原因使程序员不编写单元测试:
1. 为了完成编码任务,没有足够的时间编写单元测试。编写单元测试会导致不能按时完成编码任务,推迟项目进度。
2. 单元测试的价值不高,完全是浪费时间。
3. 业务逻辑比较简单,不值得编写单元测试。
4. 不知道怎么编写单元测试。
5. 项目没有要求,所以不编写。
6. 在项目的前期还是尽量去编写单元测试,但是越到项目的后期就越失控。
测试常常是程序员十分厌倦的一个项目活动。测试能够为我们带来什么?了解这些非常的重要,测试不可能保证一个程序是完全正确的,但是测试却可以增强我们对程序完整的信心,测试可以让我们相信程序做了我们期望它做的事情。测试能够使我们尽早地发现程序的bug和不足。
一个bug被隐藏的时间越长,修复这个bug的代价就越大。在《快速软件开发》一书中已引用了大量的研究数据指出:最后才修改一个bug的代价是在bug产生时修改它的代价的10倍。
在这里,我们需要讨论的重点是单元测试。单元测试是一个方法层级上的测试,单元测试也是最细粒度的测试。用于测试一个类的每一个方法都已经满足了方法的功能要求。
在现代软件开发过程中,不管是XP还是RUP都是十分重视单元测试。已经把单元测试作为贯穿整个开发周期的一项重要的开发活动。特别是在现代软件开发过程中,有经常集成和渐近提交的方法论。由此,总结出了非常好的单元测试理论和实践。
二、在编写代码之前先编写单元测试,即测试先行
单元测试是代码的一部份,所有的代码必须有单元测试,并使测试通过(像在Spring这些优秀的开源项目中在这方面做出了非常好的例子)。
在修改代码之前先修改单元测试,并使它测试通过。
在编写代码之前先编写单元测试,会带来非常多的好处。
在编写代码之前先编写单元测试,并不是编写代码之前需要一次性为所有的类都事先编写单元测试,这需要有一个粒度的控制。最大的粒度应该控制在一个类级别上,最合适的粒度是控制在一个方法级别上。先为某一个方法编写测试代码,然后再为该方法编写实现代码,直到其测试通过后再为另一个方法编写测试代码,如此循环。单元测试在这里已经是一个契约规范了,它规范了方法应该做什么、实现什么。测试代码远远要比难以阅读和不会及时更新的需求文档更有价值得多。
测试先行,鼓励对需求的理解。如果没有理解需求,你是不可能写出测试代码的,当然你也不可能写出好的实现代码。
测试代码与其它文档相比会更有价值。当需求发生改变,实现代码也相应改变。而往往需求文档、设计文档得不到及时更新。测试代码相比那些过期的文档更具有价值。
测试先行可以编写出最大覆盖率的测试代码。如果在方法的实现代码编写完后再编写测试代码,这时开发人员总是编写一个正确路径的测试代码。它已经很难全面的去分析其它分支逻辑。
如果我们采用测试先行,那么就自动地完成了为所有的类都编写测试。为所有的类都编写测试会将为你带来非常多的好处。
我们可以很好地使用自动化测试来测试所有的类,特别是采用日构建的系统。可以让我们放心地为类或方法添加新的功能。我们可以很容易地修改测试代码并验证修改后的代码是有用的代码。可以让我们放心地对代码进行重构和进行设计优化。
重构和设计优化通常会关联到多个类及多个方法。如果我们为所有的类都编写了测试,我们就可以在重构代码后很轻松地进行测试我们的修改是否正确。
为所有的类编写测试,可以让我们很容易地修改bug。当接到一个bug报告后,我们总是先修改测试代码,然后修改实现代码,使测试成功。这样不会因为修改一个问题而造成新问题的产生。
良好的单元测试策略给我们增强了对程序的信心,减少了bug的产生及bug的潜伏期,降低修改bug的代价。
单元测试不会是项目开发周期的某一个生命周期,它贯穿于项目的整个生命周期,是一个非常重要的日常开发活动。
三、程序员缘何拒绝编写单元测试?
我们已经知道了单元测试是多么重要的。为什么程序员仍然不编写单元测试呢?为什么程序员总是有理由拒绝编写单元测试呢?
1、 编写单元测试,增加了工作负担,会延缓项目进度?
这是笔者在多次讨论和调查中见到程序员拒绝编写单元测试的最多理由。“为了完成编码任务,没有足够的时间编写单元测试。编写单元测试会导致不能按时完成编码任务,推迟项目进度”。事实上真的是这样的吗?
软件有着其特殊的生命周期,软件开发也具有特殊性。
首先,我们需要提供给用户的至少是一个能运行的产品。绝对不能是一堆不能运行的和充满了“异味”的死代码。只有能够运行的,满足客户需求的代码才是真正有用的代码。这时,代码就变成产品了。
很多程序员只注重编写代码的完成时间,而乎略了调试代码,集成及修改和维护时间。
如果没有单元测试,开发活动会是这样情景。
以一个Web应用开发为例,流程大概如此:业务代码编写完成打包发布到服务器进行功能测试发现问题修改代码再打包……如此循环。
任何一个Web程序员对于这种开发情景都不会感到陌生。往往不断的打包、发布、功能测试的时间是代码编写的10倍以上。通过集成系统来发现程序的bug,我们往往很难一下子准确的定位bug产生的地方。应用服务器提供的错误信息对于我们来说是非常有限的。
如果为每一个类都编写单元测试并让每一个方法测试通过,又会是怎么样的开发情景呢?
编写测试代码编写业务代码运行测试方法修改代码让测试通过所有的类都通过测试打包发布到服务器进行功能测试发现bug修改测试代码修改业务代码测试通过再打包……如此循环。
从上面的过程显而易见,我们需要花费更多的编码时间。因为需要为每一个业务类编写测试代码。但是,它并不会导致我们总体需要花费更多的时间。我们只是可以非常轻松的在IDE环境中运行测试方法。
在代码尚未打包发布之前我们就已经确保了业务代码的正确性。当我们把所有通过测试的代码集成到应用服务器后,出现错误的机率要少得多。当集成测试后发现bug时,我们也总是先修改测试类。保证在集成之前所有的类都经过测试通过。这样,功能测试的时间就成数量级的减少,所以总的花费时间要比没有单元测试要少得多。
另外,如果没有单元测试,会经常出现一些低级的错误,如拼写错误、空指针异常等。就因为一个小小的拼写错误而需要重新打包、发布一次。如果有单元测试,就可以避免这些低级的错误。
如果没有单元测试,把代码集成到应用服务器后再发现错误时,我们往往更多的是凭借自己的经验来判断问题出在哪里。对于没有经验的程序员来说只能是撞运气了。这就像是瞎子走路一样,两眼一摸黑。如果每个类都有单元测试,就无需要这么痛苦了。
写到这里,使得我回想起当年做网络系统。当时的局域网络都是采用环状网络,还没有现在的交换机来组星形网络。环状网络的传输网络采用同轴细缆线,网络中的所有节点都在一条主干线上,网络的两端都会加上一个电阻来形成一个环(如下图)。
环状网络的最大的缺点就是当任意一个节点有固障时,整个网络都不能连通。维护这种网络是非常麻烦的。通常采用得比较多的方法就是“切香肠”法。把最后一个电阻取下来,接到第二台电脑的网络节点的末端,检查两条线是否能连通。连通后再把电阻取下来到第三台电脑的网络节点的末端,连上第三台电脑。这样来依次检查整个网络的线路。
后来就发展了星形网络,也是现在局域网普遍采用的。有一台交换机,每一台电脑连接到交换机,任意一个节点网络故障不会影响到其它节点,检查起来就非常方便了。没有单元测试的代码就像是环状网络,而有测试的代码就像星形网络。
其次,有可能我们第一次编写的代码是没有问题的,但是到后来需求改变而修改了其中某些类的代码,把它发布到了应用服务器去测试,所要修改的内容已经测试通过了。但是因为某些类的代码的修改导致了其它类不能正常的工作。这种bug往往隐藏得非常深,因为只要不触动它,它就不会出现。可能会程序发布到生产环境之后才会被业务人员发现。如果每个类都有测试代码,我们在打包之前运行所有测试代码,就可以很容易的发现因为代码修改带来的连带性错误。
最后,在离bug产生越近,修正bug就越容易;在bug产生越远,修正bug的代价就越昂贵。假设我们去集成一个星期(甚至更长时间)前编写的代码,当发现问题时,我们已经忘掉了很多重要的实现细节,所以修改变得困难重重。
编写单元测试,并不会加重程序员的负担,反而提高了程序员对程序的信心,大大的减少了重复打包、发布、纠错误的时间。这些花费的时间远远要比编写单元测试花费的时候多几个数量级。编写单元测试,可以让你更容易和更放心地去修改代码,增加功能从而加快了项目的开发进度。
为什么我们总是要主观的去认为编写单元测试会延缓项目进度呢?与其痛苦的挣扎,还不如去尝试一下好的实践。
2、业务逻辑简单,不值得编写单元测试
程序员是聪明的,程序员也总是自认为是聪明的。认为一些业务逻辑比较简单的类不必要编写单元测试。我们必须承认,需求不断变化,我们也必须要有勇气去接受需求变化。编写单元测试的另一个目的就是拥抱变化,而不是拒绝变化。编写单元测试就是提高了我们对程序的信心。
在敏捷软件开发中,代码为项目组所有成员所有,项目组的任何一个人都可以去修改任何一个代码文件。每当我要去修改一个别人编写的代码时,我总是多么的希望有程序的单元测试代码,往往都让我非常的失望。一般我都得花费很大的力气去猜想作者的原始意图。也许你会说:“你可以去看需求文档啊!你不会去看注释吗?”。但实际情况是,当需求文档完成了它的使命后,开发人员就把它扔到了一边了,文档总是过期的。没有几个项目组能够使得需求,设计这些文档与最新实现代码保持一致。所以去看一个过期的文档是没有价值的。注释也同样,保持最新仍然是一个最大的问题,并且注释能够提供的信息是非常有限的。所以我最需要的就是看测试代码了。测试代码最能反映出方法最新的功能契约。由代码的编写者去写的单元测试要比由其它人去编写的单元测试要更完善、更准确。
很多问题恰恰就出在一些我们认为简单的代码中。除非是像一个JavaBean的getter和setter方法,因为这些方法可以通过IDE自动代码生成,没有必要为它编写测试。
在项目开发中,我们需要经常通过重构来优化代码及改进我们的设计,当我们对代码进行重构之后,怎么能够保证代码仍然是正常的?那就是运行所有被修改的代码的测试。如果测试通过,则说明我们的重构是正确。
我们不能回避代码的维护问题。代码维护包括修正bug和增加功能。维护工作可能会距离代码编写完成有很长一段时间。当需要修改一个bug而修改了代码,或增加一个新的功能而修改了代码时,又怎么能够保证修改后的代码仍然是正确的和没有隐患的呢?
也许你会说,发布到应该服务器去测试就知道了。笔者曾经发生过因为维护而导致了更严重问题发生的情况。一个系统在生产环境正常运行很长时间了。某一天,业务人员要求修改某一个功能,笔者按业务的要求实现了要修改的功能,业务也测试了修改后的功能,然后发布到了生产环境。程序下发两个星期后,报了一个非常严重的生产问题上来,以前能够正常运行的功能突然有问题了,导致了大量的生产数据错误。这个问题是非常致命的,只能暂时停用系统。
最后我查明原因是,出错的模块与上次修改的代码有关联,上次修改时没有同时去修改现在出错的模块。要是我能够在修改代码后,运行所有的测试类,测试就肯定会报告不通过。也就不会把隐藏有这么严重错误的程序下发到生产环境去。
我们看看没有写单元测试是怎么进行集成的。如果某些结果与我们所期望的不一致时,我们可能会在程序中加上许多print语句,然后通过控制台来监视程序的运行过程。采用print语句并不能够保证我们的程序的正确性。最好的情况是,它只能保证一条正确的路径,不能保证其它的分支。另外当太多的print语句的信息在控制台上,也会让我们看不到想看到的信息——控制台的信息是有限的。在开发测试时,把调试信息打印在控制台还可以接受,但是在生产环境,如果还有调试信息出现在控制台,那是绝对不可以接受的。我们经常会忘记把调试的print语句及时的删除掉,从而影响程序的性能。最关键的是,print语句不能保证程序的正确性,也不能为你节省开发的时间。只会给你带来负面的影响。
3、不知道怎么编写单元测试
如果你相信单元测试的价值,那么去学习如何编写单元测试最终会让你获益的。
以Java开发为例,junit这样的单元测试组件是非常易于学习和使用的。其它语言也有类似的单元测试组件。要相信这将是简单和能为你带来价值的。笔者见过许多程序员编写单元测试,但是编写的单元测试完全没有起到它应有的作用,这也与不知道怎么编写单元测试有关。所以我们应该掌握一些编写单元测试的基本原则:
•为什么编写测试:虽然我们说为所有的代类都编写单元测试,但是测试JavaBean的setter或getter方法无异于是自寻烦恼。编写这样的测试完全是浪费时间,而且还增加了维护的困难。
•学会使用断言:断言就是让我们为方法设置一个期望值。当方法执行结果与期望值不一致时,测试组件就会报告测试不通过。我见过一些项目的单元测试不是使用断言,而是自己编写一个打印(println)工具类,可以详细的在控制台中打印出类的详细成员信息及集合的详细信息。
在单元测试中使用这个打印工具类来打印输出结果。这看起来好像非常不错。但是不应该使用这种方式来编写单元测试使用打印工具类,需要程序员自已从控制台去观察程序的执行结果。当输出信息非常多时,控制台信息是无法向上翻屏的。所以不能够给我们提供更多的信息。所以这种方法也不能用于自动化测试。
使用打印工具类造成了一种假像,测试报告我们的测试总是成功的!如果使用断言,当方法的执行结果与我们设置的期望值不一致时,则会详细的报告测试失败的情况。
使用打印工具来代替断言,造成测试的不充分,只会写出一个低测试覆盖率的测试。我们需要一个充分的测试。
•最大化测试覆盖率:我们除了测试一个正确的路径外,还需要测试方法的每一个分支逻辑。需要编写尽可能多的测试程序代码的测试。写一个充分的测试。
•避免重复的测试代码:测试类也是非常重要的,与应用代码一样。测试类包含的重复代码越多,测试类自身出现的错误也会越多。而我们需要做的编码工作也就越多。
• 不要依赖于测试方法的执行顺序:使用Junit来进行单元测试,它不能保证测试方法按照我们的意图的顺序来执行。当一个测试类有多个测试方法时,我们不能让一个测试方法必须在某一个测试之后执行才能成功。Junit不能为我们做这样的保证,我们不能依赖于测试方法的执行顺序。
•针对接口测试:我们有“针对接口编程”的OO设计原则。同样对于测试,我们也需要针对接口测试。也就是说在编写单元测试时,测试对象总是使用接口,而不是使用具体类。
4、项目没有要求,所以不编写单元测试
的确,在很多项目中团队并没有要求我们为每一个类编写单元测试,反而会要求我们编写很多复杂的文档。作为程序员我们需要明白:程序员是编写单元测试的最大受益者。
这不是项目经理的事,也不是QA的事,而是程序员自身的事,因为单元测试是程序代码的一部份。单元测试是最好的,最有价值的文档,它应该与代码一起交付给客户。
单元测试代码不是官僚、死板的文档。它是生动的,是程序员最有用的文档。单元测试能够提高程序员对程序的信心,能够使用养成良好的设计原则:“针对接口编程,而不是具体类”。因为要进行单元测试,所以我们需要让类独立于其依赖对象(使用Mock或stub)进行测试。这就迫使我们养成了良好的编程习惯。
单元测试是改进我们设计的保证。做为一个优秀的程序员,是会经常优化代码和设计,所以经常的进行重构。一个优秀的程序员绝对不能容忍“异味”代码,而单元测试就是我们进行重构的信心保证。
单元测试是一个日常开发活动,它贯穿于项目的整个生命周期。做一个负责任的程序员总是为自己的代码的质量负责的。是否经常改进你的设计,是否让别人很轻松的使用和修改你的代码。
为所有类编写单元测试应该是一个程序员应具有的素质。项目有没有要求,不应当成为不编写单元测试的理由。
5、为什么越在项目的后期,单元测试就越难以进行下去?
在很多项目的初期,项目中的大部分程序员都能够自觉的去编写单元测试。随着项目的进展,任务的加重,离交付时间越来越近,不能按时完成项目的风险越来越大,单元测试就往往成为牺牲品了。项目经理因为进度的压力也不重视了,程序员也因为编码的压力和无人看管而不再为代码编写单元测试了。
笔者所有亲历的项目都或多或少地有这么糟糕的情况发生。越是在项目的后期,能坚持编写单元测试的程序就在整个项目组中不会超过15%。
为了追赶进度,绝大多数程序员都把没有经过任何测试的代码提交到版本服务器,项目经理也不再追问,照单全收。这样做的结果就是在后期,集成花费的时间越来越多,几个技术骨干人员只得日夜加班进行系统集成。好不容易集成完了之后,下发给测试人员测试时,bug的报告成数量级的增长。程序员就日以继夜的修改bug.还有非常多的bug被隐藏更深,一直潜伏到生产环境去。在项目中,越来越多的人对项目失去信心,每一个人都在抱怨,数不清的bug,修正了一个bug,更多的bug报告上来。
每天都在修改bug,但是每天又会报告上更多的bug。于是开始有人想逃离了,有人请假,也有人离职。当项目总算结束时,每一个人的内心都清楚,项目太烂了,还有很多的错误还没被测试出来,赶快逃离这个项目组吧!一半的人病倒了,或对项目的维护失去了信心。
为什么会这样?有没有宣导测试的重要性呢?在项目初期应该进行宣导单元测试的重要性。
有没有做过相关的培训工作?在项目启动时,需要进行一些相关的培训,教授团队成员最基本的编写单元测试的技巧。
有没有做过相应的风险防范?越是工作资历越深的程序员,就越会拒绝编写单元测试,他们总是有太多的理由来拒绝编写单元测试。这些“顽固”的老程序员往往负责着核心的代码的编写。我们知道“20-80定律”吧。80%的错误是发生在20%的代码之中的,往往最严重的错误就发生在那些“老鸟”们的代码中。有没有在事先就做好风险防范,说服他们编写单元测试。
有没有做好测试相关的基础工作。有没有针对不同类型的程序编写测试基类,让编写测试变成一项非常简单的工作。有一些代码是依赖于特定的环境,如EJB访问、JNDI访问、Web应用程序依赖Servlet API等,而测试这些程序是非常困难的。应该编写一些测试基类和测试stub,让这些程序可以脱离于特定环境就像普通程序一样进行单元测试。让普通程序员轻松的编写测试代码进行程序测试。
可以实行日构建和测试覆盖率检查,没有通过测试的代码绝不允许放到版本服务器。检查测试的覆盖率。
在现代软件开发过程中,测试不再作为一个独立的生命周期。单元测试成为与编写代码同步进行的开发活动。单元测试能够提高程序员对程序的信心,保证程序的质量,加快软件开发速度,使程序易于维护。不管测试先行还是测试后行,没有单元测试那是绝对不行的。
弱者其找理由,强者找方法!今天你单元测试了吗?