1. 序言
1.1 工作中要求进行单元测试
- 毕业进入公司时,为了锻炼笔者的Java基础,老大给笔者分配了平台化开发的工作,基于Spring Boot + Mybatis的Java Web后端开发
- 一个人干后端开发,且以前也没有后端开发的经验,所以只是简单地模仿前人的代码,几乎没有使用任何的设计模式或者常见的Spring技术
- 反正一句话:用最简单的语法,编写最冗长的代码,仅满足当时需求,且不考虑后续扩展
- 在开发初期,组长就提出小组开发规范:开发人员要编写单元测试,以保证代码开发质量
- 长期摸索后,自己的单元测试代码写得叫一个 “溜”
- 最近学习了一些单元测试的知识后,回想自己的单元测试代码,就是一坨💩 😂
1.2 说说自己的单元测试代码
-
以一个判断用户是否为系统管理员的service方法为例:
// 对应的interface public interface AuthorityService { boolean isSystemAdministrator(String user); } // 对应的service实现 @Service public class AuthorityServiceImpl implements AuthorityService { private final static Logger logger = LoggerFactory.getLogger(AuthorityServiceImpl.class); @Autowired private AuthorityMapper authorityMapper; @Override public boolean isSystemAdministrator(String user) { // 系统管理员的定义:属于system项目,且角色为SYSTEM Authority authority = authorityMapper.getByUserAndProject(user, "system"); if (authority != null && Role.SYSTEM.equals(authority.getRole())) { return true; } return false; } }
-
MySQL数据库的访问,是使用Mapper接口 + Mapper.xml实现的
// 对应的Mapper.xml实现,这里省略 public interface AuthorityMapper extends BaseMapper<Authority> { Authority getByUserAndProject(String user, String project); }
-
单元测试的逻辑(这里暂不考虑代码执行存在异常的情况):
- system资源组中,不存在该用户,直接返回false
- system资源组中,存在用户且角色为
SYSTEM
,返回true;否则,返回false
-
整体的单元测试代码如下:
// 这里一系列的注解,都是为了能在单元测试时启动整个服务,比如连接数据库、访问配置中心等 // 这里主要是为了实现数据库的连接 @RunWith(SpringRunner.class) @SpringBootTest(classes = PlatformApplication.class) @DirtiesContext public class AuthorityServiceImplTest { private final static Logger logger = LoggerFactory.getLogger(AuthorityServiceImplTest.class); @Rule public MockitoRule rule = MockitoJUnit.rule(); @Autowired AuthorityService authorityService; @Test @Transactional // 单元测试结束后,会清理数据库中的记录 public void testIsSystemAdministrator() { // 插入一条权限记录 String user = RandomStringUtils.random(8, false, true); Authority authority = new Authority(user, Role.SYSTEM, "11120066", "system_test", "2020-10-26 12:24:45", true); authorityService.add(authority); // 资源组不是system,因此直接返回false Assert.assertFalse(authorityService.isSystemAdministrator(user)); // 插入一条权限记录 user = RandomStringUtils.random(8, false, true); authority = new Authority(user, Role.SYSTEM, "11120066", "system", "2020-10-26 12:24:45", true); authorityService.add(authority); // 项目为system且角色为SYSTEM,是系统管管理员,返回true Assert.assertTrue(authorityService.isSystemAdministrator(user)); // 插入一条权限记录 user = RandomStringUtils.random(8, false, true); authority = new Authority(user, Role.NORMAL, "11120066", "system", "2020-10-26 12:24:45", true); authorityService.add(authority); // 项目为system,但角色不是SYSTEM,不是系统管理员,返回false Assert.assertFalse(authorityService.isSystemAdministrator(user)); } }
-
主要使用到了如下的jar包依赖:
<dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.12</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-web</artifactId> <version>4.3.22.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-test</artifactId> <version>1.5.19.RELEASE</version> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-test</artifactId> <version>4.3.22.RELEASE</version> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>4.3.22.RELEASE</version> </dependency>
1.3 存在的问题
- 从单元测试的代码可以看出,service方法依赖DAO层,也就是需要访问数据库
- 为了能在单元测试时连接数据库,笔者使用了AuthorityServiceImplTest类开头的三个注解,使得单元测试时能启动整个应用
- 同时,笔者在测试时以代码的形式自动向数据库插入权限记录,并使用
@Transactional
注解在单元测试执行完后清理数据 - 这样可以避免手动建造数据或者后期数据发生变化,导致单元测试无法执行的情况
- 就算是这样的单元测试,也存在一些问题,引用其他博客的描述就是:
- This unit test is slow, because you need to start a database in order to get data from DAO.
- This unit test is not isolated, it always depends on external resources like database.
- This unit test can’t ensures the test condition is always the same, the data in the database may vary in time.
- It’s too much work to test a simple method, cause developers skipping the test.
- 总之一句话,我们希望单元测试是简单、快速且可靠的,而非像现在这样负载、缓慢且不稳定
In summary, what we want is a simple, fast, and reliable unit test instead of a potentially complex, slow, and flaky test!
1.4 正确的单元测试
- 使用Mockit实现DAO层接口的模拟,使其无需访问数据库,就能在在特定入参条件下,返回特定的值
- 这里只展示测试逻辑中的一种,用户是系统管理员的情况
import org.junit.Assert; import org.junit.Test; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; public class AuthorityServiceTest { @Test public void testIsSystemAdministrator() { // 1. 构建权限记录,作为DAO层方法的、指定的返回值 Authority authority = new Authority("sunrise", Role.SYSTEM, "system", "jack"); // 2. 使用Mockit模拟出一个mapper AuthorityMapper mapper = mock(AuthorityMapper.class); // 3. 设置访问方法的返回值 when(mapper.getByUserAndProject("sunrise", "system")).thenReturn(authority); // 4. 创建service,传入mock出来的mapper AuthorityServiceImpl service = new AuthorityServiceImpl(); service.setAuthorityMapper(mapper); // 5. 访问方法,将调用AuthorityMapper的getByUserAndProject()方法,返回指定的authority // 从而使得isSystemAdministrator()判断的结果为true Assert.assertTrue(service.isSystemAdministrator("sunrise")); } }
- JUnit和Mockit框架中,很多方法都是静态方法,为了使得的代码简洁,通过import static进行引入。
- 为了节省篇幅,后续代码示例,将不再给出引用static方法的import语句
- 一些参考链接:
- Unit Test – What is Mocking? and Why?(为什么使用Mockit模式外部服务,娓娓道来)
- SpringBoot使用Junit测试 防止事物自动回滚(关于注解
@Transactional
的使用) - JUnit 5 tutorial - Learn how to write unit tests(JUnit5使用教程)
2. 关于单元测试
- 《Best Practices For Unit Testing In Java》中,是这样描述单元测试的:
- 单元测试是软件设计和实现中的关键步骤,它不仅可以提高代码的效率和有效性(efficiency and effectiveness,翻译可能不是很准确),还使得代码更加健壮(robust),减少了以后开发和维护中的回归(regression)-
- 通过创建不同的测试用例,以验证每个独立的源代码单元的表现
- 博文提出的关于单元测试的一些最佳实践包括但不限于:
- 测试代码的开发、执行和维护,都应该与生产代码独立开来。例如,在maven项目中,测试代码存放在
src/main/test
,生产代码存放在src/main/java
- 测试代码的包名,应该与生产代码的包名保持一致
- 测试名应该是有洞察力的(insightful),用户只需要看一眼测试名就能了解该测试的行为和期望(容易导致方法名很长,感觉很难做到)
- 期望值和真实值,可以使用合适的断言(assert)进行判断。例如,使用JUnit中的
Assert.assertTrue()
判断boolean类型,使用Assert.assertEquals()
判断数值、字符串或其他自定义类 - 尽量保证一个单元测试,只测试一种特定场景。
- 例如,测试用户是否为系统管理员,三种场景可以编写三个单元测试
- 这样可以避免单元测试变得复杂而难以理解,还有利于后续调试和维护单元测试。
- 使用Mockit、EasyMock、JMockit等模拟(
mock
)外部服务,只关注被测代码在不同场景下的执行逻辑 - 对于相似的单元测试,对数据或外部服务的mock,可以提取到一个方法中,避免代码冗余
- 单元测试应该覆盖 80% 的生产代码
- … …, 详情见《Best Practices For Unit Testing In Java》
- 测试代码的开发、执行和维护,都应该与生产代码独立开来。例如,在maven项目中,测试代码存放在
3. Mockit
3.1 mock & test doubles
mock的重要性
- mock(模拟)应该是测试领域的一个概念,即使用替身(doubles)替代真实的对象
- 我们可以控制替身的行为,从而达到mock真实对象的目的
- 就像本文的开头,想要判断一个用户是否为系统管理员,需要访问MySQL数据库。
- 如果访问一个真实的数据库,费时费力、还不保证后续的稳定性。
- 这时,我们可以模拟出一个Mapper对象,它可以在我们输入特定的条件时,返回特定的结果
- mock出的Mapper对象,使得单元测试简单、快速且可靠
test doubles
- mock出的替身,又叫test doubles
- test doubles有多种类型,每种类型有自己的职责,比较常见的类型包括:stub、mock、spy、fake、dummy等
- 网上对于这些类型的描述五花八门,让人脑壳发晕
- 笔者认为,test doubles的类型实际可以狭义地理解为如何使用替身
- stub:用于返回预定义的值,通常用于模拟数据库
- spy:一种stub,但还记录了替身被调用的信息,如被调用的次数,可以用于验证替身是否被调用
- 例如,DAO层的
delete()
方法没有返回值,单元测试时,只需要关注该替身的delete()方法否被调用
- 例如,DAO层的
- mock(名词):根据入参返回期望的值,可以用于验证被测单元是否按照预期与替身进行交互
- 一旦按照期望的方式调用了替身,替身将返回一个指定的值
- 对于上述三种替身,博客《stub fake spy mock区别》给出的伪代码,可能比较符合笔者的理解
- PS:
- 笔者直到使用Mockit进行单元测试时,也没有很好地分清stub和mock的区别 😂
- 只能根据实际需求,知道自己该使用哪些Mockit函数定义替身的行为
- 关于test doubles各种类型的讲解,可以参考如下链接:
- 基于Spock介绍mock和stub:spock测试桩mock和stub的区别及使用场景
- Martin Fowler大佬的文章:Mocks Aren’t Stubs
3.2 如何使用Mockit创建替身?
3.2.1 使用Plain Mockit
-
所谓的Plain Mockit,就是使用Mockit的 静态 方法
mock()
创建一个替身 -
例如,前面创建AuthorityMapper替身时,就是使用的plain Mockit
AuthorityMapper mapper = Mockit.mock(AuthorityMapper.class);
3.2.2 使用Mockit注解
-
下面的代码展示了,如何使用
@Mock
注解创建替身 -
注意: 使用
@Mock
注解只是标识这是一个替身,还需要通过MockitoAnnotations.initMocks()
初始化替身// 必须添加该注解,否则替身无法初始化,使用时将抛出NullPointerException @RunWith(MockitoJUnitRunner.class) public class MyAuthorityServiceTest { @Mock private AuthorityMapper mock; // 只是添加了@Mock注解,并没有创建替身 @Before // 替身的初始化,需要在测试类的启动方法中完成,因此使用@Before标识setUp()方法 public void setUp() { // initMocks()负责为当前类中,添加了@Mock注解的字段或入参创建替身 MockitoAnnotations.initMocks(this); } @Test public void testIsSystemAdministrator() { Authority authority = new Authority("sunrise", Role.SYSTEM, "system", "jack"); when(mock.getByUserAndProject("sunrise", "system")).thenReturn(authority); AuthorityServiceImpl service = new AuthorityServiceImpl(); service.setAuthorityMapper(mock); Assert.assertTrue(service.isSystemAdministrator("sunrise")); } }
-
注意:
@RunWith(MockitoJUnitRunner.class)
是替身成功初始化的关键 -
感谢Stack Overflow的提问:mock instance is null after @Mock annotation
3.2.3 使用JUnit Jupiter的MockitoExtension
-
使用
@Mock
注解仍然无法实现替身的自动创建,JUnit Jupiter的MockitoExtension
可以实现 -
具体代码如下:
@RunWith(MockitoJUnitRunner.class) @ExtendWith(MockitoExtension.class) public class MyAuthorityServiceTest { @Mock private AuthorityMapper mock; @Test public void testIsSystemAdministrator() { Authority authority = new Authority("sunrise", Role.SYSTEM, "system", "jack"); when(mock.getByUserAndProject("sunrise", "system")).thenReturn(authority); AuthorityServiceImpl service = new AuthorityServiceImpl(); service.setAuthorityMapper(mock); Assert.assertTrue(service.isSystemAdministrator("sunrise")); } }
-
注意: 需要将JUnit从4升级到5,否则单元测试会报错
java.lang.NoClassDefFoundError: org/junit/jupiter/api/extension/ScriptEvaluationException
<!-- MockitoExtension对应的依赖 --> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-junit-jupiter</artifactId> <version>2.17.0</version> <scope>test</scope> </dependency> <!-- JUnit 5对应的依赖 --> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-engine</artifactId> <version>5.2.0</version> <scope>test</scope> </dependency> <dependency> <groupId>org.junit.platform</groupId> <artifactId>junit-platform-runner</artifactId> <version>1.2.0</version> <scope>test</scope> </dependency>
- 感谢stack overflow中的answer给我灵感, Junit Test error : org/junit/jupiter/api/extension/ScriptEvaluationException
- 关于如何将JUnit从4升级到5,参考博客:JUnit Setup Maven - JUnit 4 and JUnit 5
4. 如何使用Mockit定义替身的行为?
- 替身实际就是一个对象,要想替身在测试中发挥作用,就得在调用替身的某个方法时,替身能做出一定的响应
- 例如:
- 有返回值的方法,调用该方法时,需要返回一个值。
- 没有返回值的方法,调用该方法时,希望能通过统计方法的调用次数,确定该方法是否被调用
4.1 when().thenReturn()
:让替身返回指定值
- 在1.4 中,使用
when().thenReturn()
,定义了替身在getByUserAndProject()
方法的行为:以入参sunrise、system调用getByUserAndProject()方法,则返回指定的权限记录when(mapper.getByUserAndProject("sunrise", "system")).thenReturn(authority);
when(x).thenReturn(y)
的含义:When the x method is called then return y- 在没有
overwrite
替身行为的情况下,只要getByUserAndProject()方法的入参为sunrise、system,多次调用该方法都将返回同样的权限记录
4.1.1 定义多个返回值
-
有时,我们希望连续调用替身的方法时,替身能展现出不同的行为(如返回不同的值)
-
这时,如果使用多个
when().thenReturn();
,代码将显得非常冗余 -
希望能像Builder模式设置属性一样,能一次定义多个返回值 —— Mockit支持该特性
@Test public void isSystemAdministratorTest() { Authority authority1 = new Authority("sunrise", Role.SYSTEM, "system", "jack"); Authority authority2 = new Authority("sunrise", Role.NORMAL, "system", "jack"); AuthorityMapper mapper = mock(AuthorityMapper.class); // 定义不同的返回值,以便在连续方法调用中返回不同的值 when(mapper.getByUserAndProject("sunrise", "system")) .thenReturn(authority1) .thenReturn(authority2) .thenReturn(null); AuthorityServiceImpl service = new AuthorityServiceImpl(); service.setAuthorityMapper(mapper); // 测试sunrise为系统管理员的情况 assertTrue(service.isSystemAdministrator("sunrise")); // 测试sunrise权限不足,不是系统管理员的情况 assertFalse(service.isSystemAdministrator("sunrise")); // 测试sunrise的信息不存在的情况 assertFalse(service.isSystemAdministrator("sunrise")); // 注意: 后续的调用,替身都将返回null assertFalse(service.isSystemAdministrator("sunrise")); }
-
除了上述这种多次
thenReturn()
的写法,还可以精简为一个thenReturn()
when(mapper.getByUserAndResource("sunrise", "system")) .thenReturn(authority1, authority2, null);
4.1.2 替身方法的默认返回值
-
上面的代码示例,调用替身方法时,都使用了符合要求的入参,因此都能返回
thenReturn()
中指定的值 -
如果方法入参不满足
when()
中规定的条件,则将根据方法的返回值类型,返回一个默认值- 针对数值类型的原子/原子包装类型,如
int/Integer
,将返回0 - 针对布尔类型的原子/原子包装类型,
boolean/Boolean
,将返回false - 其他类型将返回null
- 针对数值类型的原子/原子包装类型,如
-
下面的代码,展示了替身方法的默认返回值
@Test public void defaultReturnValueTest() { Authority authority = new Authority("sunrise", Role.SYSTEM, "system", "jack"); AuthorityMapper mapper = mock(AuthorityMapper.class); // 返回值为Authority类型 when(mapper.getByUserAndResource("sunrise", "system")).thenReturn(authority); // 入参不符合要求,返回默认值null assertNull(mapper.getByUserAndResource("jack", "test-project")); // insert()方法的返回值为int类型,即affected rows when(mapper.insert(authority)).thenReturn(1); // 入参不符合要求,返回默认值0 assertEquals(0, mapper.insert(new Authority())); }
4.1.3 不关心方法入参的具体值
-
在进行单元测试时,我们不关心方法入参的具体值,只要类型符合要求,替身都能返回相同的值
-
例如,DAO层的insert操作,无论插入什么样的记录,默认成功插入,返回affected rows为1
-
这时候可以使用
ArgumentMatchers
提供的各种静态any()/anyX()
方法any(Class<T> type)
:匹配任何指定Class类型的入参,不包括nullanyObject()
:允许任何对象作为入参,包括null- 对于基本数据类型:
anyByte()
、anyChar()
、anyInt()
等 anyString()
:允许任何String类型的入参,不包括null
-
对service的insert()方法进行单元测试,使用
any(Authority.class)
表示只要是Authority类型的入参都符合要求// 单元测试的代码 @Test public void insertTest() { Authority authority1 = new Authority("sunrise", Role.SYSTEM, "system", "jack"); // NewAuthority extends Authority Authority authority2 = new NewAuthority("john", Role.SYSTEM, "test_project_1", "jack"); AuthorityMapper mapper = mock(AuthorityMapper.class); // 只要插入Authority,都统一返回1,表示成功插入数据 when(mapper.insert(any(Authority.class))).thenReturn(1); AuthorityServiceImpl service = new AuthorityServiceImpl(); service.setAuthorityMapper(mapper); // 成功插入authority1 assertTrue(service.insert(authority1)); // 成功authority2 assertTrue(service.insert(authority2)); // 插入null,不是Authority类型,将返回默认值0,导致insert操作失败 assertFalse(service.insert(null)); } // service.insert()方法的实现 @Override public boolean insert(Authority authority) { int row = authorityMapper.insert(authority); if (row == 1) { return true; } return false; }
4.2 让替身抛出异常
-
单元测试时,除了希望替身能返回指定值,还希望替身能抛出异常,以测试目标方法能否处理异常
-
这时,可以使用
when().thenThrow()
让替身抛出异常 -
下面的代码示例,让替身的insert()方法抛出异常,最终该异常将被service层的insert()方法上抛
@Test(expected = RuntimeException.class) public void insertTest() { Authority authority = new Authority("lucy", Role.SYSTEM, "system", "jack"); AuthorityMapper mapper = mock(AuthorityMapper.class); // 插入的权限记录,user字段过长,触发异常 when(mapper.insert(authority)).thenThrow(new RuntimeException("Unexpected error when insert data")); AuthorityServiceImpl service = new AuthorityServiceImpl(); service.setAuthorityMapper(mapper); // service的insert()方法,没有进行异常处理,DAO层的异常将上抛 service.insert(authority); }
4.2.1 无返回值的方法如何抛出异常?使用 doThrow()
-
AuthorityMapper有一个delete方法,定义如下:
void deleteData(long id);
-
service的delete方法定义如下,调用了AuthorityMapper的delete方法:
@Override public void delete(long userId) { authorityMapper.deleteData(userId); }
-
使用
when().thenThrow()
让mapper替身在执行delete方法时抛出异常,此时发现IDE提示代码编写错误
-
查看when()方法的源码,发现它是一个泛型方法,会返回一个包含被调方法的OngoingStubbing<T>对象
@CheckReturnValue public static <T> OngoingStubbing<T> when(T methodCall) { return MOCKITO_CORE.when(methodCall); }
-
对于返回值为void的方法,是不能使用when()定义调用条件的
-
对于返回值为void的方法,可以使用
doThrow(exception).when(testDoubles).methodCall()
来定义抛出异常@Test(expected = RuntimeException.class) public void deleteTest() { AuthorityMapper mapper = mock(AuthorityMapper.class); // deleteData()返回值为void,使用doThrow()定义异常 doThrow(new RuntimeException("Unexpected error when delete data")).when(mapper).deleteData(anyLong()); AuthorityServiceImpl service = new AuthorityServiceImpl(); service.setAuthorityMapper(mapper); // service的delete()方法,没有进行异常处理,异常将上抛 service.delete(1024); }
4.2.2 定义多个返回值和异常
when().then...
除了支持定义多个返回值,还允许定义多个异常,甚至还能返回值和异常一起定义-
一次定义多个异常:
@Test public void isSystemAdministratorTest() { AuthorityMapper mapper = mock(AuthorityMapper.class); // 定义多个异常 when(mapper.getByUserAndProject("sunrise", "system")) .thenThrow(new RuntimeException("Unexpected error")) .thenThrow( new SecurityException("Can not modify the database")); // 等同于如下语句 /* when(mapper.getByUserAndResource("sunrise", "system")) .thenThrow(new RuntimeException("Unexpected error"), new SecurityException("Can not modify the database")); */ AuthorityServiceImpl service = new AuthorityServiceImpl(); service.setAuthorityMapper(mapper); try { service.isSystemAdministrator("sunrise"); } catch (RuntimeException exception) { assertEquals("Unexpected error", exception.getMessage()); } try { service.isSystemAdministrator("sunrise"); } catch (SecurityException exception) { assertEquals("Can not modify the database", exception.getMessage()); } }
-
返回值和异常一起定义
@Test public void isSystemAdministratorTest() { AuthorityMapper mapper = mock(AuthorityMapper.class); // 返回值和异常一起定义, 无法精简到一个语句中 when(mapper.getByUserAndProject("sunrise", "system")) .thenReturn(null) .thenThrow( new SecurityException("Can not modify the database")); AuthorityServiceImpl service = new AuthorityServiceImpl(); service.setAuthorityMapper(mapper); assertFalse(service.isSystem("sunrise")); try { service.isSystem("sunrise"); } catch (SecurityException exception) { assertEquals("Can not modify the database", exception.getMessage()); } }
-
4.2.3 如何覆盖以前的异常行为?
-
一次偶然的机会,将异常和返回值分开定义了,竟然运行报错
// 异常和返回值的定义分开,其余代码与上一个示例保持一致 when(mapper.getByUserAndProject("sunrise", "system")) .thenThrow(new SecurityException("Can not modify the database")); when(mapper.getByUserAndProject("sunrise", "system")).thenReturn(null);
-
出错的代码行号,竟然是
when().thenReturn()
语句
-
后来仔细阅读了Mockit的代码注释,发现需要使用
doReturn().when()
替代when().thenReturn()
,已覆盖之前定义的的异常行为@Test public void isSystemAdministratorTest() { AuthorityMapper mapper = mock(AuthorityMapper.class); // 返回值和异常分开定义 when(mapper.getByUserAndResource("sunrise", "system")) .thenThrow(new SecurityException("Can not modify the database")); doReturn(null).when(mapper).getByUserAndProject("sunrise", "system"); AuthorityServiceImpl service = new AuthorityServiceImpl(); service.setAuthorityMapper(mapper); try { service.isSystem("sunrise"); } catch (SecurityException exception) { logger.info(exception.getMessage()); // 稍微有点改动,直接打印异常 } assertFalse(service.isSystem("sunrise")); }
-
运行代码,发现未打印异常信息。
-
原因: 使用
doReturn().when()
替代when().thenReturn()
,成功覆盖了之前定义的异常行为,使得后续调用不会再抛出异常 -
注意:
- 通过
when().then...
一次定义多个行为时,异常和返回特定值的行为,是按照顺序出现的 - 而如果多次使用通过
when().then...
定义替身行为,后面的行为将覆盖前面的行为 - 特殊情况: 想要覆盖之前定义的异常行为,需要使用
doReturn().when()
,而非when().thenReturn()
- 通过
4.3 校验方法的调用次数
4.3.1 常规用法
-
对于返回值为void的方法,定义替身行为时,我们更加关心该方法是否被成功调用
-
因为返回值为void,无需通过
thenReturn()
或doReturn()
让其返回特定值 -
这时,可以使用
verify()
验证void方法的调用次数。默认为一次,还可以通过times()
指定调用次数@Test public void deleteTest() { AuthorityMapper mapper = mock(AuthorityMapper.class); AuthorityServiceImpl service = new AuthorityServiceImpl(); service.setAuthorityMapper(mapper); service.delete(1024); // deleteData()返回值为void,使用verify()校验调用次数,默认为一次 verify(mapper).deleteData(1024); // 还可以指定调用次数 verify(mapper, times(0)).deleteData(256); // 等价于下面的调用 verify(mapper, never()).deleteData(256); }
-
注意:verify()不仅局限于void方法,非void方法也可以使用
4.3.2 规定调用次数的上限、下限
-
有时,方法的调用次数不是确定,我们只知道调用次数的上限或者下限
-
这时,可以使用
atMost()
、atLeast()
进行限制@Test public void deleteTest() { AuthorityMapper mapper = mock(AuthorityMapper.class); AuthorityServiceImpl service = new AuthorityServiceImpl(); service.setAuthorityMapper(mapper); service.delete(1024); // 调用替身的deleteData(1024)方法至多一次 verify(mapper, atMost(1)).deleteData(1024); // 等价于下面的语句 verify(mapper, atMostOnce()).deleteData(1024); service.delete(1024); // 调用次数是个累积值,此时deleteData(1024)已被调用2次 verify(mapper, atLeast(2)).deleteData(1024); // 至少一次的简写 verify(mapper, atLeastOnce()).deleteData(1024); }
4.3.3 校验其他方法未被调用
-
除了调用指定的方法,我们希望替身的其他方法为被调用,这时可以使用
verifyNoMoreInteractions()
或者verifyZeroInteractions()
@Test public void verifyTest() { AuthorityMapper mapper = mock(AuthorityMapper.class); mapper.deleteData(1024); Authority authority = new Authority("sunrise", Role.ADMIN, "test-project", "jack"); mapper.insert(authority); // 校验被调用过的方法 verify(mapper).deleteData(1024); verify(mapper).insert(authority); // 除了被校验的方法,没有其他方法被调用 verifyNoMoreInteractions(mapper); // 等价于下面的方法 verifyZeroInteractions(mapper); }
4.4 其他高级用法
4.4.1 救火英雄:doReturn()
-
大多数情况下,
doReturn().when()
与when().thenReturn()
二者等价,都是让替身返回特定值 -
且
when().thenReturn()
更直白易懂,因此建议使用when().thenReturn()
-
但在一些特殊情况下,必须使用
doReturn().when()
替代when().thenReturn()
-
情况1:
doReturn().when()
覆盖前面的异常定义,详情见4.2.3 -
情况2: 使用spy()监视真实对象,在spy上调用真实方法会产生副作用时,需要使用
doReturn().when()
-
例如,使用spy()监视list对象,并在spy上调用get()方法,获取list中的元素
-
如果使用
when().thenReturn()
,规定get()方法的返回值,可能会引发错误@Test public void spyTest() { List<Integer> list = new ArrayList<>(); List<Integer> spy = spy(list); // 定义spy的行为 when(spy.get(0)).thenReturn(10); // 调用spy assertEquals(Integer.valueOf(10), spy.get(0)); }
-
执行上面的代码,发现在使用
when().thenReturn()
定义行为时,就出现了IndexOutOfBoundsException
-
错误原因:
- 使用spy监视真实对象,使用
when().thenReturn()
定义spy行为时,将调用被监视对象的真实方法 - 而真实对象是一个empty list,通过
get(0)
访问其元素时,将触发IndexOutOfBoundsException
- 使用spy监视真实对象,使用
-
正确做法: 使用
doReturn().when()
替代when().thenReturn()
@Test public void spyTest() { List<Integer> list = new ArrayList<>(); List<Integer> spy = spy(list); // 定义spy的行为 doReturn(10).when(spy).get(0); // 调用spy assertEquals(Integer.valueOf(10), spy.get(0)); }
4.4.2 thenAnswer()
或让替身行为更复杂
-
thenReturn()
或doReturn()
,只能让替身返回特定的值 -
我们希望替身返回的值与方法入参有关系,或者随着方法调用而变化
-
例如,DAO层的
insert()
方法,返回自增的主键id的值;每次调用,返回的值应该有所变化 -
这时,可以使用
thenAnswer()
让替身行为更复杂@Test public void insertTest() { Authority authority = new Authority("lucy", Role.SYSTEM, "system", "jack"); AuthorityMapper mapper = mock(AuthorityMapper.class); AtomicInteger id = new AtomicInteger(); // insert方法,返回递增的id主键值 when(mapper.insert(authority)).thenAnswer(new Answer<Integer>() { @Override public Integer answer(InvocationOnMock invocation) throws Throwable { Authority input = invocation.getArgument(0); // 获取入参 System.out.println("成功插入数据: " + input); // 打印入参 return id.incrementAndGet(); // 返回自增的主键id的值 } }); // 第一次调用,主键值为1 assertEquals(1, mapper.insert(authority)); }
-
对于返回值为void的替身方法,可以使用
doAnswer()
丰富替身行为@Test(expected = IllegalArgumentException.class) public void deleteTest() { AuthorityMapper mapper = mock(AuthorityMapper.class); // lambda表达式实现Answer接口,当id不是1024时,抛出异常;否则,返回null doAnswer(invocation -> { Long id = invocation.getArgument(0); if (id != 1024L) { throw new IllegalArgumentException("不存在id为" + id + "的记录"); } System.out.println("成功删除id为" + id + "的记录"); return null; }).when(mapper).deleteData(anyLong()); // 输入值为任意类型 mapper.deleteData(1024L); mapper.deleteData(128L); // 触发异常 }
5. 后记
- 对Mockit的学习暂告一段落
- 回顾整个学习过程,自己的收获如下:
- 学会使用test doubles进行单元测试,可以排除对外部服务的依赖,使得单元测试的简单、快速且可靠
- 使用Mockit框架创建替身,定义替身行为(返回特定值、抛出异常)、验证调用次数等
- 大多数情况,使用
when().thenX()
定义替身行为,特殊情况需要转为使用对应的doX().when()
- Mockit框架提供了很多有用的函数,例如表示任何值的anyX(),定义调用次数上限或下限的atLeast()、atMost()等
- 入门级教程:Clean Unit Tests with Mockito
- 非常完善的教程:Unit tests with Mockito - Tutorial
- 关键函数的对比理解:
- Mockito - difference between doReturn() and when()
- Mockito : doAnswer Vs thenReturn
- Mockito: Difference between doThrow() and thenThrow()