 | 级别: 初级 Dennis M. Sosnoski (dms@sosnoski.com), Java 和 XML 顾问, Sosnoski Software Solutions, Inc.
2005 年 2 月 08 日 为确保代码与规格说明相符,单元测试提供了一种很好的技术。但是单元测试的质量要取决于编写测试的人,而单元测试的结果是与测试的质量挂钩的。如何确保单元测试能具有满足要求的质量呢?在这个专门关于 classworking 工具的新系列的第一篇文章中,developerWorks 正式撰稿人 Dennis Sosnoski 将讨论代码覆盖工具如何为您的测试提供重要的质量检查。
本月将开始我的关于 Java classworking 工具的新系列。作为第一期文章,我将谈到两种相关的工具,即
Hansel 和 Gretel。这两种工具都可以解决代码覆盖(code coverage)问题,确定在应用程序执行期间哪个地方的代码在运行。虽然 Hansel 和 Gretel 是为截然不同的情景而设计的,但它们拥有一些独特而有趣的特性,使得它们不同于同类的其他工具。
看到它们的名字,您可能会估计 Hansel 和 Gretel 是相关的项目。但实际上 Gretel 出来得要早一些,而 Hansel 则是以 Gretel 的部分代码为基础开发出来的。在本文中我打算把顺序倒转过来,先通过 Hansel 来简单介绍一下代码覆盖的原理,然后对 Gretel 作一个简短的介绍,以展示如何在字节码级别上实现覆盖测试。
我主要从单元测试的角度来看代码覆盖。单元测试并不是惟一用得着代码覆盖工具的地方,但是代码覆盖工具主要还是用在这里。如果在测试期间没有执行某个地方的代码,那么这部分代码就可能隐藏着未被察觉的问题 —— 这正事单元测试极力防止的。代码覆盖工具使您可以将单元测试的符咒从“干净,绿色”上升为“干净,绿色,并且已覆盖”,这更有利于单元测试的有效性。
使用 Hansel
Hansel 有一个重要的特性,使得从它开始讨论覆盖工具十分合适 —— 它与用于单元测试的 JUnit framework 相集成,并允许您很容易地检查单元测试套件的覆盖情况。在讨论 Java 开发中的单元测试时,大多数人认为是 JUnit 使
Hansel 在单元测试中比独立的覆盖工具更具优势。
像通常一样测试
清单 1 给出了 StringArray
类的源代码,这个类是 String 有序数组的一个包装器。构造函数以一个数组为参数,并确保这个数组有序,无重复。此外,这个类还提供了一个 indexOf() 方法,该方法采用对分搜索法找出某个字符串在有序数组中的索引号,另外这个类还有两个简单的存取方法。
 |
下载代码
单击本文顶部或底端的 Code 图标(或者查看下载一节),下载本文中用到的代码实例。
|
|
清单 1. StringArray 类
public class StringArray
{
/** Ordered array of strings. */
private final String[] m_list;
/**
* Constructor from array of values. This checks the array
* values to make sure they're ordered and unique. If
* they're not, it sorts them and eliminates duplicates. Once
* the array has been passed to this constructor, it must
* not be modified by outside code.
*
* @param list array of values
*/
public StringArray(String[] list) {
// first make sure the array values are ordered
int dupl = 0;
if (list.length > 0) {
String last = list[0];
int index = 0;
while (++index < list.length) {
String comp = list[index];
int diff = last.compareTo(comp);
if (diff > 0) {
// disordered, sort values in array
Arrays.sort(list);
last = list[index];
// there's an error here! see "When
// coverage is not enough"
} else if (diff < 0) {
last = comp;
} else {
dupl++;
}
}
}
// eliminate duplicates if present
if (dupl > 0) {
String[] uniques = new String[list.length - dupl];
String last = uniques[0] = list[0];
int index = 0;
int fill = 1;
while (++index < list.length) {
if (!last.equals(list[index])) {
last = list[index];
uniques[fill++] = last;
}
}
m_list = uniques;
} else {
m_list = list;
}
}
/**
* Get string at a particular index in the list.
*
* @param index list index to be returned
* @return string at that index position
*/
public String get(int index) {
return m_list[index];
}
/**
* Find index of a particular string in the array. This does
* a binary search through the array values, using a pair of
* index bounds to track the subarray of possible matches at
* each iteration.
*
* @param value string to be found in list
* @return index of string in array or -1 if not present
*/
public int indexOf(String value) {
int base = 0;
int limit = m_list.length - 1;
while (base <= limit) {
int cur = (base + limit) >> 1;
int diff = value.compareTo(m_list[cur]);
if (diff < 0) {
limit = cur - 1;
} else if (diff > 0) {
base = cur + 1;
} else {
return cur;
}
}
return -1;
}
/**
* Get number of values in array
*
* @return number of values in array
*/
public int size() {
return m_list.length;
}
}
|
清单 2 给出了针对这个类的一个简单的 JUnit 测试用例,图 1 展示了我在 Eclipse 中运行这个测试时的结果:
清单 2. 针对 StringArray 类的 JUnit 测试
public class StringArrayTest extends TestCase
{
private static String[] s_list1 = {
"a", "b", "ccc", "ccd", "d", "e", "f", "g"
};
private static String[] s_list2 = { // s_list1 reordered
"a", "b", "ccd", "ccc", "g", "f", "e", "d"
};
public void testStringArray() {
StringArray array1 = new StringArray(s_list1);
assertEquals(8, array1.size());
StringArray array2 = new StringArray(s_list2);
assertEquals(8, array2.size());
}
public void testIndexOf() {
StringArray array = new StringArray(s_list1);
assertEquals(-1, array.indexOf("ee"));
assertEquals(4, array.indexOf("d"));
}
}
|
图 1. 运行最初的测试用例
使用覆盖进行测试
根据前面简单的单元测试用例,这块代码是“干净,绿色”的 —— 但只有在我真正彻底地测试这块代码的情况下这才有意义。为了看看上述测试在检验这块代码时效果如何,我可以用 Hansel 检查代码覆盖情况。
在 JUnit 测试中加入 Hansel 检查比较简单。只需将 Hansel JAR 文件添加到测试类路径中,然后用一个 Hansel 装饰器(一个包装另一个类的类,以修改被包装的类的行为)创建一个测试套件,这个装饰器引用被测试的一个或多个类。对于 StringArrayTest 这个例子,我可以将下面这个简单的方法添加到这个类中:
public static Test suite() {
return new CoverageDecorator(StringArrayTest.class,
new Class[] { StringArray.class });
}
|
添加了这个方法后,JUnit 测试的结果就截然不同了,如图 2 所示 —— 现在发现了 4 处 JUnit 测试失败!
图 2. 用 Hansel 覆盖检查运行测试
解释 Hansel 失败
在图 2 的测试运行中,Hansel 为它检测到的每个覆盖差错创建一个测试失败。如果您正在使用提供 JUnit 集成的 IDE(例如用于这些测试的 Eclipse),那么这种方法是显示结果的一种好方法,因为它允许您检查失败跟踪行,并立即查看所涉及的源代码。在图 2 中,我对报告的第一个失败采用了这种方法,报告的这个失败是一个没有完全覆盖的分支。清单 3 展示了详细的失败消息,以及失败消息所指的代码片断:
清单 3. 第一个失败
Coverage failure: Branch not completely covered. Condition
'list.length <= 0' is not fulfilled.
at com.sosnoski.demo.StringArray.<init>(StringArray.java:29)
// first make sure the array values are ordered
int dupl = 0;
if (list.length > 0) {
String last = list[0];
int index = 0;
|
这个失败说,我没有用长度为零的数组测试该代码。这个错误很容易修正,因此我添加了一个简短的测试,然后将目光转移到接下来的失败,如清单 4 所示:
清单 4. 第二个失败
Probe in "StringArray.java" line 41
Coverage failure: Branch not completely covered. Condition
'fill >= 0' is not fulfilled.
at com.sosnoski.demo.StringArray.<init>(StringArray.java:41)
if (diff > 0) {
// disordered, sort values in array
Arrays.sort(list);
last = list[index];
// there's an error here! see "When
// coverage is not enough"
} else if (diff < 0) {
last = comp;
} else {
dupl++;
}
|
这一次,要将失败消息与代码关联起来要更加困难了一点。首先,变量名与源代码不一致。这个结果看来是 Hansel 在解释类文件中局部变量信息时犯的一个错误。这里的代码使用了一个局部变量 diff,这个局部变量只定义在这一块代码范围内,之后在构造函数中又声明了另一个相同级别的变量 fill。看来 Hansel 将这两个变量的名称搞混了,因为它们使用相同的栈帧槽(stack frame slot)。这有点烦人,但是如果您已经知道这是怎么回事,您也就不会再困惑了,因为您仍可以看到代码的正确行数。
这个失败的第二个费解之处是,其中的条件看上去并不符合实际的测试用例 —— 对“大于 0”的测试实际上发生在该失败指定的 else 语句之前。如果您知道 Hansel 的工作原理,那么这个结果也不难理解。这条失败消息并不是针对该方法中任何之前的代码而生成的,而是针对一个独立的问题。知道了这一点,上述失败就可以重新表述为 "Condition 'diff < 0' was always true",这样看起来就更有意义了。我的测试用例没有包括任何带有重复值的数组,但这正是进入这三个分支中的第三个也是最后一个分支的条件。理解了这个失败之后,修正错误就非常容易了,因此我又添加了一行代码到测试中,然后将目光转移到接下来的失败,如清单 5 所示:
清单 5. 第三个失败
Probe in "StringArray.java" line 50
Coverage failure: Branch not completely covered. Condition
'!(dupl <= 0)' is not fulfilled.
at com.sosnoski.demo.StringArray.<init>(StringArray.java:50)
// eliminate duplicates if present
if (dupl > 0) {
String[] uniques = new String[list.length - dupl];
|
这条失败消息有一点隐晦,但是通过 Boolean Logic 101,我可以将这条消息翻译为 "Condition '(dupl <= 0)' was always true."。对于这里的测试用例而言,这个失败的意思是,没有哪个测试在提供给构造函数的数组中存在重复元素 —— 这与第二个失败的原因是一样的。于是就剩下第四个也是最后一个失败了,如清单 6 所示:
清单 6. 第四个失败
Probe in "StringArray.java" line 75
Coverage failure: Method not covered.
at com.sosnoski.demo.StringArray.get(StringArray.java:75)
public String get(int index) {
return m_list[index];
}
|
这个失败很容易理解,但我不确定是不是真的要“修正”它 —— 我没有测试的这个方法非常微不足道,为这么微不足道的方法添加测试有点浪费精力。不幸的是,Hansel 没有提供选择来压制失败消息,所以如果我想要用 Hansel 覆盖测试运行我的 JUnit 测试并使其成功执行,那么就需要添加一个使用该方法的测试。我将遵照这个要求,提供一个验证查找代码的测试,以提高测试的总体质量,最后的测试用例如清单 7 所示(修改的部分用粗体显示):
清单 7. 修改后的完全覆盖的 JUnit 测试
public class StringArrayTest extends TestCase
{
private static String[] s_list1 = {
"a", "b", "ccc", "ccd", "d", "e", "f", "g"
};
private static String[] s_list2 = { // s_list1 reordered
"a", "b", "ccd", "ccc", "g", "f", "e", "d"
};
private static String[] s_list3 = { // duplicates
"a", "g", "ccc", "d", "d", "e", "ccc", "g"
};
public void testStringArray() {
// added line below for first coverage failure
StringArray array0 = new StringArray(new String[0]);
StringArray array1 = new StringArray(s_list1);
assertEquals(8, array1.size());
StringArray array2 = new StringArray(s_list2);
assertEquals(8, array2.size());
// added line below for second/third coverage failure
StringArray array3 = new StringArray(s_list3);
}
// changed this to test both get and indexOf, and sorting
public void testLookup() {
StringArray array = new StringArray(s_list2);
assertEquals(-1, array.indexOf("ee"));
for (int i = 0; i < s_list1.length; i++) {
String value = array.get(i);
assertEquals(s_list1[i], value);
assertEquals(i, array.indexOf(value));
}
}
public static Test suite() {
return new CoverageDecorator(StringArrayTest.class,
new Class[] { StringArray.class });
}
}
|
这个修改后测试用例的所有方面都可以获得通过,包括 Hansel 代码覆盖检查。
覆盖的局限性
不幸的是,代码覆盖测试只能确保您想要测试的所有代码得到执行。但是它不能保证测试的质量。对于我在本文中用到的代码,构造函数中两个循环之间的相互作用中存在一个重大错误。第一个循环检查传递给构造函数的数组中的顺序和惟一性,第二个循环消除第一个循环得出的重复值。清单 8 展示了第一个循环,其中有问题的代码以粗体显示:
清单 8. 构造函数中有问题的代码
// first make sure the array values are ordered
int dupl = 0;
if (list.length > 0) {
String last = list[0];
int index = 0;
while (++index < list.length) {
String comp = list[index];
int diff = last.compareTo(comp);
if (diff > 0) {
// disordered, sort values in array
Arrays.sort(list);,
last = list[index];
} else if (diff < 0) {
last = comp;
} else {
dupl++;
}
}
}
|
这部分代码的问题是,如果数组中的值已经排序,那么重复值计数就不再准确。为了演示这个错误,我可以修改单元测试中的第二个数组,使重复的值被计数两次(一次在列表被排序之前,一次在列表被排序之后):
private static String[] s_list2 = { // s_list1 reordered
"e", "e", "ccd", "ccc", "g", "f", "d", "a", "b"
};
|
新的测试用例在构造函数的第二个循环中给出了一个 ArrayIndexOutOfBoundsException 异常。这个问题很容易修正,只需在对数组排序时重新开始重复值扫描即可,如清单 9 所示:
清单 9. 修正后的构造函数代码
// disordered, sort values and restart scan
Arrays.sort(list);
dupl = 0;
index = 0;
last = list[index];
|
但 Hansel 让我可以确信,通过最初的 JUnit 测试,所有代码都将得到执行,那么为什么在那些测试中这个问题没有暴露出来呢?原因是,这个问题是依赖于数据的;它潜伏在代码中,直到碰到它想要的数据形式,然后就跳出来咬我一口(好在我个人比较留意捕捉 bug!)。相对于简单的逻辑错误,依赖于数据的错误比较难检测,而且,不幸的是,代码覆盖工具并不能确保您测试了所有可能触发这样错误的数据组合。所以要使这些错误浮出水面,最好的办法是在单元测试中使用各种各样的数据。
揭开 classworking 的神秘面纱
那么,代码覆盖与 classworking 有什么关系呢?测试覆盖工具旨在修改代码,使之在执行期间留下一条“面包屑”踪迹(这也是我在这一期关注的工具的名称由来)。当测试执行完毕的时候,该工具就可以检查这条踪迹,看在测试期间代码的哪些部分真正得到执行。为了演示这个功能的原理,我将介绍我们这个童话故事中的另一个主角 —— Gretel。
Hansel 是为 class-at-a-time 覆盖检查而设计的,并且被集成到 JUnit 测试中,但 Gretel 则是为应用程序范围的覆盖检查而设计的。首先使用 Gretel 插装代码,接着用 Gretel 插装(instrumentation)运行一个或多个测试,将执行数据保存到文件中,然后再次使用 Gretel 来查看所保存的数据。在这种方法当中,Gretel 类似于其他一些开放源码或商业覆盖检查工具。
Gretel 超越大多数其他代码覆盖工具的地方是它对增量覆盖检查的支持。将检测代码添加到代码中会稍微降低执行速度,如果对应用程序运行大量的测试,那么由于添加检测代码导致的性能损失可能值得关注(特别是在具有对时间敏感的测试时更是如此)。为了缓和这个问题,Gretel 允许在运行开始的一组测试之后重新插装代码。重新插装步骤会将已经覆盖过的插装从代码中去掉,只留下在开始的测试中没有执行的插装。只要您愿意,您可以重复这个重新插装步骤,所以您可以一边运行逐渐扩大被测试代码范围的测试,一边从大部分被执行的代码中消除插装。
Gretel 的用户界面是一个简单的 Swing 应用程序,它并没有以用户友好的方式提供很多的功能。我在参考资料一节中列出了其他两种开放源码选择,如果您对应用程序范围的代码覆盖检查感兴趣的话,那么您可能想看看这两种工具,还有一些相关的商业软件我没有进行调查。但在本文中,我不打算详细讨论如何使用这些工具,只打算用 Gretel 来展示代码覆盖跟踪是如何实现的。
跟踪的理论
为了演示覆盖跟踪的工作原理,我将重新把目光投向我在本文中使用的 StringArray 类的构造函数。假设您想添加代码来跟踪在运行某些测试时构造函数中哪些代码行真正得到执行。可以使用的一种方法是创建一个布尔值数组,初始方法中的每一行代码对应一个布尔值,然后添加代码,每执行一行代码就设置相应的标志。于是,在运行测试之后,您只需检查这个数组,便可以发现哪些行没有被执行。
这种分别跟踪每一行的方法虽然凑效,但其效率不是很高。如果查看构造函数代码的执行路线,如清单 10 所示,您可以发现,实际上只需检查少数几个地方,就可以确信所有代码是否被执行。例如,您可以看到,每当 if 条件为真的时候,嵌入其中的 while 循环至少会执行一次(因为 list 的长度肯定大于 0)。再进一步,看看 while 循环内部的结构。这是一个三元条件。因为这三个代码块是三选一的(在代码中是并行的路线),因此每一个代码块都需要分别跟踪,如黑体代码所示。但完成了对这三块代码的跟踪后,就可以得到整个循环所需的信息。
清单 10. 覆盖代码示例
public StringArray(String[] list) {
// first make sure the array values are ordered
int dupl = 0;
if (list.length > 0) {
String last = list[0];
int index = 0;
while (++index < list.length) {
String comp = list[index];
int diff = last.compareTo(comp);
if (diff > 0) {
// disordered, sort values and restart scan
Arrays.sort(list);
dupl = 0;
index = 0;
last = list[index];
} else if (diff < 0) {
last = comp;
} else {
dupl++;
}
}
}
...
|
Gretel 面包屑
大多数覆盖工具都只是沿着上一节中讨论的路线对 Java 字节码执行流分析,并且只是在需要的地方插入跟踪。清单 11 是一个关于真正的跟踪实现的例子,其中展示了经 Gretel 处理后的与清单 10 中代码对等的源代码(之所以说是“对等的源代码”,是因为 Gretel 是在字节码这一级进行处理的,它修改编译后的类文件)。Gretel 使用用于静态方法的调用来跟踪执行的块,将这些调用添加到字节码中的必要地方。
清单 11. Gretel 修改后的代码
public StringArray(String[] list) {
// first make sure the array values are ordered
int dupl = 0;
if (list.length > 0) {
String last = list[0];
int index = 0;
while (++index < list.length) {
String comp = list[index];
int diff = last.compareTo(comp);
if (diff > 0) {
// disordered, sort values and restart scan
Arrays.sort(list);
dupl = 0;
index = 0;
last = list[index];
residue.runtime.Monitor.hit(0);
} else if (diff < 0) {
last = comp;
residue.runtime.Monitor.hit(1);
} else {
residue.runtime.Monitor.hit(2);
dupl++;
}
}
}
...
|
结束语
覆盖测试使您可以清楚一个类或应用程序中哪一部分的代码真正得到执行。在本文中,我从测试的角度对覆盖进行了研究,但对于覆盖的使用显然还有其他一些方式。例如,大多数大型项目都是随着不同开发人员多年对其进行扩展或维护而逐渐增大的。因此最后在源代码树(source tree)中很容易留下一些无人能识的代码。如果您对您的应用程序运行一系列的测试,并发现某部分代码从来没有被执行过,这并不总是因为测试不完全 —— 有时候您会发现,由于程序逻辑或受支持的配置上的变化,这部分代码已经完全脱离了执行路线。这也为您提供了从源代码树中删除无用代码的机会,这样做总是值得的。
从测试的角度来看,有 单元测试总是 好于没有 单元测试,但有些单元测试又好于其他一些单元测试。单元测试的完整性决定通过测试可以捕捉到哪些错误(而不是等到管理演示或者客户部署的时候,由于令人尴尬的崩溃才捕捉到错误)。代码覆盖工具为您提供了一种度量测试完整性的方法。这些工具不能检查测试是否包括所有适当的数据模式,但它们至少可以让您确信在测试期间所有代码都得到了执行。相对那些根本不执行某部分代码的测试而言,这本身就是一个进步!
在关于 Java 编程的动态性 的早期文章中(请参阅参考资料),我展示了如何使用 classworking 技术来对程序行为实施有系统的更改。这种方法是 Java 平台上用面向方面编程(aspect-oriented programming,AOP)所做的大部分工作的基础。下个月我将深入探讨日益流行的 AspectWerkz 框架,该框架为 Java 程序设计提供了灵活的 AOP 扩展,我还会展示如何将 AspectWerkz 框架应用到一个经典的 AOP 用例中。请回过头来参阅 Classworking 工具箱 系列,看看 AspectWerkz 是如何
胜任这项工作的。
下载 | 名字 | 大小 | 下载方法 |
|---|
| j-cwt02095-source.zip | 3 KB | HTTP |
参考资料
关于作者
对本文的评价
|  |