http://www-128.ibm.com/developerworks/cn/java/j-jester/
用 Jester 对测试进行测试
测试套件有缺陷,这不是玩笑
|
|
级别: 初级
Elliotte Rusty Harold
, 副教授, Polytechnic University
2005 年 6 月 02 日
全面的单元测试套件对健壮的程序是必不可少的。但是如何才能保证测试套件测试了应当测试的每件事呢?Ivan Moore 的 JUnit 测试的测试器 Jester,擅长发现测试套件的问题,并提供对代码基本结构的深入观察。Elliotte Rusty Harold 介绍了 Jester 并展示如何使用它才能得到最佳结果。
测试先行的开发是极限编程(XP)中争议最少、采用最广泛的部分。到目前为止,大多数专业 Java 程序员都可能捕捉过测试 bug。(请参阅 参考资料 获得有关“被测试传染”的更多信息。) JUnit 是 Java 社区事实上的标准测试框架,没有经过全面的 JUnit 测试套件测试过的系统是不完整的。如果您的项目有全面的测试套件,那么恭喜您:您将制作出质量良好的、有利于工作的软件。但是大多数代码基础相当复杂。您能确定每个方法都被测试到、每个分支都进入过么?如果不能,那么当这些方法和分支在生产中执行的时候,应用程序会如何表现呢?
代码覆盖
对代码进行测试的下一步是用 代码覆盖 工具对测试进行度量。代码覆盖是一种查看一套测试覆盖了多少代码的方法。信心的获得,不仅需要知道测试了程序整体,还要知道每个方法在全部可能情况下都得到测试。传统情况下,这类度量的执行方法是在测试执行时对测试进行监视,可以通过 Java 虚拟机调试接口(JVMDI)或 Java 虚拟机工具接口 (JVMTI)进行,或者直接处理字节码。一次都没有执行过的语句是测试不到的。
Clover 和 EMMA(参阅 参考资料) 这类工具采用的这种方法对于发现测试不到的语句很有价值 —— 但是还不够。知道测试套件没有执行某个语句,可以证明该语句没测试到。但是,反过来不成立。如果执行了某一行代码,并不一定代表它得到测试。完全有可能存在这样的情况:测试并没有检查代码行是否生成正确结果。
当然,没有人会编写测试套件对每个语句的结果都进行验证。在众多的问题当中,这个问题可能会破坏封装。您可能认为,针对特定输入,只有方法中的每一行都操作正确,方法才会生成预期结果。但是这个假设并不合理。例如,如果没有测试到所有可能输入,也就没有测试到为处理边际情况而设计的代码,这时会如何呢?有可能还会测试到每行代码,但有可能遗漏真正的 bug。
|
并不简单
Jester 的方法并不简单。这个工具有可能会报告大量假阳性。例如,它可能把 System.out.println("Copyright 2005 Elliotte Rusty Harold") 语句改成 System.out.println("Copyright 3005 Elliotte Rusty Harold") ,然后报告没有破坏发生。但是,假阳性一般很容易过滤出来。另外,通常也有合适的理由怀疑像这个示例一样的情况是否真的是假阳性。例如,对于版权日期 3005 是否是测试套件应当通知的 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 方法(stringValue
和 numberValue
)直接调用。所以也可以用这两个方法来测试它:
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 对代码基进行测试,可以制造出更强壮的软件。
参考资料
|
|
关于作者