单元测试
概述
单元测试是开发自己编写的针对代码某个功能模块验证其行为的测试单元模块;单元测试贯穿在开发的整个过程,并伴随着新功能模块的产生而进行;单元测试并不会花费更多的时间,与之相反,在提高代码效率、减少bug数量、有序开展开发工作上,单元测试发挥着很大的作用。
场景示例
一个开发者因为最上层的代码运行没有任何输出,采用单步调试来跟踪并发现了一个bug,在他纠正了这个bug的同时又找到了好几个其它的bug;如此几次过后,bug还是存在;而程序输出这边,仍然没有结果;于是,这个开发者已经完全搞不清为什么会这样,并认为这种没有输出的行为是毫无道理的。
如果针对上面这个场景引入单元测试,情况会是这样的:
在开发过程中,每写一个函数就添加一个简单的测试来判断函数功能和所期望的是否一致;在未对刚写的函数做出确认之前,开发者并不会接着写新代码;也就是每写一个函数,必然是在验证其功能可用的情况下才引入新的功能的开发;而最后结果则是,因为有单元测试保障每一个新增函数的功能都是可用的,因而最后的最上层程序也是有输出的,而不会出现之前第一种场景里那种完全无厘头的情况。
误区纠正
编写单元测试太费时间——相比在项目结束时才进行的测试工作会花费更多的时间,用在单元测试上的时间是要少得多的;当然,前提是开发者必须要对所要测试的单元要实现什么样的功能,期望输出是怎样的要十分了解才行;
运行测试的时间太长——实际上,大多数测试的执行都是非常快的,因此通常几秒之内可以运行成千上万个测试;有时某些测试会花费很长的时间,但这样的测试并不一定要每次都运行;要将这些耗时的测试和其他测试分开,耗时测试一天运行一次或者几天运行一次,运行较快的测试则可以经常运行;
测试代码并不是开发的工作——如果一个开发者把随手编写的一块没有把握的代码随便地扔给测试组,那么实际上这个开发者并没有完成他的工作;实际上,期望别人来清理自己的代码是很不好的做法;
这些代码都能够编译通过——有一种很普遍的误解是,一个成功的编译就是成功的标记;实际上是,任何编译器和解释器都只能验证语法的正确性,而并不能验证行为的正确性
测试环境搭建
maven
现阶段,大部分工程基于maven搭建
maven + junit
junit
在java开发中,单元测试基于junit框架
搭建
在maven工程的pom.xml文件中添加:
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
</dependency>
maven+PowerMock
Powermock
单元测试的目标是一次只验证一个方法,但是倘若遇到这样的情况:某个方法依赖于系统的其他部分(如网络、数据库,甚至是servlet引擎)。在这种情况下,倘若不小心,最后会发现几乎初始化了系统的每个组件就只是为了给某一个测试创造足够的运行环境让它可以运行起来。如果这么做的话,相当于把单元测试做成了集成测试,这也违背了单元测试是要让开发更为高效的初衷。
所以,当所测试的模块需要依赖于外部的环境或者是其它模块的时候,我们就应该考虑去Mock(模拟)一下那些所依赖的模块了。至于Mock的方法,可以自己去写一个所依赖的模块,当然也可以采用第三方的框架——即将整个外界环境整合成一个模拟框架模型(MFM, Mock Framework Model)。
PowerMock具有更为强大的功能,它对现有Mock框架进行了封装,能够弥补EasyMock等框架不能Mock私有方法、静态方法、单例等的缺点,达到其他Mock框架所不能达到的效果。
PowerMock封装的单元测试框架如下图所示:
搭建
在pom文件中添加依赖(dependency):
<dependency>
<groupId>org.powermock</groupId>
<artifactId>powermock-module-junit4</artifactId>
<version>1.6.5</version>
</dependency>
<dependency>
<groupId>org.powermock</groupId>
<artifactId>powermock-api-mockito</artifactId>
<version>1.6.5</version>
</dependency>
注:需要两个插件powermock-api-mockito和powermock-module-junit4
案例分析
功能
在一个数组中找出所有数最大的一个
public class Largest {
public static int largest(int[] list) {
int index, max = Integer.MIN_VALUE;
if(list.length == 0) {
throw new RuntimeException("Empty list!");
}
for(index = 0; index < list.length; index++) {
if(list[index] > max) {
max = list[index];
}
}
return max;
}
}
输入
一个整形数组
输出
整形数组的最大值
测试用例
简单数组
以{7,8,9} 进行测试
public class LargestTest extends TestCase {
public LargestTest(String name) {
super(name);
}
public void testSimple() {
assertEquals(9, Largest.largest(new int[] {7, 8, 9}));
}
}
相同元素
public void testDup() {
assertEquals(9, Largest.largest(new int[] {9, 9, 9}));
}
负数
public void testNegtive() {
assertEquals(-6, Largest.largest(new int[] {-7, -6, -9}));
}
数组为空
public void testEmpty() {
try{
Largest.largest(new int[] {});
fail("There should have thrown an exception");
} catch(RuntimeException e) {
assertTrue(true);
}
}
注:当数组为空时,程序会抛出异常,使用测试代码捕获该异常判断是否为空
junit测试案例
junit断言
JUnit提供的用于帮助开发者确定某个被测函数是否工作正常的辅助函数统称为断言。
断言的每个方法都会记录是否失败了(断言为假)或者有错误了(遇到一个意料外的异常)的情况,并通过JUnit的一些类来报告这些结果。对于命令行版本的JUnit而言,这意味着将会在命令行控制台上显示一些错误消息;对于GUI版本的JUnit而言(如在eclipse里使用JUnit),如果出现失败或者错误,将会显示一个红色的条和一些用于对失败进行详细说明的辅助消息。
当一个失败或者错误出现的时候,当前测试方法的执行流程将会被中止,但是(位于同一个测试类中的)其他测试将会继续运行。
断言是单元测试最基本的组成部分。因此,JUnit程序提供了不同形式的多种断言。
assertEquals
assertEquals([String message], expected, actual);
注:参数expected是开发者所期望的(通常是硬编码),actual是被测代码实际产生的值,message是一个可选的在发生错误时报告的消息。
对于数组:
public void testShuzuEquals() {
int[] a = {1, 2, 4};
int[] b = {1, 2, 4};
assertEquals(a, b);
}
注:数组a,b内容相同,但因引用不同,运行此测试用例时还是在该断言处报错
对于浮点数:
assertEquals([String message], expected, actual, tolerance);
注:需要添加精度tolerance
assertNull 和 assertNotNull
验证一个对象是否为null(或者为非null);message参数是可选的。
assertNull([String message], java.lang.Object object);
([String message], java.lang.Object object);
assertSame 和 assertNotSame
验证expected参数和actual参数所引用的是否为同一个对象
assertSame([String message], expected, actual);
assertNotSame([String message], expected, actual);
assertTrue
验证给定的二元条件是否为真
assertTrue([String message], Boolean condition);
fail
将会使测试立即失败,其中message参数是可选的。这种断言通常被用于标记某个不应该被达到的分支(例如,在一个预期发生的异常之后)。
fail([String message]);
使用断言
一般而言,一个测试方法会包含多个断言。当一个断言失败的时候,该测试方法将会被中止,从而导致该方法中剩余的断言这次就无法执行了。此时开发者必须在继续测试之前先修复这个失败的测试。依此类推,开发者不断地修复一个又一个的测试,沿着这条路径慢慢前进。
应当期望所有的测试在任何时候都能通过。在实践中,这意味着当引入一个bug的时候,只有一到两个测试会失败——这种情况下,把问题分离出来会相当容易。
当有测试失败的时候,无论如何都不能给原有代码再添加新的特性。此时应该尽快地修复这个错误,直到让所有的测试都能顺利通过。
junit测试结构
单元测试类
一个测试类包含一些测试方法;每个方法包含一个或者多个断言语句。
但是测试类也能调用其他测试类:单独的类、包、甚至完整的一个系统。
可以通过继承TestCase
和添加@Test
注解这两种方式来构建单元测试类;但如果想要使用test suite,则只能通过继承TestCase的方式构建单元测试类。
若是继承TestCase的方式构建测试类,则无需在测试类中添加注解来声明某个具体的方法是:一个测试用例or每个测试用例执行之前的初始化操作集合or每个测试用例执行之后的清理操作集合or一个test suite or 每个test suite执行之前的初始化操作集合or每个test suite执行之后的清理操作集合;通过恰当的命名方式,这些对应的方法在该测试类被执行的时候就能被识别出来,如下的代码给出了说明:
public class TestExample extends TestCase {
public TestExample(String method) {
super(method);
}
/*
* 以setUp命名,该方法在每个测试用执行之前会被执行
* 执行每个测试用例执行之前的一些初始化操作
*/
public void setUp() {
//...省略的代码...
}
/*
* 以tearDown命名,该方法在每个测试用例执行之后会被执行
* 执行每个测试用例执行之后的一些清理操作
*/
public void tearDown() {
//...省略的代码...
}
/*
* 以“test”为前缀命名的方法,在继承自TestCase的类中自动被识别为1个测试用例
*/
public void testCase1() {
//...省略的代码...
}
public void testCase2() {
//...省略的代码...
}
/*
* 以oneTimeSetUp命名,该方法在每个test suite执行之前会被执行
* 执行每个test suite执行之前的一些初始化操作
*/
public static void oneTimeSetUp() {
//...省略的代码...
}
/*
* 以oneTimeTearDown命名,该方法在每个test suite执行之后会被执行
* 执行每个test suite执行之后的一些清理操作
*/
public static void oneTimeTearDown() {
//...省略的代码...
}
/*
* 以suite命名,在继承自TestCase的类中自动被识别为一个test suite
*/
public static TestSuite suite() {
TestSuite suite = new TestSuite();
suite.addTest(new TestExample("testCase1"));
suite.addTest(new TestExample("testCase2"));
return suite;
}
}
注:在这种方式构建的单元测试类,如果所有测试方法(测试用例)都不以“test”前缀来命名,则该测试类被执行的时候会报错
若测试类没有继承自TestCase,则需通过加注解的方式将测试类中对应方法声明为:一个测试用例or每个测试用例执行之前的初始化操作集合or每个测试用例执行之后的清理操作集合;如下代码所示:
public class TestExampleTwo {
/*
* @Before注解将注解方法声明为:
* 执行每个测试用例执行前初始化操作的方法
*/
@Before
public void init() {
//...省略的代码...
}
/*
* @After注解将注解方法声明为:
* 执行每个测试用例执行后清理操作的方法
*/
@After
public void clean() {
//...省略的代码...
}
/*
* @Test注解将注解方法声明为一个测试用例
*/
@Test
public void func () {
//...省略的代码...
}
}
自定义Junit断言、异常测试
自定义Junit断言
public class TestProject extends TestCase {
/*
* 自定义断言1——判断一个int型变量是否为偶数
*/
public void assertEvenNumber(String message, int num) {
int division = num / 2;
int mod_num = num - 2*division;
assertEquals(message, 0, mod_num);
}
/*
* 自定义断言2——判断一个int型变量是否为奇数
*/
public void assertOddNumber(String message, int num) {
int division = num / 2;
int mod_num = num - 2*division;
assertEquals(message, 1, mod_num);
}
public void testCase() {
assertOddNumber("Not Odd!", 3);
assertEvenNumber("Not Even!", 16);
}
}
异常测试
处理异常,需要分清以下两种情况
- 从测试代码抛出的可预测异常;
- 由于某个模块(或代码)发生严重错误,而抛出的不可预测异常。
public void testEmpty() {
try{
Largest.largest(new int[] {});
fail("There should have thrown an exception");
} catch(RuntimeException e) {
assertTrue(true);
}
}
注:需要制造一个会引发异常的输入,测试能否捕捉到异常,若不能则用fail使该测试用例失败,否则(捕捉到异常)就使用assertTrue(true)声明该测试用例成功。