原文地址:http://blog.stevensanderson.com/2009/08/24/writing-great-unit-tests-best-and-worst-practises/
这篇博客只针对那些至少有少量单元测试经验的开发员,如果你没有写过单元测试,请先读读这个介绍尝试一下。
好的单元测试和坏的单元测试有什么区别?你怎么学习怎么写一个好的单元测试? 这明显是很遥远的,即便你是一个天才且拥有数十年经验的程序员,你已有的知识和习惯也不会自动的让你写出一个好的单元测试来。因为这和写程序是不一样的编码,并且大部分的人对单元测试要达到的目标有一个没有任何帮助的假设。
我见过的大部分单元测试都是没意义的。我不是谴责开发员:通常来说,他或者她只是被命令开始去写单元测试,所以他安装NUnit然后就开始写测试方法。一旦他看到绿了,他们就假定做的很正确。这是错误的假定!这样非常容易产生一大堆对项目价值很小但是维护成本确极其高的单元测试。你认为这是敏捷吗?
单元测试不是用来寻找bug的
现在,我非常喜欢单元测试,但是只有在你知道单元测试在TDD中的承担的角色,并且没有任何用单元测试来发现bug的错误想法的时候。
以我的经验,单元测试不是一种有效的寻找bug的方法,也不是回归测试中的一种工具。单元测试,按字面意思,就是测试代码中的各个隔离的部分。但是当程序真实的运行的时候,所有的单元要一起工作,整个比把所有单元加起来更复杂。模块X和Y分别工作正常不能代表他们相互兼容或者配置正确。同时,各个独立模块里的bug也不一定会表现在最终用户面前。并且,因为你假设了你的单元测试的前提,它们不会检测到不在你的前提情况下的bug(比如,如果IHttpModule接口有异常的请求)。
所以,如果你想要找bug,把整个程序像产品环境下那样跑起来会有效的多,就像你做手动测试的时候一样。如果将来你把这些测试自动化了,那么它就叫做集成测试,这和单元测试用的是完全不一样的方法。对于这样不同的工作,你不想用更合适的工具来做吗?
目标 |
最好的方法 |
寻找bug(没按照我们没想要的方式工作) |
手动测试(有时候也用自动化的集成测试) |
回归测试(按照我们想要的方式工作但是有可能会出现不可预料问题) |
自动化的集成测试(有时候也用手工测试,看消耗的时间) |
软件模块设计(健壮性) |
单元测试(TDD 流程) |
(注意:在一种情况下单元测试可以有效的发现bug。那就是当重构代码,并且调整单元测试代码但是没有改变行为的时候。在这种情况下,单元测试通常能发现行为的变化。)
好了,如果单元测试不是用来找bug的,那么用来干什么的呢?
我打赌你肯定听这个答案超过一百次了,但是因为开发人员对单元测试有较深的误解,所以我还是要重复一遍。 就像TDD专家们一直重复说的:“TDD是一个设计流程,不是一个测试流程”一样。让我描述一下:TDD是一种交互式的设计软件模块(单元)的健壮方法,以使模块的行为能够按照单元测试指定的那样运行。
好的单元测试 vs不好的单元测试
TDD帮助你完成按行为设计的独立的模块。好的单元测试是有很大价值的:它记录了你的设计,使重构和扩展更加容易,同事保留对各个模块行为的清晰的概述。 可是,不好的单元测试也很让人不快:它没有清晰的证明任何事情,并且会极大的阻碍你重构或者修改代码。
你的单元测试处于下图中的那个位置?
TDD中创建的单元测试自然的处在最左边。它们包含大量单一单元的行为信息。如果单元的行为改变了,那么单元测试也要跟着变,反之亦然。但是他们不包含任何你的其他代码的假设,所以对其他部分代码的修改不会使单元测试失败(如果失败了,那说不它们不是真正的单元测试)。因此他们很容易维护,并且作为一种开发方法,TDD对任何规模的项目都是适用的。
在图中的另一端,集成测试不知道你的代码怎么被分成了模块,但是它描述了这个系统对用户的行为。它们很容易维护(因为不管你怎么重新组织内部系统,它都不会影响外在表现)并且保证当前系统的功能工作正常。
任何处于它们之间的,都对它要假定或者保证什么混淆不清。重构可能使测试失败,也可能不会,也不知道会不会影响最终用户的体验。改变外部的服务(比如升级DB)可能会也可能不会使测试失败,也许用户功能还继续工作着。任何很小的一个单元内的代码改变都有可能迫使你去修复上百个看起来一样的单元测试,所以他们会消耗大量的维护时间——有时候是实际写代码实际的10倍以上。并且它很令人沮丧,因为你之前做了的假设使这些测试即便通过也不能保证任何事情能够正常工作。
写好单元测试的建议
讨论完了,是时候提实际意见了。这是一些写处于图中极点A的单元测试的指导。
让每个单元测试与其他单元测试正交(独立)
任何行为必须在并且只能在一个单元测试中被表述,否则如果将来改变行为,你不得不更改多个单元测试.这些的原则包括:
o别做不必要的断言
你想要测试那个行为? 在其他测试中已经asserted过的东西会使单元测试适得其反: 它只会增加无意义的失败而不会提高单元测试的覆盖率。不必要的Verify()也是多余的——如果它不是单元测试的核心行为,那么停止做对他的观测!有时候,TDD拥护者说 – “每个单元测试里只能有一个逻辑”。
o一次只测试一个代码
你的架构必须支持测试独立单元(比如,单一类或者很少的几个类),而不是一起测试。否则,你的测试之间会有很多重叠,如果这样,对一个单元的改成会引起大范围的测试失败。
如果做不到这样,那么你的框架就限制了你的工作质量——考虑适用IOC吧。
oMOCK所有外部的服务和静态数据
否则外部的服务会在多个测试中出现重叠;而静态的数据意味着单元测试之间会产生影响。
如果你限定你的测试要按照一定顺序跑,或者它们只在数据库或者网络正常的时候才能工作,那么你当然做错了。
(顺便说一下,有时候你的框架有可能让你在跑单元测试的时候接触到静态数据。你应该尽量避免这种情况,但是如果没办法,至少要保证每个单元测试在跑前会重设相关的静态数据)
o避免不必要的先决条件
避免在一大推单元测试共享一段公共的setup代码,否则每个单元测试的假设就会不清楚,并且表明你不是测试一个单元。
一种异常情况:有时候你会发现很少量的几个单元测试共享一段代码真的很有用,那么你要确保所有的单元测试都需要这些先决条件。这是指定单元测试上下文的原则,但是如果大量测试共用一端setup代码,还是有可能使单元测试不好维护。
单元测试不能用来测试程序配置
从定义来说,你的配置不是任何单元测试的一部分(这就是为什么要把配置从单元测试里移出去)。甚至来说,如果你在写单元测试的时候检查你的配置,这仅仅是迫使你在一个多余的地方多指定了一次你的配置。祝贺你:你证明了你会复制和粘贴!
从个人的角度,我建议使用像ASP.NET MVC里的fitler自来的东西来做配置。[Authorize]或者 [RequiresSsl] 之类的filter被可选的配置在代码里。配置对集成测试有用,但是对单元测试没任何意义。它不帮助你做更好的设计,也不会检测任何程序问题。
保持你的单元测试命名清楚且一致
如果你想测试类ProductController的方法purchase在stock等于0的情况下的行为,那么你可能有一个固定的叫做PurchasingTests的测试类,而这个测试方法叫做ProductPurchaseAction_IfStockIsZero_RendersOutOfStockView().这个名字描述了测试目标(ProductController 的 Purchase方法),方案(stock is zero),和结果(renders “out of stock” view).我不知道这个命名规范有没有名字,但是我知道很多人遵守这个规则。不如我们叫它S/S/R吧?
不要写没有描述的单元测试名字,比如Purchase() 或者 OutOfStock()。如果你不知道单元测试要干什么,维护起来成本是很高的。
总结
不用质疑,单元测试会提高项目的质量。我们行业的很多人主张有任何的单元测试都比没有好,但是我不同意:一个测试可以有很大作用,也可以带来很多烦恼确贡献很少。这依赖于单元测试的质量,而这又依赖于开发员对单元测试的目标和理念能有多了解。
顺便提一下,如果你想学习集成测试(来补充你的单元测试技巧),推荐Watin,Selenium,或者我最近发布的ASP.NET MVC集成测试库.
附两篇国内的讨论: