JUnit + Mockito 单元测试

本文详细介绍了JUnit和Mockito在Java单元测试中的应用,包括如何添加依赖、编写测试类、使用JUnit注解、理解Mockito的Mock对象配置、参数匹配器、验证机制等关键概念,以及如何通过Answer接口获取参数内容。通过实践案例,读者能够掌握构建高效、可靠的单元测试代码的技巧。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

单元测试(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 可以仿真任何环境的对象,主要提供足够的细节和模拟,甚至“以假乱真”亦可以,十分强大,为了成功运行单元测试提供了重要的辅助手段。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

sp42a

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值