软件测试总结(2)

本文是对MIT讲义Reading 3: Testing翻译的第二部分,第一部分请见软件测试总结(1)


本文的翻译主要用于我自己的学习,不追求绝对的准确性,但欢迎读者质疑

用JUnit进行自动单元测试

一个经过良好测试的程序(well-tested program)将会针对每一个独立的模块(这里的模块是指一个方法或者一个类)进行测试,如果一个程序可以对每个模块进行单独测试的话,这种测试方式就称为单元测试。单独测试模块会让debug更加容易,当一个模块的单元测试出现失败时,你就更有理由相信bug就出现在这个模块而不是程序的其他地方。

JUnit是一个被广泛使用的Java单元测试库。JUnit单元测试的方法总是写在注释@Test的下面,JUnit单元测试方法通常包括对被测模块的一个或多个调用,然后用断言方法(assertion methods)来检查测试结果的正确性,这些断言方法包括assertEquals, asserTrue, assertFalse等。

例如,我们对软件测试总结(1)中提到过的Math.max()进行测试的话,用JUnit写出来的测试代码就可能像下面一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
public void testALessThanB() {
assertEquals(2, Math.max(1, 2));
}

@Test
public void testBothEqual() {
assertEquals(9, Math.max(9, 9));
}

@Test
public void testAGreaterThanB() {
assertEquals(-5. Math.max(-5, -6));
}

需要注意的是assertEquals方法的参数的顺序是重要的。第一个参数是测试希望看到的预期结果,通常是一个常数。第二个参数是代码运行的实际结果。如果你将它的顺序颠倒了,那么当测试失败的时候,JUnit就会产生一段令人困惑的错误信息。JUnit提供的所有断言方法都遵循着这样的顺序: expected first, actual second.

如果一个测试方法的断言失败了,那么该测试方法就会立即返回,JUnit会记录这个测试失败。一个测试类可以包含任意多个@Test方法,他们独立运行,即使一个测试方法失败了,其他的还会继续运行。

为你的测试策略写注释文档

我们拿一个例子来讲解。下面第1段代码是一个java函数,第2段代码展示了我们要怎样用上面提到的分区方法来为测试策略写文档,这个策略还包含了我们之前没有考虑过的边界情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

/**
* Reverses the end of a string.
*
* For example:
* reverseEnd("Hello, world", 5)
* returns "Hellodlrow ,"
*
* With start == 0, reverses the entire text.
* With start == text.length(), reverses nothing.
*
* @param text non-null String that will have
* its end reversed
* @param start the index at which the
* remainder of the input is
* reversed, requires 0 <=
* start <= text.length()
* @return input text with the substring from
* start to the end of the string
* reversed
*/
static String reverseEnd(String text, int start)

在测试类顶部为测试策略写文档:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
* Testing strategy
*
* Partition the inputs as follows:
* text.length(): 0, 1, > 1
* start: 0, 1, 1 < start < text.length(),
* text.length() - 1, text.length()
* text.length()-start: 0, 1, even > 1, odd > 1
*
* Include even- and odd-length reversals because
* only odd has a middle element that doesn't move.
*
* Exhaustive Cartesian coverage of partitions.
*/

每一个测试方法上面都应该有一个注释来说明它的测试用例是如何选择的,例如,它包含了分区中的哪部分:

1
2
3
4
5
6
// covers test.length() = 0,
// start = 0 = text.length(),
// text.length()-start = 0
@Test public void testEmpty() {
assertEquals("", reverseEnd("", 0));
}

黑盒测试和白盒测试

回顾一下上面提到过的,规范(specification)就是对函数的行为的描述——包括参数的类型,返回值类型,他们的限制和联系。

黑盒测试是指只根据规范而不根据函数的具体实现来选择测试用例。这就是目前我们在所有例子中所做的,我们并不看实际代码就为multiplymax划定分区和寻找边界。

白盒测试(也成为玻璃盒测试)是指根据函数的具体实现来选择测试用例。例如,如果函数的实现根据不同输入选择不同的算法,你的分区就应该根据这些输入域去划分。如果函数的具体实现包含一个内部缓存以记住你之前输入的答案,那么你就该测试重复输入。

覆盖

判断测试好坏的一种方法就是看它是怎样彻底的对程序进行测试的这个概念就叫做覆盖(coverage),这是几种常见的覆盖:

  • 语句覆盖: 是否每条语句都在至少一个测试用例中运行到了?
  • 分支覆盖: 对于程序中的if或者while语句,是否它们的true和false两种情况都被测试到了?
  • 路径覆盖: 是否分支的每一种组合,或者说通过程序的每一种路径都被测试到了?

分支覆盖比语句覆盖更强大(需要更多的覆盖来达到),路径覆盖比分支覆盖要求更强大。在工业上,100%的语句覆盖是一个普遍的目标,但即使是这样,这个目标其实也很少能达到,因为程序中存在着一些不可达到的防御性代码(像”should never get here”断言)。100%的分支覆盖有更高的要求。而100%的路径覆盖是无法达到的。

一个测试的标准途径是不断添加测试用例直到测试套件达到了充足的语句覆盖率,也就是说,每一个可达的语句都至少被一个测试用例执行到了。在实际中,语句覆盖率通常由代码覆盖工具来测量,代码覆盖工具统计出每一条语句被你的测试套件运行的次数,通过这样的一个工具,白盒测试变得很容易,你只需要测量出你的黑盒测试的覆盖率,然后添加更多的测试用例直到所有的重要代码语句都被标志为已执行过。

一个很好的Eclipse代码覆盖工具是EclEmma

单元测试 vs. 集成测试,和 Stubs

我们目前谈到的单元测试都是分离的测试单独模块,分离的测试模块使得debug更容易。分离的测试模块会让debug更加容易,当一个模块的单元测试出现失败时,你就更有理由相信bug就出现在这个模块而不是程序的其他地方。

与单元测试相比,集成测试测试的是模块的组合,甚至是整个程序。如果你只有集成测试,那么测试失败的时候,你就必须去寻找bug,而这个bug可能在程序中的任何地方。但集成测试仍然是很重要的,因为程序可能在模块的连接处失败。例如,一个模块可能期待不同的输入,而不是实际从另一个模块中得到输入,但是如果你有一套完整的单元测试让你对每个独立模块的正确性都有信心,那么你会更容易找到bug。

假设你要建立一个网页搜索引擎,你的其中两个模块是:getWebPage()用来下载网页,extractWords()用来提取网页的文字。

1
2
3
4
5
6
7
8
9
/** @return the contents of the web page downloaded from url 
*/
public static String getWebPage(URL url) {...}

/** @return the words in string s, in the order they appear,
* where a word is a contiguous sequence of
* non-whitespace and non-punctuation characters
*/
public static List<String> extractWords(String s) { ... }

这两个模块会被另一个用来构造搜索引擎索引的模块makeIndex()调用:

1
2
3
4
5
6
7
8
9
10
11
12
/** @return an index mapping a word to the set of URLs 
* containing that word, for all webpages in the input set
*/
public static Map<String, Set<URL>> makeIndex(Set<URL> urls) {
...
for (URL url : urls) {
String page = getWebPage(url);
List<String> words = extractWords(page);
...
}
...
}

在我们的测试套件中,我们会想要:

  • 用不同的URL对getWebPage()进行单元测试
  • 用不同的字符串对extractWords()进行单元测试
  • 用不同的URL集合对makeIndex()进行单元测试

编程者有时会犯的一个错误是为extractWords()写测试用例时要依赖于getWebPage()的正确性,其实最好独立的测试extractWords()并进行分区。用包含网页内容的测试分区也许是有道理的,因为这就是extractWords在程序中实际所使用的方式,但是不要真的在测试用例中调用getWebPage(),因为getWebPage()可能也有bug,替代的方式是将网页内容存放在文字字符串中,然后直接传送给extractWords,利用这种方式你会写出一个独立的单元测试,如果它失败了,你就会更有理由相信bug就存在于正在被测试的模块,extractWords

注意,对makeIndex()的单元测试不能简单的用这种方式分离,当一个测试用例调用了makeIndex(),它不仅在测试makeIndex()中的代码的正确性,而且还有makeIndex()调用的方法,如果测试失败了,bug就可能存在于这些方法中的任何一个。这就是为什么我们想要单独的测试getWebPage()extractWords()来增加我们对这些模块的信心。

分离一个高程度集成的模块像makeIndex()是可能的,我们可以通过给编写stub版本的它所调用的方法来实现。例如,一个getWebPage()的stub不会真正的链接互联网,而是会返回一个模拟的网页内容,无论输入的URL是什么。一个类的stub通常被叫做模拟对象(mock object),当构建大型系统时,stub是一个重要的技术。

自动测试和回归测试

自动化测试是指自动的运行测试并检查它们的结果,测试驱动程序不应该是一个提醒你输入并打印出结果让你手动检查的交互式程序,测试驱动程序的结果应该或是”all tests OK”,或是”these tests failed:…”。一个好的测试框架,例如JUnit,可以帮助你构建自动测试套件。

注意,虽然像JUnit这样的测试框架会让运行测试变得很简单,但是你仍然要自己想出好的测试用例。自动生成测试是一个很难的问题,目前仍然是计算机科学领域的一个很活跃的研究领域。

当你有了测试自动化,当你修改了代码后重新运行测试是很重要的。软件工程师从许多痛苦的经验中认识到对一个庞大的、复杂的程序的任何改动都是危险的。当你改变代码的时候频繁的运行测试可以让你的程序避免退步——修复bug或者增加新特性的时候引入新bug。做每一个改动后都运行一遍所有的测试家叫做回归测试

当你找到并修复一个bug时,将引起bug的输入添加到你的自动测试套件中作为一个测试用例,这种测试用例被称为回归测试用例,这有助于用好的测试用例填充你的测试套件。如果一个测试用例引起了bug那么它就是一个好的测试用例——每一个回归测试用例在你的代码的一个版本中都是这样。保存回归测试用例有助于避免重复引入相同的bug,这个bug可能是一个很容易犯的错误,因为他已经出现过一次了。

这个主意催生出了优先测试调试,当一个bug出现的时候,立即为其编写一个测试用例,并立即添加到你的测试套件中。

在实际中,自动测试和回归测试通常是集合在一起使用的。所以自动回归测试(automated regression testing)是一个现代软件工程师的最好实践。

小结

  • 优先编程测试。写实际代码前先写测试
  • 系统性的为测试用例选择分区和边界
  • 白盒测试和语句覆盖用于填充测试套件
  • 尽可能独立的测试不同模块
  • 自动回归测试可以避免bug重复出现

    讲义原文地址

    Reading 3: Testing