JUnit反模式

JUnit 的出现为开发人员带来了福音。遗憾的是,许多人仍然认为学会 JUnit API,编写几个测试,最后得到一个测试良好的应用程序就足够了。这种想法比不进行任何测试还要糟,因为这会导致对代码健康状态的误解。学习 JUnit 是测试中最容易的一部分。编写优秀的测试则是较困难的一个环节。本文将介绍一些常见的 JUnit 反模式,并说明如何解决它们。

两个月前,我和妻子决定在厨房里装上木镶板。这是我第一次装修房子,我带着一股盲目乐观主义精神,使用铁锤和钉子干起了装修。但这样做几乎是一场灾难,因为我用不好铁锤。最后,妻子不得不重新修整被我敲打得高低不平的镶板和出现的裂缝。

在装修卧室时,我认为已学到了一些经验教训,这次借来了岳父的气钉枪。仅用了装修厨房十分之一的时间,就装修完了卧室,但是气钉枪不能弥补我在其他方面的失误 —— 例如忘记了保持木板顶部的水平,切割木板时切错了位置,忘记检查木板,将有裂纹的木板钉了上去,等等。还出现了其他许多问题,这些问题幸好都被细心的妻子注意到了。通过此事,我认识到:气钉枪不如一个木匠。

JUnit:气钉枪式测试工具

我认为,JUnit 很像爸爸的气钉枪。JUnit 出现之前,测试不是不可能的,但是非常困难。事实上,它困难到了致使通常没有人愿意进行测试。即使进行测试,也仅仅是对那些看起来特别复杂或脆弱,以致人们有理由进行额外测试的那部分。

JUnit 就是专门解决此问题的工具。这里不可告人的秘密是,此现象致使许多编程人员实际上乐于 编写一些测试。这样就造成了编程人员编写测试,而客户期盼测试的情形。尽管仍有一些坚持者,但多数客户现在开始倾向于使用我们在测试领域的新霸主 JUnit。(有关热爱测试的更多信息,请参阅 参考资料)。

问题是,JUnit 不是万能药,它是一种名副其实的工具。像其他优秀的工具一样(JUnit 是最优秀的工具之一),JUnit 只做一件事情,并且能出色地完成,它提供一个用于执行测试的框架。具体表现在:

JUnit 提供一个用于编写测试的模板,该模板可以安装、执行和卸载。

它允许您在层次结构中组织测试。

它允许您自动而又方便地执行测试。

它减少了来自执行过程中的测试报告量,允许使用同一测试套件中的不同测试操作程序。

尽管 JUnit 功能强大,使用起来很简单,但是,它也存在许多不足之处,需要其他工具来填补这些缺陷。以下是 JUnit 无法做到的:

对被测试的单元自动生成测试。

提供覆盖条件。

编写了劣质的测试时进行提示。

阐明的观点

Robert Binder 编写了一本好书,名称为 Testing Object-Oriented Systems: Models, Patterns, and Tools。Binder 是一位少有的天才人物 —— 一个测试圣人。作为一本测试方面的参考资料,该书的价值是无法衡量的。Binder 在本书的开头再次谈及 Scott Meyers 的测试问题。这个问题就是为 Triangle 对象编写单元测试。

Java™ 技术实现了一个采用三个边长的构造函数。每一边各有一个 getters 和 setters。该技术实现有三种方法:isIsosceles()、isScalene() 和 isEquilateral(),其中每一种方法都可以返回 true 或 false,具体情况取决于三角形的配置。triangle 还是 Polygon 类型的一个子类, 后者由 Figure 类派生而来。Figure 是代表对象的抽象类,该类可以通过光栅显示描绘。现在面临的挑战是如何编写此类的测试。

Binder 从 Meyers 的原始程序解决方案中列出了 33 个测试,并提供了 32 个与面向对象的问题属性有密切关系的测试。所以现在一共有 65 个测试。除非是影响生命安全的重要软件,否则您可能从来不会如此详细地测试代码,也不会了解到原来它是如此测试的。原因不是您有生理缺陷或者懒惰。而是您没有受过测试方面的训练,还因为您将专用开发时间都消耗在了编程技巧上,而不是消耗在测试能力上。该怎么办呢?JUnit 可让测试变得简单易行。

反模式

本部分将介绍几个反模式,其中的错误现象是我们经常遇到的或易犯的。

愉快路径测试

愉快路径测试 可以验证被测系统的行为是否为所期望的行为。它们遵循每个正确的执行路径。在功能测试中,愉快路径与实际用例相同或相近。在单元测试中,它与实际用例相同或更小,因为单元服从于“单一职责原则”,您是测试它的单一职责。

实际上,愉快路径测试并不是一个反模式。反模式是指在进行愉快路径测试时开发程序的停止行为。愉快路径不测试系统的错误部分(不愉快路径)。编写代码时,通常考虑使用愉快路径进行编写。甚至在头脑中用一些愉快路径数据对它进行测试。边界条件将等待未测试的、范围之外数据,允许它们将您的应用程序带到其管辖范围之内。

假设您正在编写一个包含方法 eval 的 Factorial 类,该方法携带 int 并返回该 int 的阶乘。一个愉快路径测试会确认 Factorial.eval(3) 返回的是 6。此代码的实现不正确,但它仍返回正确的结果(误报),这种几率非常小:

public class Factorial {   public int eval(int _num) {     if (_num == 1) { return 1; }     return _num * eval(_num - 1);   }}

有些人会对此测试感到满意并继续操作,但是,请考虑下面这个实现:

public class Factorial {   public int eval(int _num) {     return 6;   }}

出现误报(false positive)会怎样呢?如果您从未接触过由测试驱动的开发(请参阅 参考资料),那么您可能也会认为人人都能编写如此头脑简单的实现。测试驱动的开发 (TDD) 中的一个练习就是首先编写测试,然后执行可能运行的最简单的操作 —— 如本例中的 return 6。

即使没有使用 TDD 方法执行操作,并在正确的实现中出现一个错误,您仍会得到误报。请考虑以下实现:

public class Factorial {   public int eval(int _num) {     if (_num == 1) { return 1; }     return _num + eval(_num - 1);   }}

除了数字的序列是相加的,而不是相乘的之外,这个算法与第一个算法几乎是相同的,对于值 3 和值 1(恰好出现这样一个值),返回的值是一样的,但是,对于其他任何值则会失败。关键是碰巧通过一个测试并不困难。

这就是为什么一定要进行两次以上的愉快路径测试。测试两次可以明显地减少一致通过的机率。尤其是测试值是 orthogonal (相互独立或没有关系)的情况下。例如,编写一个值为 3 和 5 的测试,将很就可以看出前面的两个实现是错误的。

确认测试和边界测试

还需要考虑其他两个测试类型:validity(或 domain)和 boundary。前者声明无效数据(或域外数据)的正确行为,后者是愉快路径测试的一种形式,但它声明实现在域的边界上可以正确地运行。

在这个示例中,请考虑在调用 Factorial.eval(-3) 时,将会发生什么情况。很有可能用尽堆栈空间,造成程序崩溃。当然 -3 不是一个有效的输入,所以使用它毫无意义。但是,在正确和错误之间还有一个中间方法,称为 IllegalArgumentException,演示如下:

public class Factorial {   public int eval(int _num) {     if (_num < 1) {       throw new IllegalArgumentException(         "Parameter must be greater than 0: " + _num);       }     if (_num == 1) { return 1; }     return _num * eval(_num - 1);   }}

编写了阶乘代码后,您可能发现该代码仍有错误。所以,让我们谈一下边界测试。如果存在一个边界,那么输入参数为 0,这是一个有效的输入,从数学上说,0 的阶乘是 1。执行前面的实现会导致测试失败,因为您希望的返回值是 1,但得到的却是 IllegalArgumentException。还应该检查边界的另一边 -1,以验证可以得到期望的 IllegalArgumentException,而不是一个整数。

对其他边界的相应测试将留做练习供您操练。提示:如果执行 Factorial.eval(100) 将会发生什么情况?

简单测试

与愉快路径反模式一样,简单测试反模式讲的不是关于“是什么”而是“不是 什么”。若开发人员没有经验,并且代码难以测试,则通常会出现这种症状。结果,您会看到对容易测试 (equals 和 toString 往往很突出,参见清单 1) 的内容进行多次的测试,而被测单元的真正逻辑却被忽略了。结果出现了许多不能检测系统的传递测试,这会导致对代码健康状态的误解。

清单 1. 一些容易测试的签名

testEqualsReflexive()testEqualsSymmetric()testEqualsTransitive()testEqualsOnNullParameter()testEqualsWorksMoreThanOnce()testEqualsFailsOnSubclass()testEqualsIsStillReflexive()

进行系统测试之所以困难,是因为您经常尝试测试某个方法,而不是检测某个装置。假设您要测试一个堆栈的实现,那么您的测试签名可能如清单 2 所示。

清单 2. 用于堆栈单元测试的可能测试签名

testPopHappyPath();testPopEmptyStack();testPushHappyPath();testPushFullStack();testPeek();

其中有些测试很容易,如清单 3 所示。

清单 3. 用于空堆栈的单元测试

public void testPopEmptyStack() {   Stack stackUT = new Stack();   assertEquals(0, stackUT.getSize());   try {     stackUT.pop();     fail("Expected StackUnderflowException");   } catch (StackUnderflowException _expected) {}}

但是,如何测试 push 的愉快路径呢?

清单 4. 用于 stack.push() 的元单测试

public void testPushHappyPath() {   Stack stackUT = new Stack();   Object item = new Object();   stackUT.push(item);   // now what?}

这是测试单元实现的常见错误,而不是单元与其客户机签定的契约。假设 push 方法的实现方式如下:

public class Stack {   private List elements;   ...   public void push(Object _element) {     elements.add(_element);   }}

您需要进行这一测试来验证 elements List 现在是否含有 push 添加的 Object。所以,您要编写如下测试:

public void testPushHappyPath() {   Stack stackUT = new Stack();   Object expectedElement = new Object();   stackUT.push(expectedElement);   List elements = stackUT.getElementsList();   assertEquals(1, elements.size());   assertEquals(expectedElement, elements.get(0));}

其中的问题是破坏了封装,原因是公开了被测单元的内幕。相反,要测试 push 是否将对象放入了列表,您应测试堆栈与客户机签定的契约。J.B. Rainsberger 将此称为测试装置 (fixture)。

现在,您的测试如清单 5 所示。

清单 5. 用于堆栈装置的单元测试

public void testPushPop() {   Stack stackUT = new Stack();   Object expectedElement = new Object();   assertEquals(expectedElement, stackUT.push(expectedElement).pop();   assertTrue(stackUT.isEmpty());}public void testFILO() {   Stack stackUT = new Stack();   Object expectedOne = new Object();   Object expectedTwo = new Object();   stackUT.push(expectedOne);   stackUT.push(expectedTwo);   assertEquals(expectedTwo, stackUT.pop());   assertEquals(expectedOne, stackUT.pop());   assertTrue(stackUT.isEmpty());}

您将不会再破坏封装,原因是您没有声明单元在封装中如何运行。相反,您充分利用了该装置显示的严密内聚性。拥有可以推动但不能弹出的堆栈没有任何意义,因此,您可以将这些方法作为堆栈暴露给其客户机的契约的一部分进行测试。

当编写代码时,应考虑到这个契约 —— 您将要编写的特定内容都将暴露给它的客户机,无论此内容是一个方法、一个类,还是一个与类交互的组。该契约是您要测试的一个内容,而不是实现细节。以这种形式进行测试将有助于该契约的形式化,使该契约更为明确并能够通过测试得到很好的定义,而不会处于不确定和非正式状态。

过度复杂的测试

当测试明显正确时,该测试通常会成功。如果测试很复杂,以致于不能立即断定它是否正确,那么您将无法知道该测试是否因为是错误的测试(甚至更糟的是不知道它是否正被错误地传递)而导致失败。当被测系统需要一个复杂的设置或暴露需要拆分的复杂数据结构时,通常会出现这种情况。

请考虑这样一个例子,在这个例子中有一个代码,该代码携带一些客户数据并将其写出,保存到一个有固定记录的文件中,以便在旧式系统中使用。您大概不会对记录是否为正确格式的测试感兴趣 —— 在这些方面,您已经进行了许多测试。您要测试的是记录中是否存在正确的数据。在这种情况下,很容易看到如清单 6 所示的测试。

清单 6. 过度复杂的测试

import java.io.ByteArrayOutputStream;import java.io.File;import java.io.FileInputStream;import junit.framework.TestCase;public class RecordTest extends TestCase {   public void testRecordContainsCorrectCustomerData() {     // setup     String expectedName = "Estragon";     int expectedId = 1001;     String [] expectedItemNames = {"A man", "A plan", "A canal", "Suez"};     Customer customer = new Customer(expectedId, expectedName, expectedItemNames);     // execute     BillingCenter.processCustomer(customer);     // assert results     File file = new File("customer.rec");     assertTrue(file.exists());     FileInputStream fis = new FileInputStream(file);     ByteArrayOutputStream baos = new ByteArrayOutputStream();     byte [] buffer = new byte[16];     int numRead;     while ((numRead = fis.read(buffer)) >= 0) {       baos.write(buffer, 0, numRead);     }     byte [] record = baos.toByteArray();     assertEquals(128, record.length); // exactly one record     String actualName = new String(record, 0, 15).trim();     assertEquals(expectedName, actualName);     int [] temp = new int[4];     temp[0] = record[15];     temp[1] = record[16];     temp[2] = record[17];     temp[3] = record[18];     int actualId = (temp[0] << 24) & (temp[1] << 16) & (temp[2] << 8) & temp[3];     assertEquals(expectedId, actualId);     int itemFieldLength = 16;     int itemFieldOffset = 19;     for(int i = 0; i < 4; ++i) {       String actualItemName = new String(record,           itemFieldOffset + itemFieldLength * i, itemFieldLength);       assertEquals(expectedItemNames[i], actualItemName.trim());     }   }}

唉呀!这里发生了什么?除了确认被测系统的正确性外,还可以将测试作为文档提供。它们应该充当系统正确行为的指示。这个测试的目的是为了显示:在使用适当填充的 Customer 对象调用 BillingCenter 对象上的静态 processCustomer 方法时,会导致一个适当格式的记录被写入 customer.rec 文件中。但此目标在执行测试所需的所有 I/O 文件、字节转换文件、字段偏移(field-offsetting)文件中是无法实现的。

测试代码可能比其要测试的代码更复杂。我不能保证这个测试是否正确,但是,我在这里将它写了出来。我们还要做其他一些事情。让我们进一步简化并抽象该测试,使其更像是一个测试(参见清单 7)。

清单 7. 一个简单的测试

public class RecordTestImproved extends TestCase {   public void testRecordContainsCorrectCustomerData() {     // setup     String expectedName = "Estragon";     int expectedId = 1001;     String [] expectedItemNames = {"A man", "A plan", "A canal", "Suez"};     Customer customer = new Customer(expectedId, expectedName);     // execute     BillingCenter.processCustomer(customer);     // assert results     RecordFileFacade records = new RecordFileFacade("customer.rec");     assertEquals(1, records.getTotalRecords());     RecordFacade record = records.get(0);     assertEquals(expectedName, record.getName());     assertEquals(expectedId, record.getId());     for(int i = 0; i < 4; ++i) {       assertEquals(expectedItemNames[i], record.getItemName(i));     }   }}

现在,测试代码清楚地表示出了该测试的意图。毫无疑问,此测试是正确的,因为它已经完成了设置预期值,调用被测试的系统,调用 getter 和作出声明。该逻辑被应用到了 RecordFileFacade 和 RecordFacade 类中。RecordFileFacade 负责从文件中读取数据,并成批将它们送入记录中。RecordFacade 负责解析每条记录,并通过 Java 语言友好测试方法公开这些这些数据。这个测试的另一个优点是 RecordFileFacade 和 RecordFacade 现在也能够测试。当拆分记录的逻辑保存在该测试中时,将无法对其进行测试。

最好将该逻辑应用到基础结构中。一个优秀的测试程序应当满足以下条件:

设置

声明预期结果

练习被测试的单元

获得实际结果

声明实际结果是否与预期结果相符

一个测试良好的应用程序不仅仅包含应用程序代码和测试。一定数量的基础结构代码可以充当测试程序与被测系统之间的适配器。此用途有两个:其一,可以允许测试清楚地表示其意图,其二,通过将复杂的代码抽象到独立层中,还能够为该层编写测试。

结束语

在许多方法中,使用 JUnit 进行测试更方便。测试编写代码越来越趋向于进行坏的测试和好的测试。但是,1,000 个坏的传递测试比不进行试测更糟糕,因为坏的测试会给您一个错误的自信意识。

编写测试时,一定要注意所编写测试的质量:

不要仅测试愉快路径,还要测试边界条件和范围之外的值。

不要测试实现,而是要测试装置。

不要使您的测试代码比被测代码更复杂。

总之,要通过不懈的努力来扩展您的测试技巧,使之成为专业开发的一部分。在测试工作方面,不要将全部精力都用在编程技巧上。

所以你不懂我的选择,也可以不懂我的难过,

JUnit反模式

相关文章:

你感兴趣的文章:

标签云: