单元测试是由开发人员在开发产品代码的同时进行的一种独立测试,验证开发的每一个代码单元。单元测试要覆盖到便捷值和正常输入,也要测试每个方法的出错条件和无效输入。可根据所开发代码类型来选择相应的自动化单元测试工具包括Junit,JunitEE,Dbunit,Rational Purecoverage和JTest等。
优秀的单元测试与糟糕的单元测试有何区别,如何才能够写出一个完美单元测试?即便是一个有着数十年经验的开发者,用已掌握的技能和形成的习惯都不能保证自己写的单元测试是完美的。原因之一是单元测试和普通的开发实践之间存在很大的差异。
一、单元测试的目的不是寻找BUG
我非常推崇单元测试,但前提是我们需要正确理解单元测试在“测试驱动开发”(Test Driven Development [TDD])中扮演的角色,并排除自己脑海中关于“单元测试有助于排除BUG”的错误想法。
经验告诉我,单元测试并不是发现BUG或是回归测试的有效方法。按照定义来看,单元测试是要对软件中的每个最小独立单元进行的测试。但是当你的软件在真实场景运行时,所有的独立单元都必须相互合作,这就导致软件整体的复杂度远远超过了单元测试用例数目的总和。能够证明X和Y模块能够独立工作,并不代表它们能与另外的模块合作无间;而且,用户反馈的问题往往看起来与单个模块的缺陷毫无关联;另外,你编写的测试用例并不能保证覆盖所有的情况,也就不能检测出所有可能发生的问题(例如,网络请求可能受到一些预料之外的干扰)。
所以,如果你的目的是发现BUG,那么更有效的做法是模拟生产环境并把软件整个运行一遍,就像常规的人工测试那样。如果你构建了一系列测试用例并让它们自动运行来暴露将来可能发生的问题,这又属于集成测试的范畴了,集成测试与单元测试涉及到不同的技术。下面是一些关于不同测试方法的使用建议:
注:关于单元测试,也会有一种例外的情况确实是为了发现BUG。比如在你重构某个模块的代码但并不打算改变该模块的原有行为时,使用单元测试可以保证模块的行为确实不会被改变。
那么,既然单元测试不是用来发现BUG,它的作用到底是什么呢?
关于这个问题的答案,我敢打赌你已经听说过不下上百次了,但由于我们大多数开发者脑中关于单元测试的错误观念相当顽固,我还是决定在这儿重复一下。就像TDD领域的专家说的:“TDD是一种开发流程,而不是一种测试流程”,我在这儿延伸一下:“TDD是一种交互式地设计健壮的软件模块或单元的方法,而且可以通过单元测试来验证这些模块或单元的行为是否符合预期”。
二、优秀的单元测试和糟糕的单元测试
TDD有助于让你交付符合预期的代码单元。优秀的单元测试具有极高的价值,它可以阐述你的设计、可以让重构变得容易,还可以让你在扩展代码之前对每个单元的行为有一个清晰的整体印象。
相反,糟糕的单元测试则是相当有害的,它并不能清楚地阐述任何事情,却会妨碍你的重构工作,并会时不时得向你抛出错误。
来看看在下面的坐标中,你的做法处于哪个位置?
如果是按照TDD的流程创建的单元测试,那么应该与坐标轴最左边的情况相符。如果代码单元的行为改变,那么单元测试必须随之改变,反之亦然。但是这些单元测试与别的代码是毫不相关的,所以其它代码的更改不应该导致单元测试不通过(如果你的测试代码无法通过,说明你做的并不是真正的单元测试)。这样测试代码的维护成本就会很低,这也是TDD能够作为一种开发技术被应用于各种规模的项目中的原因。
在坐标轴的另一端的是集成测试,集成测试不会关心代码单元层面的问题,它从用户的角度出发,考量的是系统整体的运作情况。集成测试的维护成本也是很低的,因为无论你怎样修改内部代码,最终展现给用户的功能是不应该变化的。
如果你处在坐标轴的中间位置,说明你并不清楚自己在作何假设、在尝试证明什么。在这种情况,任何一处微小的单元代码的变动都可能迫使你去修改上百个看起来无关的单元测试用例,你因此会耗费大量的时间,有时甚至会达到你正常修改代码所需时间的10倍以上!此外,为了让这些互相耦合的测试通过,你需要添加更多的前置条件,但这样到头来实际上证明不了任何事情,处在这样一个恶性循环中真是一件令人沮丧的事情。
三、关于编写单元测试的一些建议
理论性的探讨已经足够,是时候讨论一些实践层面的问题了。下面是一些能让你的单元测试处于之前所说的坐标轴的左边的建议。
1. 确保每个测试方法与其它所有的测试方法的关系是正交的(相对独立)
1.1 一个测试方法只能用来测试一种行为,也不能把一种行为分散到多个测试方法中,否则如果日后行为发生改变,就需要修改多个地方。
1.2 不要做不必要的断言(Assert),编写测试方法前一定要先搞清楚验证的行为是什么
a)滥用断言不会提高测试覆盖率
b)如果某个行为不属于测试方法验证的目标,就停止对其测试。关于这一点,TDD有个说法为:一个测试方法有且仅有一个断言。
c)要时刻谨记:单元测试的目的是为了验证方法的行为是否符合预期,而不是监视方法在各种情况的行为。
1.3 一次只测试一个代码单元
a) 你设计的软件架构必须支持对每个单元的独立测试(一个类或功能相关的几个类),不然单元测试之间会有重叠,在这种情况下,某处测试代码的微小改动可能造成数目庞大的级联修改。如果你的软件架构做不到支持单元测试,那么软件质量就无法得到保证,建议使用Inversion Of Control(控制反转)的思想进行重构。
b) 排除所有对外部服务和状态的依赖
引用外部服务会导致测试重叠,而对外部状态的依赖意味着单元测试在不同的情况会有不同的输出。如果你编写的单元测试必须按照一定的次序运行或者必须在数据库和网络就绪后才能运行,说明你已经走在了错误的道路上。(有时,单元测试代码可能会修改静态变量的值,尽量不要这样做,如果无法避免,至少应在测试结束后将修改过的变量复原)。避免添加前置条件;还要避免在独立的单元测试之前统一执行配置代码(原文为Setup Code),否则我们无法确定每个单元测试依赖的假设是什么,同时这也意味着你的单元测试思路出了问题。
c) 不要对配置代码做单元测试。
我们先来明确配置代码的定义。配置代码不是每个代码单元的公共部分。配置代码是可以复制粘贴的,我个人将ASP.NET MVC 中的 filter也归为配置代码的范畴,像[Authorize]或[RequiresSs]之类的标签则是掺杂在普通代码中的配置代码,对于这些代码,最好使用集成测试的方法,从外部观察它们的行为,使用单元测试是没有意义的,这对你的设计没有帮助,也无法帮您检测缺陷。
2. 采用清晰统一的命名规范
如果你是在测试ProductController控制器中的Purchase方法(Action)在库存为0时的行为,你可能会对应创建一个PurchasingTests类和一个名称为ProductPurchaseAction_IfStockIsZero_RendersOutOfStockView()的单元测试方法,这种命名方法同时给出了主题(ProductController控制器中的Purchase方法)、情景(库存为0)和结果(显示“库存为0”)。我不清楚这种命名方法是否已经有固定的名称,但我知道很多人采用这种命名方法,我们是否可以考虑将其称为”S/S/R”命名规范(Subject/Scenario/Result)...
尽量不要使用一些含混不清的命名,比如Purchase()或者OutOfStock(),这一类方法会很难维护,因为你根本无法搞清楚自己在维护什么。
总之,单元测试可以提升项目质量,这一点是毫无疑问的。但对于许多人声称的“做(单元测试)总比不做(单元测试)好”,我是不同意的,单元测试可能产生极高的价值,却也可能造成极大的负担,这都取决于单元测试的编写质量,取决于单元测试的编写者能否很好地理解单元测试的原则和目标。
软件测试热门文章推荐: