我们经常为我们的业务代码写测试用例,对吧?毫无疑问,大多数答案会落在“不错,但是你知道怎样避免它么?”和“当然,我喜欢测试”之间的某种状态。这里我将介绍一些小窍门,让你明白写好测试用例也是如此简单。这也将帮助你写更少的碎片化的测试,以确保你的应用更加强壮。
同时,如果你的答案是“不,我从来不写测试”,那我也希望这些简单有效的技术让你看到写测试用例的好处,你也将会看到写出明确无价的测试集并不像你想的那样困难。
如何写测试用例和什么是管理测试套件的最好实践,如今是一个新的主题。
我们过去已经讨论了很多主题。从如何在编译流程中正确地使用集成测试,到如何在单元测试中模拟测试环境,再到代码覆盖率和如何找出实际需要测试的代码等。
今天,我想给你一些新的思路,教你如何从低级到高级构建测试蓝图,组织测试的心理画像。从如何构造一个简单的单元测试用例,到更高层级的工具的应用等。比如: 你会明白模拟(mock)、侦测(spy)和复制粘贴测试代码(copy-pasting 这里估计是指代码复用)等。让我们开始吧!
AAArrr, 听起来就像是海盗,对吧~~~
在大量的软件开发中,找到合适的设计模式来采用会是一个好的开端。你是否想通过工厂创建对象?亦或者是否需要把你的web应用分为模型,视图和控制器等模块?在这背后经常会有一种模式帮助你实现你的想法。那么,一个典型的测试模式应该看起来是什么样的呢?
在写测试代码时,一个最有效,也最简单的模式是“准备(Arrange)---动作(Act)---断言(Assert)”模型,也叫做 AAA.
这个模型的前提是:所有的测试应该遵循这个默认布局。被测系统的所有预置条件和输入应该在测试一开始就安排好。等所有前置条件确定后,我们就可以针对被测系统执行动作(Act)了, 比如执行一个方法或检查一些系统状态。最后,我们还需要对被测系统产生的结果进行检查(Assert)。
让我们看一个Java JUnit中使用该模式的测试用例:
@Test
public void testAddition() {
// Arrange
Calculator calculator = new Calculator();
// Act
int result = calculator.add(1, 2);
// Assert
assertEquals("Calculator.add returns invalid result", 3, result);
}
怎么样?这样的代码看起来不错吧?准备(Arrange),动作(Act),断言(Assert)模式可以让你马上明白这个测试用例正在做什么。
偏离这个模式可能会导致更加凌乱的代码结构。
请记住迪米特原则
迪米特原则是指各单元之间应该只使用最少的知识(或联系),以保持松耦合的状态。在软件开发中,迪米特原则总是一个设计目标。
迪米特原则可以被描述为以下一系列规则:
在一个方法中,一个类实例可以调用该类中的其它方法。
在一个方法中,一个实例可以查询它自己的数据,而不能是数据的数据。
当一个方法需要参数的时候,第一层的方法可以通过给定的参数被调用。
当一个方法实例化本地变量时,类实例可以调用这些本地变量的方法。
不要调用全局对象的方法。
那么,迪米特原则在测试中又意味着什么呢?这意味着你的应用更容易进行单元测试,因为迪米特原则的应用提升了你程序的松耦合度。为了说明该原则如何辅助单元测试,让我们来看看一个不符合该原则的例子:
考虑以下类,我们需要对它进行测试:
public class Foo() {
public Bar doSomething(Baz aParameter) {
Bar bar = null;
if (aParameter.getValue().isValid()) {
aParameter.getThing().increment();
bar = BarManager.getBar(new Thing());
}
return bar;
}
}
如果我们尝试测试该方法,因为该类的设计问题,我们将立即遇到一些麻烦。
在测试该方法的过程中,我们遇到的第一个困难是:我们调用了一个静态方法 --- BarManager.getBar()。该方法在单元测试的约束下是如何工作的?我们没有办法很容易地知道。还记得我们之前讲的"准备,动作,断言“3A模式吗?这里,在调用 doSomething()方法之前(act 动作),我们没有办法对 BarManager 进行配置(Arrange 准备)。如果 BarManager.getBar() 是非静态的,我们可以传递一个 BarManager 实例给 doSomething() 方法,那样也更容易在测试套件中传递统一的用例值,以对该方法的过程进行更好的和可预测的控制。
在这个方法中,也可以看到我们进行了一个方法链的调用:aParameter.getValue().isValid() 和 aParameter().getThing().increment(). 为了对它们进行测试,我们必须知道对象 aParameter.getValue() 和 aParameter.getThing() 的返回类型是什么?知道了返回类型,我们才可以在测试中构造合适的值对其进行测试。
如果我们要这样做(译者注:这里指的是构造合适的值),我们必须非常熟悉这些方法返回的对象,并且我们的单元测试将开始变成一大堆不可维护的脆弱代码。我们将打破单元测试的一个基本规则,那就是测试单个单元,而不是这些单元实现的细节。
我并不是说单元测试只能测试单个类,但是在大多数情况下,将类看作单个单元可能是一个好主意。然而,有时两个或更多的类可以被认为是一个单元。
我将把它留给读者作为练习,以便将这个方法完全重构为更容易测试的方法。但是对于初学者,我们可以将 aParmater.getValue() 对象作为参数传递到方法中。这将满足我们的一些设定,并使该方法更易于测试。
知道什么时候才使用断言
JUnit和TesgNG是两个非常优秀的测试框架,它们提供了丰富的断言方法,比如检查值是否相等或不等,是否为空等。
是的,我们也认为断言非常酷,那我们就随性地到处使用吧。且慢,且慢,过度使用断言会使得你的测试用例难以维护,我知道那个坑有多深... ...,它会导致应用不可测,不稳定。
请考虑以下测试方法:
@Test
public void testFoo {
// Arrange
Foo foo = new Foo();
double result = …;
// Act
double value = foo.bar( 100.0 );
// Assert
assertEquals(value, result);
assertNotNull( foo.getBar() );
assertTrue( foo.isValid() );
}
代码乍一看没有问题。我们遵循了AAA模式,也对将要发生的动作进行了正确的断言。这有什么错呢?
首先,从这个测试方法名字,testFoo,我们无法得到该测试的任何有效信息,它也不能匹配我们正在检查的任何断言。
那么,如果其中的一个断言失败,我们如何确定被测系统的哪一部分导致的失败呢?是我们的执行动作中 foo.bar(100.0)失败,还是foo.getBar()失败?亦或者是foo.isValid()失败?如果不通过对测试代码的深入debug分析以检查到底发生了什么,那我们就没有办法知道。
这样一个小小的不同就导致了我们单元测试目的的难以实现。我们本来是希望有一个可信赖的,强壮的测试集,以帮助我们了解我们应用的健康状态。可是现在,如果测试失败了,我们不得不祭出调试器来检查实际发生了什么,这不但没有减轻我们的负担,反而是增加负担了。
在一个测试用例中,只使用最少数量的断言,通常是一个比较好的实践做法。对于一个测试方法,最好只有一个断言。这可以确保我们的测试是独一无二的,而且只对应于我们应用的一个功能。
侦测、模拟和插桩
有时候,监控你的程序正在做什么,或者给定特定的参数多次对同一个方法进行调用,这些操作都是非常有实际意义的。有时候,我们想调用数据库层,但是又不想调用真实的数据库,只想模拟数据库的响应。以上所有这些功能都可以在侦测、模拟和插桩等工具的帮助下完成。
在 Java 中,有许多不同的库可以用来进行侦测、插桩和模拟等工作。比如 Mockito、EasyMock 和 JMockit。那么,侦测、模拟和插桩之间有什么不同呢?我们应该怎么使用它们呢?
侦测(spy)可以让你轻松地检查在应用程序中被调用的方法是否具有正确的参数,并能够记录这些参数,以便稍后进行验证。例如,如果你的代码里有一个循环,每次循环都会调用一次方法,那么侦测(spy)可以用来验证这个方法调用的次数是否正确、参数是否正确。间谍(spies)本质上是特定类型的插桩(stubs)。
插桩(stub)是一个对象,当被客户端调用时,提供特定的库存响应。也就是说,它们对输入有预定程序的响应。当您希望在一段代码中强制执行某个条件时,插桩(stub)将会非常有用,例如,当数据库调用失败时,你希望在测试中调用数据库异常处理。插桩(stubs)是模拟对象(mock objects)的一种特殊情况。
mock(模拟)对象提供了插桩的所有功能,还提供了预编程的期望。也就是说,一个mock对象能表现得更像一个真正的对象,可以基于以前设置的状态执行不同的操作。例如,我们可以使用mock对象来代表一个安全系统,它可以根据不同的用户登录提供了不同的访问控制。就我们的测试而言,mock对象将与真正的安全系统通信,并且我们将能够在应用程序中执行许多不同的路径。
在一些情况下,术语测试替身(Test Double)用于引用任何类型的对象,例如在上面描述的我们在测试中使用的对象。
通常来讲,侦测根据它的目标只提供一些最小功能,比如捕获方法是否被调用以及哪些参数被调用了。
插桩是测试替身的更高一级,它允许被测系统的工作流被详细设定,给定方法需要的预定义的返回值,我们就可以设定被测系统通过哪些分支。一个合适的插桩对象可以在许多测试中被使用。
最后,模拟对象比插桩提供了更丰富的行为。因此,最佳实践是:对特定的测试开发特定的插桩,否则,插桩对象就会慢慢变得跟真实对象一样复杂。
不要使你的测试代码太DRY
在软件开发中,遵循DRY原则通常是一个好的实践 --- (DRY: Don't Repeat Yourself, 拒绝重复).
但是在测试中,并非总是这样。当写代码时,你经常会提取频繁被调用到的代码片段到一个独立的方法中,然后你的代码基就可以重复调用这个方法了,这通常是一个好习惯。它对测试也是有意义的,只写一次代码我们也只需要对它测试一次就好了。另外,只写一次代码,也减少了我们重复写它们时可能产生的输入错误问题。一定要小心拷贝粘贴问题。
DAMP 背后的理论认为,一种好的领域专用语言(domain specific language/DSL)使用描述性和有意义的短语来提高语言的可读性,并减少学习和培训时间,从而变得高效。
通常,许多单元测试与套件中以前的测试非常相似,只是在被测试系统的安排上略有不同。因此,对于软件开发人员来说,将重复的代码从单元测试重构为更有用的函数是很自然的。将实例变量更改为静态变量也是很自然的,这样每个测试类只声明一次实例变量——再次从测试中删除重复。
虽然代码在经过这些类型重构之后会“更整洁”,但是单元测试将更难作为单独的部分来理解。如果一个单元测试调用了其它方法并使用了非局部变量,那么单元测试的流程将变得不那么线性,并且不那么容易理解单元测试的基本流程。
基本上,如果让我们的单元测试代码遵循DRY原则,测试代码的复杂性将会变得更严重和不可预测,它的维护性也将变得更困难 --- 相对地,使测试代码遵循DRY原则的目的是什么呢?对于单元测试,使它们遵循DAMP原则比DRY原则可以让你的代码更可读和可维护。
这里没有绝对的答案告诉你应该把你的测试代码重构到什么程度,但是在DRY和DAMP之间保持一个平衡将使得你的测试代码更容易维护。