单元测试和测试驱动开发(TDD)杂谈

最近公司要求重新回顾单元测试的实际效果,作为一个开发经理,我个人对单元测试也有很多疑惑。就个人而言,我自己也写过很多单元测试,也鼓励程序员写单元测试,但实际效果似乎不尽如人意。因此,写了这篇短文,想和大家一起探讨。

1. 背景介绍

我所在的公司是一家外资软件公司,主要工作是开发一个复杂的在线系统(java based web applicaiton). 该系统的主要特点是:定制化程度比较高,业务逻辑相当复杂。系统的技术栈是Struts,EJB (JBoss)and Hibernate。我管理的小组一共有10个左右开发人员,6个左右测试人员,平均工作经验在3年以上。

公司在两年前开始推行单元测试。在开始推行单元测试之前,系统已经正式上线,也就是意味着有海量的没有单元测试的代码。推行之后,应该说投入了相当多的时 间,总共覆盖的行数有20k,其中行覆盖率(line coverage)有55%左右,分支覆盖率(branch coverage)有40%左右。我相信经过这么多尝试,应该说,我带的这个组不是一个单元测试的新手,有资格讨论单元测试的得失。

2. 实践中的问题和疑惑(开发人员怎么说?)

我一向主张:一项技术值不值得或者好不好用归根结底是要问实际的使用者和开发者的。作为一个经理,我不倾向于推行一项程序员极力反对的技术,不管这项技术是不是业界的标准或者是评论者的宠儿。一项技术必须要解决实际问题,也就是mark your life easier。所以下面是开发人员的回答。

2.1为什么需要单元测试和TDD (Test Driven Development)?

2.2.1 单元测试可以发现代码缺陷(Defect)么?投入/产出比(Defect count/Effort)是多少?

只能发现待测单元的缺陷,不能发现单元交互(集成)之间的缺陷。在实践过程中,很少有defect通过单元测试发现。

基本不能用于发现表现层(JSP,Java scripts,css,UI etc)的代码缺陷投入/产出比太高。换句话说,相比于单元测试,人工测试(munual testing)可以很大程度得更快更好的发现系统缺陷。

2.2.2单元测试可以用来防止Regression Defect么?

如果我们特地为某个regression defect加了相应的单元测试,那么单元测试在某种程度上可以防止regression defect的再一次出现。但是同样的,单元测试只能防止待测单元中的Regression Defect,而且需要通过猜测来加入相应的测试案例。

2.2.3 单元测试对设计有帮助么?

单元测试本身不一定能帮助设计。据说TTD可以帮助设计,实践过程中没有很深的体会。

2.2.4你投入了多少时间写单元测试?需要多少时间维护单元测试?

单元测试:代码= 2:1,也就是说一行代码需要两行单元测试。也有些人说1:1。

维护成本基本上决定于单元接口的变化频率:对于一些比较稳定的代码单元,维护成本还可以接受。但对于一些需求变化剧烈的单元,基本上需要重写。在实际实践 中,可能的比例为稳定的单元测试:重写的单元测试 = 80%:20%。但是这里有一个悖论:其实我们更希望单元测试可以用于验证(verify)核心单元的正确性,然而这些单元的测试单元确基本上需要重写。 这是为什么呢?其中一个可能的原因是:对于一个在线系统(web based application)来说,系统的主要逻辑和用户接口(user interface)绑定过于紧密,所以,用户接口的变化导致从表现层到数据库层的垂直变化。即使业务需求只是加了一个新的属性,但是这个数据将被加入核 心的对象当中,所有涉及这个对象的单元测试需要改变。

2.2.5单元测试的主要挑战是什么?

挑战之一:如何在多个测试用例之间共享测试数据。

公司产品支持一个很复杂的在线向导,由七步组成,每一步可以单独保存然后退出,下次继续编辑。如果你想测试最后一步的API,你需要准备很多其他页面的数 据。因此,需要花很多时间准备测试数据。另外,公司产品还支持相似功能的其他向导。作为一个程序员,我们一直想在多个类似功能的向导API之间共享测试数 据。然而,如果待测对象本身有些微变化,所有共享该数据的测试代码全部需要重写。这是一个巨大的维护费用。

挑战之二:剧烈的需求变化导致维护成本剧增,收益减少。

正如2.2.4中描述的,一个典型的在线系统(web based application),通常可以分为三层:表现层,主要是用户界面,包括HTML/JSP/CSS/Java Scripts/Ajex等等;业务层,主要是业务逻辑;数据层,存取数据。根据面向对象设计(OOD)的原则,业务层主要由一组领域对象(Business Object/Domain Object)构成。这些领域对象只提供一组数目相对有限的,接口比较清晰的,时间比较稳定的API。对这组API进行单元测试是有必要的,也是有意义的。

然而,系统还有相当一部分的代码用于调用不同领域对象之间的API,转变成表现层需要的对象。表现层其他的逻辑还包含大量的代码用于连接不同的页面,以及构建不同的向导。

正如和绝大多数的系统一样,产品需求的变化是极其剧烈的,可以预测的,不可避免的。在这种情况下,需求变化将导致领域对象API以上的代码包括绝大多数 表现层代码和一部分业务层代码)将发生剧烈变化,与之相应的单元测试代码都需要相应的改变。也就是说,这些代码的单元测试代码的维护成本很好。

挑战之三:海量的遗留代码(Legacy Codes)

正如前面描述的,我们是在产品已经上线之后才开始推行单元测试的。因此,大量的遗留代码并不适用于单元测试。换句话说,单元测试必须要在API实现之前予以仔细得考虑。如果API本身没有得到很好的设计,单元测试基本上是不可能的。

2.2.6 拿什么来衡量单元测试?

一般来说,业界使用行覆盖率(line coverage)和分支覆盖率(branch coverage)来衡量单元测试的测量。但在实际过程中,我们发现这些衡量标准和我们对单元测试的期望有很大差距:比如说,高覆盖率不见得较少的代码缺 陷。高覆盖率也不能防止regression缺陷。高覆盖率也似乎和设计没有直接关联。从另外一个角度说,达到高覆盖率所花费的时间也是相当惊人的。

从另外一个角度来说,我们希望找到一个方法可以简单直接地衡量单元测试的测量:比如说代码缺陷或regression defect数量

3. 聆听和讨论(业界怎么说)

带着这些问题和困惑,我在网上查询了大量相关资料,牛人的文章和业界的讨论。很容易看出,业界对于单元测试的目标,作用,方法和手段都有很多争议。

3.1 什么是(不是)单元测试?

3.1.1 单元测试和发现缺陷无关(http://blog.stevensanderson.com/2009/08/24/writing-great-unit-tests-best-and-worst-practises/)

摘要】单元测试不是一个发现缺陷或者检测regression defect的有效方法。其一,单元测试,根据定义,是用来测试特定代码单元。然而,一个系统往往是一个大量单元的复杂集成,单元测试很难发现集成的缺 陷。其二,相比单元测试,手工测试或者自动化集成测试更容易用于检测缺陷。

3.1.2一个测试不是单元测试 如果 -

数据库交互

需要跨越网络

需要访问文件系统

不能和其他的单元测试同时运行

需要配置文件才能运行

3.1.3测试驱动开发和验证(verification)无关,和规范(specification)相关

测试驱动开发并不意味着单元测试驱动的开发。实际上,测试驱动开发这个词非常容易引起误解。在TDD的大佬们眼中,单元测试只是测试驱动开发的工具,而不 是目的。测试驱动开发更关注这个代码单元应该如何运行(behave)而不是这个代码单元实现是否正确(verification)。之所以叫测试驱动开 发,意义及在于此。最近你可以看到一个新名字的兴起-行为驱动开发(BehavIoUr Driven Developemnt)。BDD更强调一个组件应该如何工作,以及寻找一个简易的方法来规范行为。

3.1.4为什么需要单元测试?

可以单独地测试每个单元

可以用于验证重构后的代码

可以用来确保不会破坏其他人的代码

可以用来提高系统设计,比如说,不能单元测试的代码将不会出现

3.2 对怎么样的代码做(不做)单元测试?

参考http://blog.stevensanderson.com/2009/11/04/selective-unit-testing-costs-and-benefits/

摘要】该文作者在总结3年的TDD实践经验的时候,反复认识一个情况:对于某些类型的代码,单元测试工作得很好,也极大的提高了待测代码的质量;但对另外一些类型的代码,单元测试耗费了大量的时间,并没有起到辅助设计和减少缺陷的作用,同时还导致代码更加难以维护。

基于这个想法,作者画了一张图来表示:

作者认为,

(1)代码本身很复杂但是对外部的依赖很少(左上),最适合单元测试,因为耗费较少和收益较多。一般来说,某种算法(排序),核心业务规则,数据解析类似的模块属于这样的代码

(2)带有很多依赖的琐碎代码(Trivial Code with many dependencies,右下):称为协调者(Coordinators),因为这些代码主要用于集成多个代码单元和安排代码单元之间的交互。这些代码 不适用于单元测试,因为耗费很高,收益却不大。

(3)代码很复杂而且外部依赖很多(右上):过于复杂的代码,需要重构。

(4)外部依赖较少的琐碎代码(左下):这些代码可测可不测,因为重要性不高,复杂度也不高,加单元测试的意义不是很大。

对于作者的这个想法,我比较同意。正如我在2.2.4和2.2.5中提到的,对于一个在线系统(web-based application)来说,可以分为内外两个圈:内圈由核心领域对象(Core Domain Object)构成,外圈由大量链接汇编代码构成。所谓核心领域对象,这些对象包含核心业务逻辑,数目相对较少,逻辑相对比较复杂,接口(API)相对比 较稳定,输入输出相对比较清晰,属于算法类代码,最合适单元测试,也需要单元测试来提高相应的质量。同时我想强调一下:这些对象一定要精心设计,精挑细 选,一定要满足数量少,接口稳定,输入输出清晰这三个要求。数量少意味着需要测的代码少,相应的单元测试数量也少。接口稳定意味着用户界面的变化(或需求 变化)对单元测试的影响较小。输入输出清晰意味着容易验证逻辑的正确性。相对的,外圈的代码主要用来链接多个领域对象,安排他们之间的交互,转换成用户界 面需要的数据结构。也就意味着外圈代码和需求紧密相连,微小的用户界面变化将会导致相应的代码代码,写单元测试得不偿失。

3.3单元测试最佳实践和测试驱动开发反模式

参考http://blog.stevensanderson.com/2009/08/24/writing-great-unit-tests-best-and-worst-practises/

(1)单元测试之间应该完全独立的

不要写不必要的断言,每次只测一个代码单元,模拟(Mock)外部依赖,避免不必要的前提条件。

(2)单元测试应该运行得很快(比如说少于5 min)

单元测试需要运行得很快,这样程序员才愿意经常运行单元测试。这就意味着(1)单元测试不要访问数据库(2)单元测试不要访问网络(3)外部依赖需要被模拟(mock)

(3)给单元测试一个清晰和一致的命名

一个单元测试的名字应该包含3项内容:待测对象_待测案例_期望结果,比如说ProductPurchaseAction_IfStockIsZero_RendersOutOfStockView()。

(4)常见的TDD反模式

撒谎者:所有测试案例都通过了,看上去是有效的。但如果靠近看,你会发现这些案例完全没有测试预期的内容

过度配置:一个单元测试需要一堆配置然后才能开始测试。有的时候需要几百行代码配置环境,设计数十个对象。

巨人:一个单元测试需要测试数千行代码,并且包含大量的测试用例。

无用者 :有的时候模拟是有效的方便的。但是其他一些时候,过多的模拟对象,Stub对象,假对象,导致单元测试主要在测模拟对象而不是实际的系统。

检察官:单元测试对待测代码非常了解,任何对待测代码的变化将影响单元测试代码

慷慨的富有者:多个单元测试共享测试数据,任何一个单元测试改动了一些数据,其他的测试全部失败了。

本地英雄:单元测试依赖于开发环境某些特定的配置。这意味着:在其他环境上运行单元测试将会失败。

采集者:单元测试验证所有的输出尽管它只对某些数据感兴趣。

狗仔队:单元测试依赖于特定的实现细节,比如说,单元测试捕获待测代码中抛出的每一个异常。

欺瞒者:单元测试验证了一大堆无关的细节,但从不测试核心行为。比如说,待测代码访问数据库并返回数据,单元测试验证每个返回的数据。

大声说话的人:单元测试输出一大堆诊断信息,日志信息,然而没有清晰的成功/失败标志。

please refer tohttp://tdd-antipatterns.net/index.php?title=Main_Page

4. 结论(前途在哪里)

现在,我想答案应该比较清楚了。

第一,重新强调单元测试和测试驱动开发的目地:通过写单元测试来澄清接口(帮助系统设计),确保核心代码(i.e.Domain Object)的正确性。它不是一个检测缺陷的有效工具,也不应该只用覆盖率来衡量单元测试的质量

第二,只对核心代码(比如说主要业务对象)进行单元测试。待测对象需要有相对有限的API数量,比较稳定的接口定义和清晰的输入输出。不要把宝贵的时间花在测试大量的非核心代码,比如说,链接(多个核心对象),协调(多个核心对象),和需求/用户接口紧密相关的代码

第三,不要滥用单元测试,请时刻关注最佳实践和TDD反模式。

第四,好的单元测试来源于好的设计。设计不好,代码不会好,单元测试也不会好。

最后,我还想继续研究行为驱动开发(BDD),看看BDD是否更实用,请继续关注。

相关文章

适配器模式将一个类的接口转换成客户期望的另一个接口,使得原本接口不兼容的类可以相互合作。
策略模式定义了一系列算法族,并封装在类中,它们之间可以互相替换,此模式让算法的变化独立于使用算法...
设计模式讲的是如何编写可扩展、可维护、可读的高质量代码,它是针对软件开发中经常遇到的一些设计问题...
模板方法模式在一个方法中定义一个算法的骨架,而将一些步骤延迟到子类中,使得子类可以在不改变算法结...
迭代器模式提供了一种方法,用于遍历集合对象中的元素,而又不暴露其内部的细节。
外观模式又叫门面模式,它提供了一个统一的(高层)接口,用来访问子系统中的一群接口,使得子系统更容...