2. 使用 student.TestCase 编写 JUnit 测试¶
本页介绍在 Java 中使用 student.TestCase class 创建 JUnit 测试的基础知识:
2.1. 使用 JUnit¶
要在 eclipse 中创建 JUnit 测试类:
在包资源管理器(Package Explorer)中右键单击你要为其创建测试类的那个类
点击:
New > Class(创建 JUnit Test Case 不符合 CS2-Support 的要求)给类命名,以 Test 结尾(例如 HokieTest、ArrayBagTest)
点击 Finish(如果需要,你可以勾选“generate comments”复选框)
添加一条 import 语句:import student.TestCase
添加语句让你的类继承 TestCase。
项目的 Build Path 应配置为包含 CS2-Support 项目(注意,CS2-Support 需要处于打开状态才会显示为可选项目)
声明实例变量
为你在测试的那个类的对象创建至少一个字段。
编写 setUp 方法
使用 setUp() 方法初始化你的对象,它会在每个测试方法之前运行。
为你正在测试的类中的每个方法编写测试方法
为你类中的每个方法至少创建一条测试方法。测试类中的每个方法都需要以 'test' 开头,否则它将无法正确运行!(例如 testGetName、testAdd)对于测试方法,请在对象上调用对应的方法,并使用断言语句来测试你的代码。
根据需要编写额外的测试方法
一个针对 Student 类的简化测试类示例:
2.2. 运行 JUnit 测试¶
要运行一个 JUnit 测试类:
在包资源管理器(Package Explorer)中右键单击测试类
点击:
Run as > JUnit Test(对于 Android 项目,点击Android Junit Test)。此时应弹出一个 JUnit 窗口:如果所有测试都正确,则显示为绿色;如果一个或多个测试失败,则显示为红色。
2.3. 命名约定¶
对于类:在类名的末尾添加 Test
示例:
HelloWorld是类;HelloWorldTest是测试类
对于方法:以 test 开头命名测试方法
示例:
foo是方法;testFoo是测试方法
2.4. 实例变量¶
使用实例变量来保存用于测试的值
也称为字段变量(field variables)、成员变量(member variables)
实例变量的作用域为所有实例方法,因此该变量可以在多个测试中使用
在上面的例子中,
janeDoe就是一个实例变量
2.5. setUp 方法¶
setUp()方法在每个测试方法之前运行。使用该方法初始化实例变量
必须命名为
setUp-- 记住要把 U 大写!
2.6. tearDown 方法(可选)¶
tearDown() 方法在每个测试方法结束时运行。对于测试用例来说,它是 可选的 。
它用于在测试结束后收尾
用途:检查链表的布局、关闭文件
必须命名为
tearDown-- 记住要把 D 大写!
2.7. 代码覆盖率¶
编写覆盖平均情况的测试
示例:在线性表中测试向中间进行添加
编写覆盖边界情况的测试
示例:在线性表中测试在列表开头进行添加
2.7.1. N 个简单条件,N+1 个分支和测试¶
测试方法中的断言需要覆盖 if-else 语句的每一个条件。仅仅让测试到达 “else” 条件是不够的。为了正确地测试 if-else 语句,每个条件的主体都必须在测试期间执行。
关于边界情况和平均情况的说明:对于一个包含 100 个值的线性表,你必须检查索引 -1、0、99、100,以及介于其间的某个值。
示例 :假设我们有如下代码:
你的测试类必须测试上面列出的全部 5 种可能情况,才能执行 if-else 语句块中的每一行代码。
测试代码的最好方法有时是先把代码清理干净!
在测试之前清理代码可以节省大量时间。此外,代码的组织方式也可能使正确测试变得更加容易。
示例:假设我们在某个方法内部编写了如下代码:
我们可以轻松地清理这个 if 语句——只需注意到我们其实对 A > B 做了两次不必要的判断。我们可以把它改写为:
if ( A > B )
{
if ( C != 0)
{
// do something
}
}
我们也可能决定去掉它们的嵌套:
现在,更容易看清所有需要测试的条件了。
2.7.2. 让 JUnit 测试方法保持小巧¶
在测试包含多个 if-else 语句的方法时,把每种可能情况拆分到单独的测试方法通常可以简化测试。当你需要确保覆盖更复杂的 if-else 语句块中的每个条件时,这种方法尤其有用(这是 Web-CAT 经常报告的错误类别)。
假设我们正在测试一个包含如下 if-else 语句的方法:
当 A > B 为真时,用一个测试方法评估这个 if 语句;当 A > B 为假时,用另一个测试方法评估同一个 if 语句,这可能是个好主意。
2.7.3. 断言语句¶
断言语句用于在测试类中测试代码 运行测试类时,断言语句会告诉你代码是否正常 在此阅读相关内容:http://courses.cs.vt.edu/~cs1114/api/student/TestCase.html
常用的断言语句:
assertEquals
assertTrue
assertFalse
assertNull
assertNotNull
避免通过 System.out.println() 进行测试
使用断言语句,而不是通过 System.out.println() 进行测试
示例:假设你想确保 getName() 方法返回了正确的 String。与其调用:
System.out.println(janeDoe.getName());
改为使用一条断言语句:
assertEquals(“Jane Doe”, janeDoe.getName());
警告:如果测试方法内没有任何断言语句,那么当作 JUnit 测试运行时,它总是会被评估为 “true”。为防止这种情况,你可以在尚未完成的测试方法中添加这样一行:
fail("Not yet implemented");
把它放在你尚未完成的测试方法内部即可。
2.8. 测试异常¶
如果你在代码中抛出了异常,那么在测试中就要捕获它!
在测试中使用 try-catch 块来检查你的代码是否抛出了正确的异常。在 try 块中,你应该调用那个会导致抛出异常的方法。catch 块应该捕获所抛出的异常,然后断言该异常存在、类型正确,并且(如果适用)包含正确的消息。
示例 :假设你想访问数据结构中某个无法通过迭代器(iterator)对象访问的元素,因此你在测试代码是否抛出了 NoSuchElementException,且消息为 “There are no more elements left to iterate over.”。测试方法中的以下代码将判断你是否正确地捕获了异常:
示例 :
2.8.1. 测试 toArray() 方法¶
toArray() 方法返回一个包含给定集合中每个元素的对象数组(Object array)。
测试 toArray() 方法需要确认该方法返回的实际对象数组与期望的对象数组相匹配。
请注意, assertEquals 和 assertTrue 方法 不会 提供直接比较两个数组的机制。
因此,我们不能简单地执行以下操作:
Object[] expectedArray = {"A","B","C","D"};
Object[] actualArray = {"A","B","C","D"};
assertEquals(expectedArray, actualArray);
以这种方式使用断言会导致测试失败,并抛出 AssertionFailedError (见下图)。
我们也不能这样写:
因此,我们需要一个替代方案。
一种方法是遍历每个数组的元素,把一个数组中的每个元素与另一个数组中的对应元素进行比较。如果任何一对不匹配,我们就可以断定这两个数组不相等,从而返回 false。请注意,我们必须把数组的每个元素与对应的元素逐一比较之后,才能确定它们相等:只有当没有遇到任何彼此不相等的元素对时,它们才是相等的。在这个例子中,我们将在索引 0 处开始比较,即把 expectedArray[0] 与 actualArray[0] 比较,然后在索引 1 处继续比较,即把 expectedArray[1] 与 actualArray[1] 比较,依此类推,从左到右进行。
考虑使用 for 循环来协助完成这样的任务。
2.9. 单元测试通用技巧¶
调试失败的测试可能很繁琐,尤其是在较大的项目中。为了让这个过程对自己更容易些,请确保每个测试用例恰好覆盖 1 个逻辑组件。例如,考虑一下我们的 Hokie 类的这个简化形式:
public class Hokie {
private String pid;
private String hometown;
private int graduationYear;
private int DOBYear;
public boolean setDOBYear(int year) {
if (year > 0 && (year < 3000)) {
DOBYear = year;
return true;
}
return false;
}
public String toString() {
return pid;
}
}
我们可以创建这样一个测试用例:
public void test1(){
// Tests setDOBYear
assertTrue(elena.setDOBYear(1968));
assertEquals(1968,elena.getDOBYear());
assertFalse(john.setDOBYear(12031995));
// tests toString
Hokie gobbler = new Hokie("gobbledee",1973);
assertEquals("gobbledee",gobbler.toString());
}
public void test1(){
// Tests setDOBYear
assertTrue(elena.setDOBYear(1968));
assertEquals(1968,elena.getDOBYear());
assertFalse(john.setDOBYear(12031995));
// tests toString
Hokie gobbler = new Hokie("gobbledee",1973);
assertEquals("gobbledee",gobbler.toString());
}
然而,如果 test1 失败,调试时你就要考虑可能是测试本身有错误,也可能是 setDOBYear() 方法、 getDOBYear() 方法或 toString() 方法有错误。Eclipse 会把你引到失败的那一行,但这不一定能告诉你问题实际上是从哪里开始的!无论是哪种方式,为一个且仅为一个逻辑组件编写一个测试方法都是很好的做法。把这两项拆分成单独的测试,会让以后的调试更容易。
在更大的程序中,每个方法只写 1 个测试可能也不够。考虑以下代码:
public int foo(int x, int y){
for (int i = 0; i<10; i++){
x+=i;
if (x%3 ==0){
x++;
}
y *= i;
}
if (x%2 == 0){
return x;
}else if (y%2 == 0){
return y;
}
return 0;
}
你可能会发现,编写一个处理 for 循环内部逻辑的测试用例,再为循环外部的条件判断编写另一个独立的测试用例,这样更容易。这样一来,如果某个测试失败,你就确切地知道该在代码中的哪个位置查找!
2.10. 通过传递 null 参数测试方法¶
一般情况下,当设置一个需要向方法传递 null 的测试用例时,你应该避免直接传递 null,因为提交到 Web-CAT 时这会导致风格扣分。
例如,测试: .. code-block:: java
assertFalse( someNonNullObject.equals( null ) );
提交到 Web-CAT 时,上面的测试会返回风格错误。
为避免这种情况,你应当创建另一个对象(务必取一个恰当的名字),把它设置为 null ,然后将该对象传递给被测方法。
例如:
SomeObject nonNullObject = new SomeObject (...);
SomeObject nullObject = null;
assertFalse( nonNullObject.equals( nullObject ) );
2.11. I/O 测试技巧¶
本节包含关于如何为输入和输出编写测试用例的信息。
这里包含的示例代码假定你使用 student.TestCase 作为 JUnit 测试的基类,而不是 junit.framework.TestCase 。 student.TestCase 类提供了许多额外的测试方法,这些方法在与 I/O 相关的测试中特别有用。
技巧 1:使用 PrintWriter 进行文本输出
当程序需要生成输出时,把目标硬编码到某个具体位置——某个特定文件、 System.out 或其他目标——往往看起来很省事。不幸的是,这样做有两个缺点:
这会让你的代码灵活性降低,因为输出位置被硬编码,无法轻易更改。事实上,将输入/输出(I/O)的设计选择(包括目标和格式的选择)与数据处理方式的设计选择分离开来,这条总体原则已经存在 40 年了!
由于将输出重定向到不同目标比较困难,测试也就更困难,因为要在测试用例内部“获取”生成的输出以检查其正确性,需要做更多工作。
更好的策略是让所有输出生成都面向通用的输出流类,而不是特定的目标,这样就能提供任何合适的流实例。在 Java 的 IO 库中,PrintWriter 类非常适合这一点。PrintWriter 表示一个文本输出流,你可以为任何可以发送文本输出的目标创建 PrintWriter。
因此,使用 PrintWriter 而不是硬编码你的目标。
技巧 2:使用 Scanner 进行文本输入
当程序需要读取文本输入时,把来源硬编码到某个具体位置——System.in、特定文件或其他来源——往往看起来很省事。不幸的是,这样做有两个缺点:
这会让你的代码灵活性降低,因为输入来源被硬编码,无法轻易更改。事实上,将输入/输出(I/O)的设计选择(包括目标和格式的选择)与数据处理方式的设计选择分离开来,这条总体原则已经存在 40 年了!
由于将输入重定向到不同来源比较困难,测试也就更困难,因为要在测试用例内部提供“预设”输入来检查程序的行为,需要做更多工作。
更好的策略是让所有输入读取都面向通用的输入流类,而不是特定的来源,这样就能提供任何合适的流实例。在 Java 的 IO 库中,Scanner 类非常适合这一点。Scanner 表示一个文本输入流,你可以为任何可以从中读取文本输入的来源创建 Scanner。
因此,使用 Scanner 而不是硬编码你的输入来源。
技巧 3:使用 PrintWriter 和 Scanner 编写测试用例
student.TestCase 类提供了几个辅助方法,使测试使用 PrintWriter 或 Scanner 的 I/O 代码变得更简单。让我们看一个例子。假设你有一个这样的简单类:
import java.io.PrintWriter;
public class OutputExample1
{
public void doit(PrintWriter out)
{
out.println("hello world");
}
}
要测试这一点,你需要以某种方式创建一个 PrintWriter 并将其传入方法,然后在完成后提取字符串内容,以便检查它是否正确。 student.TestCase 类提供了一个名为 out() 的有用方法,它可以访问一个可用于测试的内置 PrintWriter 。这个内置的 PrintWriter 具有以下特性:
它会自动为你创建并随时可用。
它的内容会在每个测试用例前自动清空,因此它总是以全新的状态开始。
与普通的
PrintWriter不同,这个内置对象提供了getHistory()方法,让你可以轻松访问已经发送给它的全部内容。虽然所有
PrintWriter在调用println()时都使用宿主操作系统原生的行分隔符序列概念,但getHistory()方法总是返回以换行符(在 Java 文本字符串中写作 "n")表示换行的内容,因此你可以在不考虑平台差异的情况下检查生成的输出。
你可以像这样使用 out() 编写测试用例:
public void testExample1()
{
OutputExample1 example = new OutputExample1();
example.doit(out());
assertEquals("hello world\n", out().getHistory());
}
现在,假设你在输入时使用 Scanner。考虑这个例子:
import java.io.PrintWriter;
import java.util.Scanner;
public class OutputExample2
{
public void doit(Scanner in, PrintWriter out)
{
String line = in.nextLine();
out.println(line);
}
}
student.TestCase 类通过一个名为 in() 的方法提供一个内置的 Scanner ,还提供了一个名为 setIn() 的方法,可以让你设置这个 Scanner 的内容。因此,你可以编写这样的测试用例:
public void testExample2()
{
setIn("hello\n");
OutputExample2 example = new OutputExample2();
example.doit(in(), out());
assertEquals("hello\n", out().getHistory());
}
对任何使用 Scanner 或 PrintWriter 的代码,都要使用 setIn() 、 in() 和 out() 来编写测试用例。
技巧 4:使用 ``System.out`` 编写测试用例
虽然让类使用 PrintWriter 对象进行输出更好,但你的主程序往往会把这类代码专门指向 System.out 产生输出。那么,当输出流向 System.out 时,你该如何测试 main() 呢?
student.TestCase 类提供了一个辅助方法,使这项工作像使用 PrintWriter 测试输出一样简单。假设你有这样一个简单的类:
public class OutputExample3
{
public static void main(String[] args)
{
System.out.println("hello world");
}
}
要测试这一点,你需要以某种方式捕获在 System.out 上生成的输出。 student.TestCase 类提供了一个名为 systemOut() 的有用方法,它可以访问一个同样表示 System.out 的更复杂的对象。这个更智能的对象提供了以下特性:
它的内容会在每个测试用例前自动清空,因此它总是以全新的状态开始。无论终端上显示了多少输出,你的测试只会看到单个测试期间生成的输出。
与
System.out不同,systemOut()返回的对象提供了getHistory()方法,让你可以轻松访问代码的任何部分发送到System.out的全部内容。通常,这是你使用systemOut()的唯一方式——获取它的历史记录。虽然
System.out在调用println()时使用宿主操作系统原生的行分隔符序列概念,但getHistory()方法总是返回以换行符(在 Java 文本字符串中写作 "n")表示换行的内容,因此你可以在不考虑平台差异的情况下检查生成的输出。
你可以像这样使用 systemOut() 编写测试用例:
public void testExample3()
{
OutputExample3.main(null);
assertEquals("hello world\n", systemOut().getHistory());
}
技巧 5:使用 ``System.in`` 编写测试用例
如果你有直接读取 System.in 的代码(比如 main() ),那么测试它会是一个挑战。为了提供输入,总得有人在键盘上输入些内容……真的是这样吗?
与前面描述的用 Scanner 对象测试的策略类似, student.TestCase 类还提供了一个方便的 setSystemIn() 方法,你可以用它设置可供从 System.in 读取的内容。你可以这样使用它:
public void testExample4()
{
// Provide the content to be read from System.in
setSystemIn("line 1\nline 2 with more words\n");
// Call main()
SomeClass.main(...);
// Make an assertion about what appeared on System.out
assertEquals("some output\n", out().getHistory());
}
技巧 6:把长字符串放在多行中
在测试用例中编写字符串字面量,并且该字符串字面量表示程序的输入序列或期望输出时,它有时可能相当长。请记住两点。首先,别忘了你可以把字符串字面量拆分成多个部分,再用 + 运算符(用于连接字符串)组合起来。这对于保持长字符串的可读性至关重要。其次,请记住你确实需要包含 n 来表示字符串中的每一个换行——把字符串字面量写在多行上并不意味着字符串本身包含换行符!
假设你的程序产生以下输出,而你希望把这段输出写成字符串字面量: .. code-block:: text
The quick brown
fox jumps over
the lazy
dog.
在断言中,你可以这样写:
assertEquals(
"The quick brown\n"
+ "fox jumps over\n"
+ "the lazy\n"
+ "dog.\n",
systemOut().getHistory());
技巧 7:测试可能只有细微差异的字符串
有时,在比较字符串时,你并不关心逐字符相等,因为某些差异可能并不重要(例如大小写或空格)。如果你遇到这种情况, student.TestCase 类提供了一个与 assertEquals() 类似的名为 assertFuzzyEquals() 的方法。它的用法与 assertEquals() 完全相同,只不过它只用于比较字符串值。在比较字符串值时,它会忽略以下内容:
大小写的差异
换行表示方式的差异(例如 Windows 与 Linux 的行尾符)
标点符号的差异(任何不是字母、数字、下划线或空白的内容)
单词之间空白数量的差异(即任何空格、制表符或其他空白字符序列——除换行外——都被当作单个空格字符处理)
每行开头或结尾是否存在空白
结尾是否存在尾部换行或空行
通过一些附加命令,这种模糊比较还可以忽略所有空白(不只是数量上的差异)、忽略所有空行,或完全忽略所有行边界,但这些都不是默认行为。如果你需要帮助,请在论坛上提问。
student.TestCase 类提供的任何名称中包含 “Fuzzy” 的比较方法都具备这一相同特性。
技巧 8:测试你输出的片段
无论程序使用哪种输出目标,有时对生成的结果做断言都可能是一个挑战。在上面的例子中,写出完全逐字符一致的期望输出并检查输出是否完美,是相当容易的。但是,如果你的程序每次运行产生的输出都不同,该怎么办呢?或者如果输出太长,不方便写出全部内容,又该怎么办?
在这些情况下,你可能希望对输出的某些部分进行“抽查”,而不提供完整内容。例如,假设你的程序产生以下输出:
The quick brown
fox jumps over
the lazy
dog
student.TestCase 提供了一个名为 contains() 的辅助方法,你可以在这样的测试中使用它:
assertTrue(contains(
systemOut().getHistory(),
"brown",
"fox",
"lazy",
"dog"));
contains() 的含义类似于 String 类提供的 contains() 方法的含义,但扩展到多个参数。除了第一个参数——要搜索的字符串——你可以提供任意多个要查找的子字符串。当且仅当所有指定的子字符串都以你指定的顺序出现在被搜索的字符串中时, contains() 方法才返回 true。 contains() 方法不关心子字符串之间有什么内容,因此它们可以彼此紧邻,也可以相距任意远。它只关心每一个子字符串都存在,并且它们以你列出的确切顺序出现。
你可以使用 contains() 抽查输出的关键部分,而无需逐字列出全部输出。
