单元测试(Unit Test)是以最小粒度来测试某个功能或代码块。一般由程序员来做,因为它需要知道内部程序设计和编码的细节。对于持续发展的产品,单元测试在后期的维护和重构,回归有重要等方面有重要作用。不要认为烦琐多余就不写测试代码。相反而言编写测试代码会使你的压力逐渐减轻,因为通过编写测试代码,可以对类的行为有了确切的认识,使得更快地编写出有效率地工作代码。
AJAXJS 推崇单元测试驱动(TDD)的开发模式,主要使用的工具有 JUnit + Mockito。本文就根据此两点展开讨论。
JUnit 入门
JUnit 几乎是 Java 单元测试的标配库,主流版本是 JUnit 4/5。首先是添加 JUnit 依赖。主流 IDE 均集成 JUnit 库,以 Eclipse 为例子,通过鼠标右击项目名称,进入构建的配置,如下插图所示。
然后点击库【Libraries】→添加库【Add Library...】→选择 JUnit,我们选择 4 的版本即可。
现在可以新建一个测试类,一般保存在 /src/test/java/ 自定义包名的目录下。在测试类编写以下方法。
import static org.junit.Assert.assertEquals;
import org.junit.Test;
public class MyTest {
class MyClass {
public int multiply(int x, int y) {
if (x > 999) {
throw new IllegalArgumentException("X 必须小于 1000");
}
return x * y;
}
}
@Test
public void testMultiply() {
MyClass tester = new MyClass();
assertEquals("10 x 5 等于 50 ", 50, tester.multiply(10, 5));
}
}
在测试类中并不是每一个方法都是用于测试的,必须使用以 @ 作为开头“注解”来明确表明哪些是要测试的方法,例如 @Test 是必须的注解,表明这是一个测试用例。可以看到在某些方法的前有 @Before、@Test、@Ignore 等字样,这些都是 JUnit 提供的注解,掌握这些标注的含义非常重要。接着你要测试哪个类,首先就要创建一个该类的对象,如:
MyClass tester = new MyClass();
这里的 assertEquals() 是 Assert 静态方法。Assert 包含了一组静态的测试方法,用于期望值和实际值比对是否正确,即测试失败,Assert 类就会抛出一个 AssertionFailedError 异常,JUnit 将这种错误归入 Failes 并加以记录,同时标志为未通过测试。如果该类方法中指定一个 String 类型的传参则该参数将被做为 AssertionFailedError 异常的标识信息,告诉测试人员改异常的详细信息。然后运行这个测试用例,右键点击需要测试的类,右击选择【Run】→【Run As】→【JUnit Test】,或者快捷键 Alt+Shift+X, T,如下插图 所示。
运行返回的结果如下插图所示。
进度条是红颜色表示发现了错误,具体的测试结果在进度条上面有表示“共进行了 4 个测试,其中 1 个测试被忽略,一个测试失败”。下面补充 JUnit 中常用的注解。
注解 | 作用 |
@Test (expected = Exception.class) | 表示预期会抛出 Exception.class 的异常 |
@Ignore | 表示“某些方法尚未完成,暂不参与此次测试”。这样的话测试结果就会提示你有几个测试被忽略,而不是失败。一旦你完成了相应函数,只需要把 @Ignore 注解删去,就可以进行正常的测试 |
@Test(timeout=100) | 表示预期方法执行不会超过 100 毫秒,以便控制死循环 |
@Before | 表示该方法在每一个测试方法之前运行,可以使用该方法进行初始化之类的操作 |
@After | 表示该方法在每一个测试方法之前运行,可以使用该方法进行初始化之类的操作 |
@BeforeClass | 表示该方法只执行一次,并且在所有方法之前执行。一般可以使用该方法进行数据库连接操作,注意该注解运用在静态方法 |
@AfterClass | 表示该方法只执行一次,并且在所有方法之后执行。一般可以使用该方法进行数据库连接关闭操作,注意该注解运用在静态方法。 |
下面介绍上边用到的静态类 junit.framework.Assert。该类主要包含七个方法。
• assertEquals() 方法,用来查看对象中存的值是否是期待的值,与字符串比较中使用的 equals() 方法类似;
• assertFalse() 和 assertTrue() 方法,用来查看变量是是否为 false 或 true,如果 assertFalse() 查看的变量的值是false则测试成功,如果是 true 则失败,assertTrue() 与之相反;
• assertSame() 和 assertNotSame() 方法,用来比较两个对象的引用是否相等和不相等,类似于通过“==”和“!=”比较两个对象;
• assertNull() 和 assertNotNull() 方法,用来查看对象是否为空和不为空。
事实上在 JUnit 中使用 try-catch 来捕获异常是没有必要的,JUnit 会自动捕获异常。那些没有被捕获的异常就被当成错误处理。
Mock 入门
JUnit 在单元测试框架这方面已经足够好了。Mockito 则与 JUnit 不同,并不是单元测试框架,准确说它是用于生成模拟对象(Mock,或称“假对象”)的工具。两者各自分工,一般联合在一起进行测试。
Mockito 没有集成到 IDE,通过下面的 Maven 坐标来导入。注意添加 <scope>provided</scope>,表明该包只在编译和测试的时候用。
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-all</artifactId>
<version>2.0.2-beta</version>
<scope>provided</scope>
</dependency>
首先是配置 Mock 对象,看看例子怎么写的。
import static org.junit.Assert.assertEquals;
import java.util.List;
import org.junit.Test;
import static org.mockito.Mockito.*;
public class TestMockito {
@Test
public void testStub() {
List mock = mock(List.class);
when(mock.get(0)).thenReturn(1);
assertEquals("预期返回 1", 1, mock.get(0));// mock.get(0) 返回 1
}
}
其中变量 mock 是模拟 List 的对象,拥有 List 的所有方法和属性。when(xxx).thenReturn(yyy); 是指定当执行了这个方法的时候,返回 thenReturn 的值,相当于是对模拟对象的配置过程,为某些条件给定一个预期期望的返回值。相信通过这个简单的例子你可以明白所谓 Mock 便是这么一回事。
java.util.List 是接口并不是实现类,但这不妨碍我们使用它作为我们的“打桩”对象,——当然你也可以使用实现类,传入 mock(obj) 方法中。这里提到的是打桩(Stub)的概念是一个形象的说法,也有人称其为“存根”,它就是把所需的测试数据塞进对象中,适用于基于状态的测试,关注的是输入和输出。Mockito 中 when(…).thenReturn(…) 那样的语法用来定义对象方法和参数(输入),然后在 thenReturn 中指定结果(输出)——此过程称为 Stub 打桩。打桩的时候还需要注意以下几点。
• 对于 static 和 final 方法,Mockito 无法对其 when(…).thenReturn(…) 操作
• 当我们连续两次为同一个方法使用 stub 的时候,他只会只用最新的一次
• 一旦这个方法被打桩了会一直返回这个 stub 的值
mock 对象会覆盖整个被 mock 的对象,因此没有 stub 的方法只能返回默认值。又因为当 mock 一个接口的时候,很多成员方法只是一个签名,并没有实现,那就要我们手动写出这些实现方法。典型地模拟一个 reques 请求对象,被测试的代码中正在调用了 HttpSerevletRequest 什么方法那便要写出相应的实现方法,下面假设调用了 getParameter() 方法,那么就要写出:
HttpServletRequest request = mock(HttpServletRequest.class); when(request.getParameter("foo")).thenReturn("boo");
这里打桩之后执行 request.getParamter("foo") 会返回 boo,如果不这样设定 Mockito 会返回默认的 null,也不会报错说这个方法找不到。mock 实例默认的会给所有的方法添加基本实现:返回null或空集合,或者 0 等基本类型的值。这取决于方法返回类型,如int会返回0,布尔值返回false。对于其他类型会返回 null。
参数匹配器
这里 getParameter("foo") 这里我们是写死参数 foo 的,但是如果不关心输入的具体内容可以吗?是可以的,最好能像正则表达式那样,/w+ 表示任意字符串是不是很方便,不用考虑具体什么参数,只要是“字符串”类型的参数便可以打桩。如此方便的需求 Mockito 也能满足,它提供了参数匹配器 argument matchers 机制模拟传入的参数,例如 anyString() 匹配任何 String 类型参数,anyInt() 匹配任何int类型参数,anySet() 匹配任何 Set,any() 则意味着参数为任意值。例子如下,
List mock = mock(List.class);
when(mock.get(anyInt())).thenReturn(888);
assertEquals("预期返回 888", 888, mock.get(1));
assertEquals("预期返回 888", 888, mock.get(99));
其他自定义类型也可以,例如 any(User.class)。
上述我们一直在讨论被测试的方法都有返回值的,那么没有返回值的 void 方法呢?也是测试吗?答案是肯定的——只不过 Mockito 要求你的写法上有不同:因为都没返回值了,调用 thenReturn(xxx) 肯定不行,取而代之的写法是:
List mock = mock(List.class);
doNothing().when(mock).clear();
mock.clear();
Mockito 还能对被测试的方法强行抛出异常。
when(i.next()).thenThrow(new RuntimeException());
doThrow(new RuntimeException()).when(I).remove(); // void 方法的
// 迭代风格
doNothing().doThrow(new RuntimeException()).when(i).remove();
// 第一次调用 remove 方法什么都不做,第二次调用抛出 RuntimeException 异常。
打桩支持迭代风格的返回值设定,例如。
// 第一种方式
when(i.next()).thenReturn("Hello").thenReturn("World");
// 第二种方式
when(i.next()).thenReturn("Hello", "World");
// 第三种方式,都是等价的
when(i.next()).thenReturn("Hello");
when(i.next()).thenReturn("World");
第一次调用 i.next() 将返回 Hello,第二次的调用会返回 World。
获取返回的结果
thenReturn() 的返回结果是写死的,如果要让被测试的方法不写死,返回实际结果并让我们可以获取到的——怎么做呢?为了让自定义方法执行的返回结果可以使用 Answer 接口,例如 HttpServletRequest 获取属性的方法 request.getAttribute(key),由于那是接口,所以需要开发者自己编写 when(…).thenReturn(…) 实现。下面的例子便是通过 Answer 接口获取参数内容。
HttpServletRequest request = mock(HttpServletRequest.class);
final Map<String, Object> hash = new HashMap<>();
hash.put("errMsg", "Foo");
Answer<Object> anwser = new Answer<Object>() {
@Override
public Object answer(InvocationOnMock invocation) {
Object[] args = invocation.getArguments();// 执行方法的送入的参数
return hash.get(args[0].toString());
}
};
when(request.getAttribute("isRawOutput")).thenReturn(true);
when(request.getAttribute("errMsg")).thenAnswer(anwser);
when(request.getAttribute("msg")).thenAnswer(anwser);
assertEquals(true, request.getAttribute("isRawOutput"));// 已给出实现,跳过 anwser
assertEquals("Foo", request.getAttribute("errMsg"));// 经过 anwser 返回
assertNull( request.getAttribute("msg")); // 没有给出实现
Answer 唯一需要重写的方法为 answer(),其参数 InvocationOnMock 提供了不少方法可以获取 mock 方法有关的调用信息,整理如下:
• invocation.getArguments() 调用后会以 Object 数组的方式返回 mock 方法调用的参数
• invocation.getMethod() 返回 java.lang.reflect.Method 对象
• invocation.getMock() 返回 mock 对象
• invocation.callRealMethod() 真实方法调用,如果 mock 的是接口它将会抛出异常
Answer 的泛型应与 Mock 方法的返回类型一致。
Mock对象的void方法可以获取参数,由于没有返回值,所以显然不可能存在 thenAnswer(),参见下面不同的写法,doAnswer() 写在前头,不过 anwser 回调函数依然要返回 Object 类型的返回值。
// 设置 request 属性
HttpServletRequest request = mock(HttpServletRequest.class);
final Map<String, Object> hash = new HashMap<>();
doAnswer(new Answer<Object>() {
@Override
public Object answer(InvocationOnMock invocation) {
Object[] args = invocation.getArguments();
// Object mock = invocation.getMock();
System.out.println(args[1]);
hash.put(args[0].toString(), args[1]);
return "called with arguments: " + args;
}
}).when(request).setAttribute(anyString(), anyString());
request.setAttribute("isRawOutput", true);
request.setAttribute("errMsg", "bar");
Mock 对象如果不是接口而是实现类的话,则可以不用另外写出实现。
验证 Verify
一旦使用mock()对模拟对象打桩,意味着Mockito会记录着这个模拟对象调用了什么方法,记录调用了多少次。——这个功能有什么用?就像网站的游客计数器,我们设计许多许多的功能点固然是一方面,但是有时候需要了解这些功能或者页面访问了多少次、效果怎么样、交互了什么对象等等,归结在一起就让网络统计器发挥了作用,——单元测试的验证(Verify)也是类似的概念。即便模拟对象成功,但不一定都执行了期望的方法,到底有没有执行过呢?有时候可能是漏了执行,有了验证机制的话便可保证能否通过测试。
相比来说,前面提到的when(……).thenReturn(……)属于状态测试,某些时候测试不关心返回结果,而是Verify则侧重方法有否被正确的参数调用过,从概念上讲是和状态测试所不同的“行为测试”了。也就是说,单单通过结果来判断正确与否还是不够的,还要判断是否按我指定的路径执行的用例。verify()说明其作用的例子。
Map mock = mock(Map.class);
mock.get("foo");
verify(mock).get("foo");// 是否调用了一次?如果 times 不传入,则默认是 1
mock.get("foo");
mock.get("bar");
verify(mock, times(2)).get("foo"); // 调用了两次
verify(mock, times(3)).get(anyString()); // 不指定具体值,符合的类型即可
verify内部跟踪了所有的方法调用和参数的调用情况,然后会返回一个结果说明是否通过。也就是说这是对历史记录作一种回溯校验的处理。Mockito除了提供times(N)方法供调用外还提供了很多可选的方法:
• never()没有被调用,相当于times(0)
• atLeast(N)至少被调用N次
• atLeastOnce()相当于atLeast(1)
• atMost(N)最多被调用N次
verify也可以使用模拟参数,若方法中的某一个参数使用了matcher则所有的参数都必须使用matcher,例如上一例的不指定具体值,符合的类型:verify(mock, times(3)).get(anyString()),再接着看下一例。
when(mock.put(anyInt(), anyString())).thenReturn("world");
mock.put(1, "hello");
verify(mock).put(anyInt(), eq("hello"));
在最后的验证时如果只输入字符串hello是会报错的,必须使用Matchers类内建的eq方法。
另外还可以关注参数有否传入。
verify(mock, times(2)).get(Matchers.eq("foo"));// 关注参数有否传入
其他高级用法还有以下几点,限于篇幅关系就不展开论述了。
• 参数验证,利用 ArgumentCaptor(参数捕获器)捕获方法参数进行验证。
• 超时验证,通过 timeout 并制定毫秒数验证超时。注意如果被调用多次 times 还是需要的。
• 方法调用顺序,通过 InOrder 对象,验证方法的执行顺序。
• verifyNoMoreInteractions 查询是否存在被调用,但未被验证的方法,如果存在则抛出异常。
• verifyZeroInteractions 查询对象是否未产生交互,如果传入的 mock 对象的方法被调用过,则抛出异常。
Spy 用法
前面说的 Mock 均为根据接口生成的假对象,设置参数和返回值进行打桩,——那么换作真实的对象呢?能打桩不?虚假的对象既然都可以了,没理由真实的对象不行,Spy 正是为真实对象服务的,Mockito 称之为 Spy 监视。spy() 基本上和 mock() 方法一致,只是前者参数务必为真实对象,后者为接口类而已。
List<Integer> list = new ArrayList<>();
List<Integer> spy = spy(list);
when(spy.size()).thenReturn(100);
assertEquals(100, spy.size());
spy.add(0);
spy.add(1);
assertTrue(0 == spy.get(0));
verify(spy).add(0);
verify(spy).add(1);
真实对象打桩过程中应多采用 doReturn(XXX).when(spy).foo(),把返回值写在前头,先输入返回值,那样可以避免真实对象方法的某些异常。例如这个例子中的 list,如果打桩直接使用 when(spy.get(99)).thenReturn(99) 会产生 IndexOutOfBoundsException 异常,于是改用 doReturn() 预先设值,如下所示。
doReturn(99).when(spy).get(99);
assertTrue(99 == spy.get(99));
Mock 可以仿真任何环境的对象,主要提供足够的细节和模拟,甚至“以假乱真”亦可以,十分强大,为了成功运行单元测试提供了重要的辅助手段。