我们经常为我们的业务代码写
测试用例,对吧?毫无疑问,大多数答案会落在“不错,但是你知道怎样避免它么?”和“当然,我喜欢测试”之间的某种状态。这里我将介绍一些小窍门,让你明白写好测试用例也是如此简单。这也将帮助你写更少的碎片化的测试,以确保你的应用更加强壮。
同时,如果你的答案是“不,我从来不写测试”,那我也希望这些简单有效的
技术让你看到写测试用例的好处,你也将会看到写出明确无价的测试集并不像你想的那样困难。
如何写测试用例和什么是管理测试套件的最好实践,如今是一个新的主题。
我们过去已经讨论了很多主题。从如何 在编译流程中正确地使用集成测试 ,到 如何在
单元测试中模拟测试环境 ,再到 代码覆盖率和如何找出实际需要测试的代码等 。
今天,我想给你一些新的思路,教你如何从低级到高级构建测试蓝图,组织测试的心理画像。从如何构造一个简单的单元测试用例,到更高层级的工具的应用等。比如: 你会明白模拟(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分析以检查到底发生了什么,那我们就没有办法知道。