之前也一直在用JUnit,感觉自己使用的不够规范,未对其进行全面的了解,感觉未发挥其强大的功能,所以决定再重新整理一遍。(《JUnit实战》读书笔记)
零、前言
xUnit框架的兴起推动了敏捷方法论(agile methodologies)的流行,这再一次推进了迭代开发的发展。敏捷方法论倾向于垂直地编写代码来生成一个有效的用例,而不是水平地编写代码来一层一层地提供服务。为了对所有特性都保持一致的设计,敏捷方法论提倡通过重构来根据需求调整整个代码,单元测试可以保证重构或者改进现有代码的设计不会损坏现有的代码。
同时,单元测试能够帮助我们改进代码,比如如果一个单元测试太长或者太笨拙,那通常就意味着被测试的代码在设计上存在一些问题,需要进行重构。
一、核心概念
1、测试方法
测试方法是单元测试的基本测试单位,是一个以@Test注释的方法。
2、测试类(Test class 或 test case)
测试类是测试方法的容器。
3、测试集(Suite 或 test suite)
有一个或多个测试类组成的一个测试组或测试集合。
4、测试运行器(Runner 或 test runner)
执行测试集的程序。
5、领域对象(Domain Object) 与 测试对象(Test Object)
在单元测试环境中,领域对象指被用来对比和比较的用于应用程序中的对象。任何被测试的对象都被看作是一个领域对象。用来与被测对象(领域对象)交互的对象称为测试对象(Test Object)。一次只能单元测试一个领域对象。(多个领域对象一起测会导致无法定位问题)。
二、规范化约定
1、测试类名称是在被测试类名称的末尾添加“Test”字样。(推荐但不强制)
2、单元测试方法以testXXX模式命名。(推荐但不强制)
3、测试类与生产类之间存在一对一的对应关系(推荐但不强制)
三、核心实现原理
1、每个测试方法都运行于一个新的测试类实例上。
JUnit在调用每个@Test方法之前,会为测试类创建一个新的实例。这有助于提供测试方法之间的独立性。
2、
四、单元测试种类
单元测试分为逻辑单元测试、继承单元测试、功能单元测试。
逻辑单元测试:这种单元测试主要针对一个单独的方法来检查代码。可以通过mock objects或者stub来控制某个特定的测试方法的边界;
集成单元测试:这种单元测试主要用来测试在真实环境(或者真实环境的一部分)中的不同组件之间的相互作用;
功能单元测试:这种单元测试目的是为了确认一个刺激响应,验证某个功能是否正常。
五、测试种类
1、普通测试
2、参数化测试
Parameterized(参数化)的测试运行器允许使用不同的参数多次运行同一个测试。
import static org.junit.Assert.*;
import java.util.Arrays;
import java.util.Collection;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameters;
@RunWith(value=Parameterized.class)
public class ParameterizedTest {
// 必须声明测试中使用的的实例变量
private double expected;
private double valueOne;
private double valueTwo;
// 指定参数的构造
public ParameterizedTest(double expected, double valueOne, double valueTwo) {
this.expected = expected;
this.valueOne = valueOne;
this.valueTwo = valueTwo;
}
// 获取参数值和目标值的方法
@Parameters
public static Collection<Integer[]> getTestParameters() {
return Arrays.asList(new Integer[][] {
{2, 1, 1}, //
{3, 2, 1},
{4, 3, 1},
});
}
// 使用了参数的测试方法
@Test
public void sum() {
Calculator calc = new Calculator();
assertEquals(expected, calc.add(valueOne, valueTwo), 0);
}
}
3、异常测试
单元测试可以模拟异常条件,就像模拟正常条件一样简单。为了测试正常流程,可以创建一个Sample的测试对象,相应的,为了测试异常条件,可以创建一个Sample的异常测试对象,抛出一个异常来模拟。
例如:
private class SampleExceptionHandler implements RequestHandler {
@Override
public Response process(Request request) throws Exception {
throw new Exception("error processing request");
}
}
@Test
public void testProcessRequestAnswersErrorResponse() {
SampleRequest request = new SampleRequest("testError");
SampleExceptionHandler handler = new SampleExceptionHandler();
controller.addHandler(request, handler);
Response response = controller.processRequest(request);
assertNotNull("Must not return a null response", response);
assertEquals(ErrorResponse.class, response.getClass());
}
有些方法按照设计可能需要抛出异常,单元测试需要测试这些方法是否是按照设计的那样抛出了异常,具体做法是为@Test注解的expected参数指定期望抛出的异常参数,例如:
@Test(expected=RuntimeException.class)
public void testGetHandlerNotDefined() {
SampleRequest request = new SampleRequest("testNotDefined");
// The following line is supposed to throw a RuntimeException
controller.getHandler(request);
}
其中,controller.getHandler(request)会抛出RuntimeException异常,该测试用例是可以测试通过的。
4、超时测试
使用JUnit提供的@Test注解的另一个参数timeout。我们可以实现超时测试。timeout参数能够指定一个以毫秒为单位的运行时间。并且如果测试执行超过该指定的时间,JUnit就会把这个测试标记为失败。
例如:
@Test(timeout=130)
public void testProcessMultipleRequestsTimeout() {
Request request;
Response response = null;
RequestHandler handler = new SampleHandler();
for (int i=0; i<99999; i++) {
request = new SampleRequest(String.valueOf(i));
controller.addHandler(request, handler);
response = controller.processRequest(request);
assertNotNull(response);
assertNotSame(ErrorResponse.class, response.getClass());
}
}
其中,该测试用例指定的超时时间为130毫秒,如果执行时间超过130毫秒,该测试用例就会被标记为失败。
1、使用测试集Suite来组合测试
(1)、将多个测试类组合成一个测试集
示例:该例将测试类Case1Test 与 测试类Case2Test组合在一起,形成一个测试集FirstTestSuite
public class Case1Test {
@Test
public void sum() {
System.out.println("this is " + this.getClass().getSimpleName());
Calculator calc = new Calculator();
assertEquals(2, calc.add(1, 1), 0);
}
}
public class Case2Test {
@Test
public void sum() {
System.out.println("this is " + this.getClass().getSimpleName());
Calculator calc = new Calculator();
assertEquals(3, calc.add(2, 1), 0);
}
}
@RunWith(value=org.junit.runners.Suite.class)
@SuiteClasses(value= {Case1Test.class, Case2Test.class})
public class FirstTestSuite {
}
运行FirstTestSuite,得到如下控制台输出:
this is Case1Test
this is Case2Test
(2)、将多个测试集组合成一个测试集
示例:SecondTestSuite是与FirstTestSuite类似的一个测试集,该例将测试集FirstTestSuite与测试集SecondTestSuite组合成一个新的测试集MasterTestSuite
public class Case3Test {
@Test
public void sum() {
System.out.println("this is " + this.getClass().getSimpleName());
Calculator calc = new Calculator();
assertEquals(4, calc.add(2, 2), 0);
}
}
@RunWith(value=org.junit.runners.Suite.class)
@SuiteClasses(value= {Case2Test.class, Case3Test.class})
public class SecondTestSuite {
}
@RunWith(value = Suite.class)
@SuiteClasses(value = {FirstTestSuite.class, SecondTestSuite.class})
public class MasterTestSuite {
}
运行MasterTestSuite,获得如下控制台输出:
this is Case1Test
this is Case2Test
this is Case2Test
this is Case3Test
2、测试驱动开发
避免对接口进行过度设计的一种方法是实践“测试驱动开发(Test-Deriven Development)”,这应该作为编写测试方法的指导。
3、引入Hamcrest匹配器来简化测试中的断言
随着编写越来越多的单元测试和断言,将不可避免地遇到一些问题,比如:一些断言太大并且很难看懂。为了解决这个问题,我们可以引入一个用于构建测试表达式的匹配器库:Hamcrest。该匹配器库(http://code.google.com/p/hamcrest/)包含了大量有用的匹配器对象(也称为约束或者谓词),它可以被植入到其他几种开发语言(如Java、C++、Object-C、Python 和 PHP)中。它可以帮助我们通过声明方式指定简单的匹配规则。这些匹配规则可以在许多不同的情况下使用,但是它们尤其适用于单元测试。
使用Hamcrest匹配器的好处是,它能够在断言失败时提供一些可读的描述信息(这是标准断言无法提供的),断言失败时Hamcrest的栈跟踪信息也比标准断言详细。此外,Hamcrest极具可扩展性,可以自定义编写用来检查某个特定条件的匹配器。
用法示例:
无Hamcrest时:
@Test
public void testWithoutHamcrest() {
assertTrue(values.contains("one")
|| values.contains("two")
|| values.contains("three"));
}
使用Hamcrest时:
@Test
public void testWithHamcrest() {
assertThat(values, hasItem(anyOf(equalTo("one"), equalTo("two"), equalTo("three"))));
}
4、创建良好的测试项目结构
真实的项目中,测试类和领域类要分离开,同时为了测试类能够访问领域类的受保护方法,需要采用“分离但等同”的目录结构(即相同的包,分离的目录)。
把所有测试类和待测试类都放在相同的包名下,但是采用平行目录结构。例如如下目录结构:
七、测试最佳实践
1、对还没有实现的测试代码抛出一个异常
这样做可以防止测试通过并且提醒自己必须实现这部分代码。例如:
@Test
public void testMethod() {
throw new RuntimeException("implement me");
}
2、一次只能单元测试一个对象
单元测试需要两种类型的对象:一种是需要测试的目标对象,被称为领域对象(Domain Object),另一种对象是用来与被测试对象交互的测试对象(Test Object)。单元测试的要点在于,每次测试只能测试一个对象。单元测试的一个至关重要的方面就是细粒度,一个单元测试独立地检查你创建的每一个对象,这样你就可以在问题发生的第一时间把它们隔离起来。如果被测试的对象多于一个,那么一旦这些对象发送了变化,你就无法预测它们将如何相互影响。
注:测试类通常作为测试包中的公有类,或者把测试类作为测试用例类的内部类。
3、选择有意义的测试方法名字
对于测试方法一开始就采用testXXX的命名模式,其中XXX是待测试领域方法的名字。当你需要为同一个方法添加其他测试时,则可以采用testXXXYYY的命名模式,其中YYY说明了测试之间的不同。不要担心你的测试名字变得越来越复杂或冗长。
4、在assert调用中解释失败的原因
无论何时,只要你使用了JUnit的任何assert*方法,就要确保自己使用第一个参数为String类型的那个签名。这个参数让你可以提供一个有意义的文本描述,在断言失败时JUnit的test runner就会显示这个描述,可以更容易地发现失败原因。
5、一个单元测试等于一个@Test方法
不要试图把多个测试塞进一个方法中,这样导致的结果就是测试方法变得更加复杂,难以阅读也难以理解。更糟糕的是,你的测试方法中编写的逻辑越多,测试失败的可能性就越大。
如果你需要在多个测试中使用相同的代码块,那么把它提取出来作为一个工具方法,每个测试方法就都可以调用这个工具方法。如果所有的方法都共享这段代码,那么更好的做法是把它放进fixture(如@Before、@After等)中。
确保每个测试方法都使用assert调用,唯一可以接受不使用assert调用的情况是当抛出一个异常来指出一个错误条件时。
6、测试任何可能失败的事物
“任何可能出错的事情,最终都会出错”,应该测试任何可能出错的地方,程序应该优雅地捕获、记录并解释所有的错误。
7、使异常测试更易于阅读
通常@Test注释中的expected参数会明确告知开发者,应该产生什么类型的异常。但是可以更近一步。除了以一种清晰易懂的方式命名你的测试方法,来表示这个方法要测试一个异常条件,也可以向产生expected异常的代码行中添加一些代码注释,以突出显示。
8、让测试改善代码
编写单元测试常常有助于你写出更好的代码。一个测试用例就是一位你的代码的用户,只有在使用代码时你才能发现代码的缺点。因此,不要犹豫,应当根据测试时发现的问题重构代码,使其更加易于使用。
9、总是为跳过测试说明原因
在JUnit4.x中通过@Ignore注解可以实现忽略某个测试方法,例如:
@Test(timeout=130)
@Ignore(value="Ignore for now until we decide a decent time-limit")
public void testProcessMultipleRequestsTimeout() {
....
}
在JUnit 4.x中,当你使用@Ignore注解方法时,就会获得详细的统计数据,除了通过和失败的测试数量,还包括JUnit跳过了多少测试。
一条最佳实践就是我们要通过@Ignore注解的value参数说明为什么需要跳过该测试的执行。
附录:
1、注解清单
@Test
参数:expected
@Before
@After
@BeforeClass
@AfterClass
注:@Before/@After、@BeforeClass/@AfterClass这两组注解标注的方法必须为公有访问权限。其中@BeforeClass/@AfterClass标注的方法必须为公有且静态的方法。
2、assert清单
assertSame
assertEquals
assertNotNull