用 Jester 对测试进行测试
 

2009-06-11 作者:Elliotte Rusty Harold 来源:IBM

 
本文内容包括:
全面的单元测试套件对健壮的程序是必不可少的。但是如何才能保证测试套件测试了应当测试的每件事呢?Ivan Moore 的 JUnit 测试的测试器 Jester,擅长发现测试套件的问题,并提供对代码基本结构的深入观察。Elliotte Rusty Harold 介绍了 Jester 并展示如何使用它才能得到最佳结果。

测试先行的开发是极限编程(XP)中争议最少、采用最广泛的部分。到目前为止,大多数专业 Java 程序员都可能捕捉过测试 bug。(请参阅 参考资料 获得有关“被测试传染”的更多信息。) JUnit 是 Java 社区事实上的标准测试框架,没有经过全面的 JUnit 测试套件测试过的系统是不完整的。如果您的项目有全面的测试套件,那么恭喜您:您将制作出质量良好的、有利于工作的软件。但是大多数代码基础相当复杂。您能确定每个方法都被测试到、每个分支都进入过么?如果不能,那么当这些方法和分支在生产中执行的时候,应用程序会如何表现呢?

代码覆盖

对代码进行测试的下一步是用 代码覆盖 工具对测试进行度量。代码覆盖是一种查看一套测试覆盖了多少代码的方法。信心的获得,不仅需要知道测试了程序整体,还要知道每个方法在全部可能情况下都得到测试。传统情况下,这类度量的执行方法是在测试执行时对测试进行监视,可以通过 Java 虚拟机调试接口(JVMDI)或 Java 虚拟机工具接口 (JVMTI)进行,或者直接处理字节码。一次都没有执行过的语句是测试不到的。

Clover 和 EMMA(参阅 参考资料) 这类工具采用的这种方法对于发现测试不到的语句很有价值 —— 但是还不够。知道测试套件没有执行某个语句,可以证明该语句没测试到。但是,反过来不成立。如果执行了某一行代码,并不一定代表它得到测试。完全有可能存在这样的情况:测试并没有检查代码行是否生成正确结果。

当然,没有人会编写测试套件对每个语句的结果都进行验证。在众多的问题当中,这个问题可能会破坏封装。您可能认为,针对特定输入,只有方法中的每一行都操作正确,方法才会生成预期结果。但是这个假设并不合理。例如,如果没有测试到所有可能输入,也就没有测试到为处理边际情况而设计的代码,这时会如何呢?有可能还会测试到每行代码,但有可能遗漏真正的 bug。

Jester 简介

这正是 Jester 发挥作用的地方。与 Clover 这类传统的代码覆盖工具不同,Jester 不去查看报告了哪行代码。相反,Jester 会修改源代码、重新编译源代码,然后运行测试套件,查看是否有什么事出错。例如,它会把 1 改成 2,或者把 if (x > y) 改成 if (false)。如果测试套件的关注不够,没有注意到修改,那么就说明遗漏了某项测试。

我将通过在开源的 Jaxen XPath 工具(参阅 参考资料)上应用 Jester 而对它进行演示。Jaxen 有一个基于 JUnit 的测试套件,而且这个套件的代码覆盖并不完善。

入门

在运行 Jester 之前,所有对没有修改的源代码的单元测试都必须测试通过。如果不是这样,那么 Jester 就无法知道是不是它的修改造成了破坏。(为了演示,我不得不修复一个 bug,我过去为它编写了测试用例,但是没有跟踪修复它。)

Jester 与 IDE 的集成不是特别好(或者根本不好),所以要让测试通过,重要的是正确设置 CLASSPATH 和目录。运行测试套件所需要的命令行对于每个项目都是不同的。因为 Jaxen 测试使用指向特定测试文件的相对 URL ,所以它的测试必须在 jaxen 目录中运行。下面是我最后运行 Jaxen 测试的方式:

$ java -classpath ../jester136/jester.jar:target/lib/junit-3.8.1.jar
:target/lib/dom4j-core-1.4-dev-8.jar:target/lib/jdom-b10.jar
:target/lib/xom-1.0d21.jar:target/test-classes:target/classes 
junit.textui.TestRunner org.jaxen.JaxenTests

在运行 Jester 之前,还需要清楚针对测试套件的一项附加限制。除非测试失败,否则不能打印有关 System.err 的任何内容。Jester 要通过检查打印的内容来判断测试是否成功,所以对 System.err 的程序输出会把 Jester 弄混。

测试套件运行无误之后,请做一份源代码树的拷贝。记住,Jester 要向代码故意加入 bug,所以您可不要冒险在出现问题的情况下遗漏一个 bug。(如果您在使用源代码控制,那么这不会是个大问题。如果没有,请暂停阅读本文,立即把代码签入 CVS 或 Subversion 仓库。)

运行 Jester

要运行 Jester,在路径中必须同时拥有 jester.jar 和 junit.jar(JUnit 没有和 Jester 绑在一起。需要分别下载)。Jester 在类路径中查找它的配置文件,所以必须还要把 Jester 的主目录放在类路径中。当然,还需要添加所测试的应用程序需要的其他 JAR。主类是 jester.TestTester。传递给这个程序的参数是测试应用程序的测试套件名称。(我不得不为 Jaxen 编写一个主类,因为它没有包含一个可以运行它的全部测试的类。)如果把全部必要的 JAR 文件和目录都添加到 CLASSPATH 环境变量,而不是把它们添加到 jre/lib/ext 或者用 -classpath 引用它们,那么 Jester 工作起来会更加稳定。下面是我针对 Jaxen 运行初始测试的方式:

$ export CLASSPATH=src2/java/main:../jester136/jester.jar:../jester136
:target/lib/junit-3.8.1.jar:target/lib/dom4j-core-1.4-dev-8.jar
:target/lib/jdom-b10.jar:target/lib/jdom-b10.jar:target/lib/xom-1.0d21.jar
:target/test-classes:target/classes
$ java  jester.TestTester org.jaxen.JaxenTests src2/java/main 

Jester 运行很慢,即使检测一个文件也是如此。它显示一个进度对话框,如图 1 所示,并在 System.out 上打印输出,让您知道它在做的工作,并向您保证它并没有完全挂起。

图 1. Jester 进度

如果在第一次运行若干分钟(或者时间足够运行完整的测试套件,甚至更长)之后,什么输出也没有看到,那么 Jester 可能 确实 挂起了,这很可能是因为类路径的问题。如果每件事都进行顺利,那么应当看到像清单 1 所示的输出:

清单 1. Jester 输出
 
				Use classpath: src2/java/main:../jester136/jester.jar
:../jester136:target/lib/junit-3.8.1.jar:target/lib/dom4j-core-1.4-dev-8.jar
:target/lib/jdom-b10.jar:target/lib/jdom-b10.jar:target/lib/xom-1.0d21.jar
:target/test-classes:target/classes
...
src2/java/main/org/jaxen/BaseXPath.java 
 - changed source on line 192 (char index=7757) from 1 to 2
             answer.size() == ?1 )
        {
            Object first = answ
src2/java/main/org/jaxen/BaseXPath.java 
 - changed source on line 691 (char index=24848) from 0 to 1
        return results.get( ?0 );
    }
}
lots more output...
src2/java/main/org/jaxen/BaseXPath.java 
 - changed source on line 691 (char index=24848) from 0 to 1
        return results.get( ?0 );
    }
}
10 mutations survived out of 11 changes. Score = 10
took 1 minutes

从清单 1 中可以看到,BaseXPath 没有得到很好的测试。Jester 对类进行了 11 项修改,而只有一项造成测试失败。有些修改是假阳性,但是 11 处修改肯定不应当只报告 1 处。

下一步是在不破坏测试套件的情况下查看 Jester 改变的代码,看看是否需要为它编写测试。Jester 在 GUI 中显示它进行的修改,如 图 1 所示(它不能在无人控制的情况下运行,这有点烦人),在控制台上打印输出,如 清单 1 所示,并生成 XML 文件,文件中是没有产生影响的修改列表,如清单 2 所示:

清单 2. Jester XML 输出
 
				<JesterReport>
<JestedFile fileName="src2/java/main/org/jaxen/BaseXPath.java" absolutePathFileName=
"/Users/elharo/Documents/articles/jester/jaxen/src2/java/main/org/jaxen/BaseXPath.java" 
numberOfChangesThatDidNotCauseTestsToFail="8" numberOfChanges="11" score="28">
<ChangeThatDidNotCauseTestsToFail index="7691" from="if (" to="if (true ||"/>
<ChangeThatDidNotCauseTestsToFail index="7691" from="if (" to="if (false &&"/>
<ChangeThatDidNotCauseTestsToFail index="7703" from="!=" to="=="/>
<ChangeThatDidNotCauseTestsToFail index="7754" from="==" to="!="/>
<ChangeThatDidNotCauseTestsToFail index="7757" from="1" to="2"/>
<ChangeThatDidNotCauseTestsToFail index="7826" from="if (" to="if (true ||"/>
<ChangeThatDidNotCauseTestsToFail index="7826" from="if (" to="if (false &&"/>
<ChangeThatDidNotCauseTestsToFail index="24749" from="if (" to="if (false &&"/>
</JestedFile></JesterReport>

Jester 的行号报告通常不是个好方法,所以最好是在控制台输出中查找修改的代码。下面是 清单 1 的报告中的修改:

src2/java/main/org/jaxen/BaseXPath.java 
 - changed source on line 691 (char index=24848) from 0 to 1
        return results.get( ?0 );
    }
}

在这个方法中,这个修改是在类的结束处:

protected Object selectSingleNodeForContext(Context context) throws JaxenException 
{
  List results = selectNodesForContext( context );
  if ( results.isEmpty() )
  {
    return null;
  }
        return results.get( 0 );
}

对测试套件迅速查找之后发现,实际上没有测试调用 selectSingleNodeForContext。所以下一步就是为这个方法编写一个测试。这个方法是 protected 的方法,所以测试不能直接调用它。有时需要编写一个子类(通常作为内部类)来测试 protected 的方法。但是在这个例子中,稍做一点检查就很快发现这个方法由同一个类中的两个 public 方法(stringValuenumberValue)直接调用。所以也可以用这两个方法来测试它:

    public void testSelectSingleNodeForContext() throws JaxenException {
        
        BaseXPath xpath = new BaseXPath("1 + 2");
        
        String stringValue = xpath.stringValueOf(xpath);
        assertEquals("3", stringValue);
        
        Number numberValue = xpath.numberValueOf(xpath);
        assertEquals(3, numberValue.doubleValue(), 0.00001);
        
    }

最后一步是运行测试用例,确定它通过。下面是结果:

java.lang.NullPointerException
	at org.jaxen.function.StringFunction.evaluate(StringFunction.java:121)
	at org.jaxen.BaseXPath.stringValueOf(BaseXPath.java:295)
	at org.jaxen.BaseXPathTest.testSelectSingleNodeForContext(BaseXPathTest.java:23)

Jester 捕捉到一个 bug!方法没有像预期的那样工作。更有趣的是,对 bug 的调查揭示出潜在的设计缺陷。BaseXPath 类可能更适合作为抽象类而不是具体类。我发誓,我并不是特意挑选这个示例来公开这个 bug。我从 BaseXPath 开始只是因为它是顶级 org.jaxen 包的第一个类,而且我选择 selectSingleNodeForContext 作为所测试的方法也只是因为它是 Jester 报告的最后一个错误。我真的认为这个方法没有什么问题,但是我错了。如果某些事没有经过测试,那么就应当假设它是有问题的。Jester 会告诉您出了什么问题。

下一步显而易见:修复 bug。(请确保同时对 Jester 正在处理的源树拷贝和实际树中的 bug 进行了修复。)然后,迭代 —— 针对这个类重新运行 Jester,直到任何修改都不能通过,或者可以通过的修改都是不相关的。在我为这个 bug 添加测试(并修复)之后,Jester 就报告 11 个修改中只有 8 个没有检测到,如 清单 2 所示。这在调试中是经常出现的事:修复了一个问题就修复(或者暴露了)另外几个。

Jester 的性能

因为 Jester 重新编译代码基,而且要为自己做的每个修改都重新运行测试套件,所以它的运行要比 Clover 这样的传统工具慢得多。因此,对性能加以关注是很重要的。可以用许多技术加快 Jester 的运行。

首先,如果编译在 Jester 执行时间中占了显著部分,那么请尝试使用一个更快的编译器。许多用户都报告采用 Jikes 代替 Javac 后速度有显著提高(参阅 参考资料)。可以在 Jester 主目录中的 jester.cfg 文件中修改 Jester 使用的编译命令。

第二,剖析和优化测试套件。一般情况下,人们对单元测试运行的速度没太注意,但是如果乘上 Jester 上千次执行测试套件的次数,那么任何节约都会非常显著。具体来说,要在测试套件中查找在正常代码中不会出现的问题。JUnit 会重新初始化每个执行方法的全部字段,所以如果不是测试类的每个方法都用的字段,那么把测试数据从字段中拿出来放在本地变量中,可以显著提高速度。如果形成的代码副本不合您的风格,请尝试把测试套件分成更小、更模块化的类,以便所有的初始数据可以在全部测试方法之间共享。

第三,重新组织测试套件的 suite 方法,以便最脆弱的测试(修改之后最有可能出错的)在不太脆弱的测试之前运行。只要 Jester 发现一个测试失败,就会终止运行,所以尽早失败可以短路大量耗时的额外测试。

第四,出于相似的原因,当测试失败的机会差不多时,把最快的测试放在第一位。按照大概的执行时间给测试排序。只在内存中执行的测试在访问磁盘的测试之前,访问磁盘的测试在访问 LAN 的测试之前,访问 LAN 的测试在访问 Internet 的测试之前。如果有些测试特别慢,试试去掉它们,即便这会增加假阳性的数量。在 XOM (一个用 Java 语言处理 XML 的 API)的测试套件中,在 50 个测试类中,只有很少的几个就占据了 90% 以上的执行时间。在测试的时候清除这些类可以带来 10 倍的性能提升。

最后,也是最重要的,就是不要一次测试整个代码基。每次把测试限制在一个类上,而且只运行能够暴露这个类的覆盖不足的测试。可能需要更长时间来测试每个类,但是用这种方法,几乎可以立即填补不足、修复 bug,而不必为 Jester 的一次运行完成等上好几天。

结束语

Jester 是聪明的程序员的工具包中一个重要的附加。它可以发现其他工具不能发现的代码覆盖不足,这会直接变成发现和修复 bug。使用 Jester 对代码基进行测试,可以制造出更强壮的软件。

参考资料


火龙果软件/UML软件工程组织致力于提高您的软件工程实践能力,我们不断地吸取业界的宝贵经验,向您提供经过数百家企业验证的有效的工程技术实践经验,同时关注最新的理论进展,帮助您“领跑您所在行业的软件世界”。
资源网站: UML软件工程组织