- Java锁的逻辑(结合对象头和ObjectMonitor)
- 还在用饼状图?来瞧瞧这些炫酷的百分比可视化新图形(附代码实现)⛵
- 自动注册实体类到EntityFrameworkCore上下文,并适配ABP及ABPVNext
- 基于Sklearn机器学习代码实战
作者:陈昌毅(常意) 。
那些年,为了学分,我们学会了 面向过程编程 ; 那些年,为了就业,我们学会了 面向对象编程 ; 那些年,为了生活,我们学会了 面向工资编程 ; 那些年,为了升职加薪,我们学会了 面向领导编程 ; 那些年,为了完成指标,我们学会了 面向指标编程 ; …… 那些年,我们学会了 敷衍地编程 ; 那些年,我们 编程只是为了敷衍 .
现在,要响应提高代码质量的号召,需要提升单元测试的代码覆盖率。当然,我们要努力提高单元测试的代码覆盖率。至于单元测试用例的有效性,我们大抵是不用关心的,因为我们只是 面向指标编程.
我曾经阅读过一个Java服务项目,单元测试的代码覆盖率非常高,但是通篇没有一个依赖方法验证(Mockito.verify)、满纸仅存几个数据对象断言(Assert.assertNotNull)。我说,这些都是无效的单元测试用例,根本起不到测试代码BUG和回归验证代码的作用。后来,在一个月黑风高的夜里,一个新增的方法调用,引起了一场血雨腥风.
编写单元测试用例的目的,并不是为了追求单元测试代码覆盖率,而是为了利用单元测试验证回归代码——试图找出代码中潜藏着的BUG。所以,我们应该具备工匠精神、怀着一颗敬畏心,编写出有效的单元测试用例。在这篇文章里,作者通过日常的单元测试实践,系统地总结出一套避免编写无效单元测试用例的方法和原则.
在维基百科中是这样描述的:
在计算机编程中,单元测试又称为模块测试,是针对程序模块来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类、抽象类、或者派生类中的方法.
首先,通过一个简单的服务代码案例,让我们认识一下集成测试和单元测试.
这里,以用户服务(UserService)的分页查询用户(queryUser)为例说明.
@Service
public class UserService {
/** 定义依赖对象 */
/** 用户DAO */
@Autowired
private UserDAO userDAO;
/**
* 查询用户
*
* @param companyId 公司标识
* @param startIndex 开始序号
* @param pageSize 分页大小
* @return 用户分页数据
*/
public PageDataVO<UserVO> queryUser(Long companyId, Long startIndex, Integer pageSize) {
// 查询用户数据
// 查询用户数据: 总共数量
Long totalSize = userDAO.countByCompany(companyId);
// 查询接口数据: 数据列表
List<UserVO> dataList = null;
if (NumberHelper.isPositive(totalSize)) {
dataList = userDAO.queryByCompany(companyId, startIndex, pageSize);
}
// 返回分页数据
return new PageDataVO<>(totalSize, dataList);
}
}
很多人认为,凡是用到JUnit测试框架的测试用例都是单元测试用例,于是就写出了下面的集成测试用例.
@Slf4j
@RunWith(PandoraBootRunner.class)
@DelegateTo(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = {ExampleApplication.class})
public class UserServiceTest {
/** 用户服务 */
@Autowired
private UserService userService;
/**
* 测试: 查询用户
*/
@Test
public void testQueryUser() {
Long companyId = 123L;
Long startIndex = 90L;
Integer pageSize = 10;
PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
log.info("testQueryUser: pageData={}", JSON.toJSONString(pageData));
}
}
集成测试用例主要有以下特点:
采用JUnit+Mockito编写的单元测试用例如下:
@Slf4j
@RunWith(MockitoJUnitRunner.class)
public class UserServiceTest {
/** 定义静态常量 */
/** 资源路径 */
private static final String RESOURCE_PATH = "testUserService/";
/** 模拟依赖对象 */
/** 用户DAO */
@Mock
private UserDAO userDAO;
/** 定义测试对象 */
/** 用户服务 */
@InjectMocks
private UserService userService;
/**
* 测试: 查询用户-无数据
*/
@Test
public void testQueryUserWithoutData() {
// 模拟依赖方法
// 模拟依赖方法: userDAO.countByCompany
Long companyId = 123L;
Long startIndex = 90L;
Integer pageSize = 10;
Mockito.doReturn(0L).when(userDAO).countByCompany(companyId);
// 调用测试方法
String path = RESOURCE_PATH + "testQueryUserWithoutData/";
PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
String text = ResourceHelper.getResourceAsString(getClass(), path + "pageData.json");
Assert.assertEquals("分页数据不一致", text, JSON.toJSONString(pageData));
// 验证依赖方法
// 验证依赖方法: userDAO.countByCompany
Mockito.verify(userDAO).countByCompany(companyId);
// 验证依赖对象
Mockito.verifyNoMoreInteractions(userDAO);
}
/**
* 测试: 查询用户-有数据
*/
@Test
public void testQueryUserWithData() {
// 模拟依赖方法
String path = RESOURCE_PATH + "testQueryUserWithData/";
// 模拟依赖方法: userDAO.countByCompany
Long companyId = 123L;
Mockito.doReturn(91L).when(userDAO).countByCompany(companyId);
// 模拟依赖方法: userDAO.queryByCompany
Long startIndex = 90L;
Integer pageSize = 10;
String text = ResourceHelper.getResourceAsString(getClass(), path + "dataList.json");
List<UserVO> dataList = JSON.parseArray(text, UserVO.class);
Mockito.doReturn(dataList).when(userDAO).queryByCompany(companyId, startIndex, pageSize);
// 调用测试方法
PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
text = ResourceHelper.getResourceAsString(getClass(), path + "pageData.json");
Assert.assertEquals("分页数据不一致", text, JSON.toJSONString(pageData));
// 验证依赖方法
// 验证依赖方法: userDAO.countByCompany
Mockito.verify(userDAO).countByCompany(companyId);
// 验证依赖方法: userDAO.queryByCompany
Mockito.verify(userDAO).queryByCompany(companyId, startIndex, pageSize);
// 验证依赖对象
Mockito.verifyNoMoreInteractions(userDAO);
}
}
单元测试用例主要有以下特点:
为什么集成测试不算单元测试呢?我们可以从单元测试原则上来判断。在业界,常见的单元测试原则有AIR原则和FIRST原则.
AIR原则 内容如下:
1、A-Automatic(自动的) 。
单元测试应该是全自动执行的,并且非交互式的。测试用例通常是被定期执行的,执行过程必须完全自动化才有意义。输出结果需要人工检查的测试不是一个好的单元测试。单元测试中不准使用System.out来进行人肉验证,必须使用assert来验证.
2、I-Independent(独立的) 。
单元测试应该保持的独立性。为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能对外部资源有所依赖.
3、R-Repeatable(可重复的) 。
单元测试是可以重复执行的,不能受到外界环境的影响。单元测试通常会被放入持续集成中,每次有代码提交时单元测试都会被执行.
FIRST原则 内容如下:
1、F-Fast(快速的) 。
单元测试应该是可以快速运行的,在各种测试方法中,单元测试的运行速度是最快的,大型项目的单元测试通常应该在几分钟内运行完毕.
2、I-Independent(独立的) 。
单元测试应该是可以独立运行的,单元测试用例互相之间无依赖,且对外部资源也无任何依赖.
3、R-Repeatable(可重复的) 。
单元测试应该可以稳定重复的运行,并且每次运行的结果都是稳定可靠的.
4、S-SelfValidating(自我验证的) 。
单元测试应该是用例自动进行验证的,不能依赖人工验证.
5、T-Timely(及时的) 。
单元测试必须及时进行编写,更新和维护,以保证用例可以随着业务代码的变化动态的保障质量.
阿里的夕华先生也提出了一条 ASCII原则 :
1、A-Automatic(自动的) 。
单元测试应该是全自动执行的,并且非交互式的.
2、S-SelfValidating(自我验证的) 。
单元测试中必须使用断言方式来进行正确性验证,而不能根据输出进行人肉验证.
3、C-Consistent(一致的) 。
单元测试的参数和结果是确定且一致的.
4、I-Independent(独立的) 。
单元测试之间不能互相调用,也不能依赖执行的先后次序.
5、I-Isolated(隔离的) 。
单元测试需要是隔离的,不要依赖外部资源.
根据上节中的单元测试原则,我们可以对比集成测试和单元测试的满足情况如下:
集成测试基本上不一定满足所有单元测试原则;通过上面表格的对比,可以得出以下结论:
所以,根据这些单元测试原则,可以看出集成测试具有很大的不确定性,不能也不可能完全代替单元测试。另外,集成测试始终是集成测试,即便用于代替单元测试也还是集成测试,比如:利用H2内存数据库测试DAO方法.
要想识别无效单元测试,就必须站在对方的角度思考——如何在保障单元测试覆盖率的前提下,能够更少地编写单元测试代码。那么,就必须从单元测试编写流程入手,看哪一阶段哪一方法可以偷工减料.
在维基百科中是这样描述的:
代码覆盖(Code Coverage)是软件测试中的一种度量,描述程序中源代码被测试的比例和程度,所得比例称为代码覆盖率.
常用的单元测试覆盖率指标有:
除此之外,还有方法覆盖(Method Coverage)、类覆盖(Class Coverage)等单元测试覆盖率指标.
下面,用一个简单方法来分析各个单元测试覆盖率指标:
public static byte combine(boolean b0, boolean b1) {
byte b = 0;
if (b0) {
b |= 0b01;
}
if (b1) {
b |= 0b10;
}
return b;
}
单元测试覆盖率,只能代表被测代码的类、方法、执行语句、代码分支、条件子表达式等是否被执行,但是并不能代表这些代码是否被正确地执行并返回了正确的结果。所以,只看单元测试覆盖率,而不看单元测试有效性,是没有任何意义的.
首先,介绍一下作者总结的单元测试编写流程:
定义对象阶段主要包括:定义被测对象、模拟依赖对象(类成员)、注入依赖对象(类成员).
模拟方法阶段主要包括:模拟依赖对象(参数、返回值和异常)、模拟依赖方法.
调用方法阶段主要包括:模拟依赖对象(参数)、调用被测方法、验证参数对象(返回值和异常).
验证方法阶段主要包括:验证依赖方法、验证数据对象(参数)、验证依赖对象 .
针对单元测试编写流程的阶段和方法,在不影响单元测试覆盖率的情况,我们是否可以进行一些偷工减料.
通过上表格,可以得出结论,偷工减料主要集中在验证阶段:
通过一些合并和拆分,后续将从以下三部分展开:
在单元测试中,验证数据对象是为了验证是否传入了期望的参数值、返回了期望的返回值、设置了期望的属性值.
在单元测试中,需要验证的数据对象主要有以下几种来源.
数据对象来源于调用被测方法的返回值,例如:
PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
数据对象来源于验证依赖方法的参数捕获,例如:
ArgumentCaptor<UserDO> userCreateCaptor = ArgumentCaptor.forClass(UserDO.class);
Mockito.verify(userDAO).create(userCreateCaptor.capture());
UserDO userCreate = userCreateCaptor.getValue();
数据对象来源于获取被测对象的属性值,例如:
userService.loadRoleMap();
Map<Long, String> roleMap = Whitebox.getInternalState(userService, "roleMap");
数据对象来源于获取请求参数的属性值,例如:
OrderContext orderContext = new OrderContext();
orderContext.setOrderId(12345L);
orderService.supplyProducts(orderContext);
List<ProductDO> productList = orderContext.getProductList();
当然,数据对象还有其它来源方式,这里就不再一一举例了.
在调用被测方法时,需要对返回值和异常进行验证;在验证方法调用时,也需要对捕获的参数值进行验证.
JUnit提供Assert.assertNull和Assert.assertNotNull方法来验证数据对象空值.
// 1. 验证数据对象为空
Assert.assertNull("用户标识必须为空", userId);
// 2. 验证数据对象非空
Assert.assertNotNull("用户标识不能为空", userId);
JUnit提供Assert.assertTrue和Assert.assertFalse方法来验证数据对象布尔值的真假.
// 1. 验证数据对象为真
Assert.assertTrue("返回值必须为真", NumberHelper.isPositive(1));
// 2. 验证数据对象为假
Assert.assertFalse("返回值必须为假", NumberHelper.isPositive(-1));
JUnit提供Assert.assertSame和Assert.assertNotSame方法来验证数据对象引用是否一致.
// 1. 验证数据对象一致
Assert.assertSame("用户必须一致", expectedUser, actualUser);
// 2. 验证数据对象不一致
Assert.assertNotSame("用户不能一致", expectedUser, actualUser);
JUnit提供Assert.assertEquals、Assert.assertNotEquals、Assert.assertArrayEquals方法组,可以用来验证数据对象值是否相等.
// 1. 验证简单数据对象
Assert.assertNotEquals("用户名称不一致", "admin", userName);
Assert.assertEquals("账户金额不一致", 10000.0D, accountAmount, 1E-6D);
// 2. 验证简单集合对象
Assert.assertArrayEquals("用户标识列表不一致", new Long[] {1L, 2L, 3L}, userIds);
Assert.assertEquals("用户标识列表不一致", Arrays.asList(1L, 2L, 3L), userIdList);
// 3. 验证复杂数据对象
Assert.assertEquals("用户标识不一致", Long.valueOf(1L), user.getId());
Assert.assertEquals("用户名称不一致", "admin", user.getName());
...
// 4. 验证复杂集合对象
Assert.assertEquals("用户列表长度不一致", expectedUserList.size(), actualUserList.size());
UserDO[] expectedUsers = expectedUserList.toArray(new UserDO[0]);
UserDO[] actualUsers = actualUserList.toArray(new UserDO[0]);
for (int i = 0; i < actualUsers.length; i++) {
Assert.assertEquals(String.format("用户 (%s) 标识不一致", i), expectedUsers[i].getId(), actualUsers[i].getId());
Assert.assertEquals(String.format("用户 (%s) 名称不一致", i), expectedUsers[i].getName(), actualUsers[i].getName());
...
};
// 5. 通过序列化验证数据对象
String text = ResourceHelper.getResourceAsString(getClass(), "userList.json");
Assert.assertEquals("用户列表不一致", text, JSON.toJSONString(userList));;
// 6. 验证数据对象私有属性字段
Assert.assertEquals("基础包不一致", "com.alibaba.example", Whitebox.getInternalState(configurer, "basePackage"));
当然,数据对象还有其它验证方法,这里就不再一一举例了.
这里,以分页查询公司用户为例,来说明验证数据对象时所存在的问题.
代码案例:
public PageDataVO<UserVO> queryUser(Long companyId, Long startIndex, Integer pageSize) {
// 查询用户数据
// 查询用户数据: 总共数量
Long totalSize = userDAO.countByCompany(companyId);
// 查询接口数据: 数据列表
List<UserVO> dataList = null;
if (NumberHelper.isPositive(totalSize)) {
List<UserDO> userList = userDAO.queryByCompany(companyId, startIndex, pageSize);
dataList = userList.stream().map(UserService::convertUser)
.collect(Collectors.toList());
}
// 返回分页数据
return new PageDataVO<>(totalSize, dataList);
}
private static UserVO convertUser(UserDO userDO) {
UserVO userVO = new UserVO();
userVO.setId(userDO.getId());
userVO.setName(userDO.getName());
userVO.setDesc(userDO.getDesc());
...
return userVO;
}
反面案例: 很多人为了偷懒,对数据对象不进行任何验证.
// 调用测试方法
userService.queryUser(companyId, startIndex, pageSize);
存在问题:
无法验证数据对象是否正确,比如被测代码进行了以下修改:
// 返回分页数据
return null;
反面案例:
既然不验证数据对象有问题,那么我就简单地验证一下数据对象非空.
// 调用测试方法
PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
Assert.assertNotNull("分页数据不为空", pageData);
存在问题:
无法验证数据对象是否正确,比如被测代码进行了以下修改:
// 返回分页数据
return new PageDataVO<>();
反面案例:
既然简单地验证数据对象非空不行,那么我就验证数据对象的部分属性.
// 调用测试方法
PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
Assert.assertEquals("数据总量不为空", totalSize, pageData.getTotalSize());
存在问题:
无法验证数据对象是否正确,比如被测代码进行了以下修改:
// 返回分页数据
return new PageDataVO<>(totalSize, null);
反面案例:
验证数据对象部分属性也不行,那我验证数据对象所有属性总行了吧.
// 调用测试方法
PageDataVO<UserVO> pageData = userService.queryUser(companyId);
Assert.assertEquals("数据总量不为空", totalSize, pageData.getTotalSize());
Assert.assertEquals("数据列表不为空", dataList, pageData.getDataList());
存在问题:
上面的代码看起来很完美,验证了PageDataVO中两个属性值totalSize和dataList。但是,如果有一天在PageDataVO中添加了startIndex和pageSize,就无法验证这两个新属性是否赋值正确。代码如下:
// 返回分页数据
return new PageDataVO<>(startIndex, pageSize, totalSize, dataList);
备注: 本方法仅适用于属性字段不可变的数据对象 。
对于数据对象属性字段新增,有没有完美的验证方案?有的!答案就是利用JSON序列化,然后比较JSON文本内容。如果数据对象新增了属性字段,必然会提示JSON字符串不一致.
完美案例:
// 调用测试方法
PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
text = ResourceHelper.getResourceAsString(getClass(), path + "pageData.json");
Assert.assertEquals("分页数据不一致", text, JSON.toJSONString(pageData));
备注: 本方法仅适用于属性字段可变的数据对象.
由于没有模拟数据对象章节,这里在验证数据对象章节中插入了模拟数据对象准则.
在上一节中,我们展示了如何完美地验证数据对象。但是,这种方法真正完美吗?答案是否定.
比如:我们把userDAO.queryByCompany方法返回的uesrList的所有UserDO对象的属性值name和desc赋值为空,再把convertUser方法的name和desc赋值做一下交换,上面的单元测试用例是无法验证出来的.
private static UserVO convertUser(UserDO userDO) {
UserVO userVO = new UserVO();
userVO.setId(userDO.getId());
userVO.setName(userDO.getDesc());
userVO.setDesc(userDO.getName());
...
return userVO;
}
所以,在单元测试中,除触发条件分支外,模拟对象所有属性值不能为空.
在上面的案例中,如果UserDO和UserVO新增了属性字段age(用户年龄),且新增了赋值语句如下:
userVO.setAge(userDO.getAge());
如果还是用原有的数据对象执行单元测试,我们会发现单元测试用例执行通过。这是因为,由于属性字段age为空,赋值不赋值没有任何差别。所以,新增属性类属性字段是,必须模拟数据对象的属性值.
注意: 如果用JSON字符串对比,且设置输出空字段,是可以触发单元测试用例执行失败的.
在单元测试中,必须验证所有数据对象:
具体案例可以参考《数据对象来源方式》章节.
在使用断言验证数据对象时,必须使用确定语义的断言,不能使用不明确语义的断言.
正例:
Assert.assertTrue("返回值不为真", NumberHelper.isPositive(1));
Assert.assertEquals("用户不一致", user, userService.getUser(userId));
反例:
Assert.assertNotNull("用户不能为空", userService.getUser(userId));
Assert.assertNotEquals("用户不能一致", user, userService.getUser(userId));
谨防一些试图绕过本条准则的案例,试图用明确语义的断言去做不明确语义的判断.
Assert.assertTrue("用户不能为空", Objects.nonNull(userService.getUser(userId)));
如果一个模型类,会根据业务需要新增字段。那么,针对这个模型类所对应的数据对象,尽量采用整体验证方式.
正例:
UserVO user = userService.getUser(userId);
String text = ResourceHelper.getResourceAsString(getClass(), path + "user.json");
Assert.assertEquals("用户不一致", text, JSON.toJSONString(user));
反例:
UserVO user = userService.getUser(userId);
Assert.assertEquals("用户标识不一致", Long.valueOf(123L), user.getId());
Assert.assertEquals("用户名称不一致", "changyi", user.getName());
...
上面这种数据验证方式,如果模型类删除了属性字段,是可以验证出来的。但是,如果模型类添加了字段,是无法验证出来的。所以,如果采用了这种验证方式,在新增了模型类属性字段后,需要梳理并补全测试用例。否则,在使用单元测试用例回归代码时,它将会告诉你这里 没有任何问题 .
异常作为Java语言的重要特性,是Java语言健壮性的重要体现。捕获并验证抛出异常,也是测试用例的一种。所以,在单元测试中,也需要对抛出异常进行验证.
判断属性字段是否非法,否则抛出异常.
private Map<String, MessageHandler> messageHandlerMap = ...;
public void handleMessage(Message message) {
...
// 判断处理器映射非空
if (CollectionUtils.isEmpty(messageHandlerMap)) {
throw new ExampleException("消息处理器映射不能为空");
}
...
}
判断输入参数是否合法,否则抛出异常.
public void handleMessage(Message message) {
...
// 判断获取处理器非空
MessageHandler messageHandler = messageHandlerMap.get(message.getType());
if (CollectionUtils.isEmpty(messageHandler)) {
throw new ExampleException("获取消息处理器不能为空");
}
...
}
注意: 这里采用的是Spring框架提供的Assert类,跟if-throw语句的效果一样.
判断返回值是否合法,否则抛出异常.
public void handleMessage(Message message) {
...
// 进行消息处理器处理
boolean result = messageHandler.handleMessage(message);
if (!reuslt) {
throw new ExampleException("处理消息异常");
}
...
}
调用模拟的依赖方法时,可能模拟的依赖方法会抛出异常.
public void handleMessage(Message message) {
...
// 进行消息处理器处理
boolean result = messageHandler.handleMessage(message); // 直接抛出异常
...
}
这里,可以进行异常捕获处理,或打印输出日志,或继续抛出异常.
有时候,静态方法调用也有可能抛出异常.
// 可能会抛出IOException
String response = HttpHelper.httpGet(url, parameterMap);
除此之外,还有别的抛出异常来源方式,这里不再累述.
在单元测试中,通常存在四种验证抛出异常方法.
Java单元测试用例中,最简单直接的异常捕获方式就是使用try-catch语句.
@Test
public void testCreateUserWithException() {
// 模拟依赖方法
Mockito.doReturn(true).when(userDAO).existName(Mockito.anyString());
// 调用测试方法
UserCreateVO userCreate = new UserCreateVO();
try {
userCreate.setName("changyi");
userCreate.setDescription("Java Programmer");
userService.createUser(userCreate);
} catch (ExampleException e) {
Assert.assertEquals("异常编码不一致", ErrorCode.OBJECT_EXIST, e.getCode());
Assert.assertEquals("异常消息不一致", "用户已存在", e.getMessage());
}
// 验证依赖方法
Mockito.verify(userDAO).existName(userCreate.getName());
}
JUnit的@Test注解提供了一个expected属性,可以指定一个期望的异常类型,用来捕获并验证异常.
@Test(expected = ExampleException.class)
public void testCreateUserWithException() {
// 模拟依赖方法
Mockito.doReturn(true).when(userDAO).existName(Mockito.anyString());
// 调用测试方法
UserCreateVO userCreate = new UserCreateVO();
userCreate.setName("changyi");
userCreate.setDescription("Java Programmer");
userService.createUser(userCreate);
// 验证依赖方法(不会执行)
Mockito.verify(userDAO).existName(userCreate.getName());
}
注意: 测试用例在执行到 userService.createUser方法后将跳出方法,导致后续验证语句无法执行。所以,这种方式无法验证异常编码、消息、原因等内容,也无法验证依赖方法及其参数.
如果想要验证异常原因和消息,就需求采用@Rule注解定义ExpectedException对象,然后在测试方法的前面声明要捕获的异常类型、原因和消息.
@Rule
public ExpectedException exception = ExpectedException.none();
@Test
public void testCreateUserWithException1() {
// 模拟依赖方法
Mockito.doReturn(true).when(userDAO).existName(Mockito.anyString());
// 调用测试方法
UserCreateVO userCreate = new UserCreateVO();
userCreate.setName("changyi");
userCreate.setDescription("Java Programmer");
exception.expect(ExampleException.class);
exception.expectMessage("用户已存在");
userService.createUser(userCreate);
// 验证依赖方法(不会执行)
Mockito.verify(userDAO).existName(userCreate.getName());
}
注意: 测试用例在执行到 userService.createUser方法后将跳出方法,导致后续验证语句无法执行。所以,这种方式无法验证依赖方法及其参数。由于ExpectedException的验证方法只支持验证异常类型、原因和消息,无法验证异常的自定义属性字段值。目前,JUnit官方建议使用Assert.assertThrows替换.
在最新版的JUnit中,提供了一个更为简洁的异常验证方式——Assert.assertThrows方法.
@Test
public void testCreateUserWithException() {
// 模拟依赖方法
Mockito.doReturn(true).when(userDAO).existName(Mockito.anyString());
// 调用测试方法
UserCreateVO userCreate = new UserCreateVO();
userCreate.setName("changyi");
userCreate.setDescription("Java Programmer");
ExampleException exception = Assert.assertThrows("异常类型不一致", ExampleException.class, () -> userService.createUser(userCreate));
Assert.assertEquals("异常编码不一致", ErrorCode.OBJECT_EXIST, exception.getCode());
Assert.assertEquals("异常消息不一致", "用户已存在", exception.getMessage());
// 验证依赖方法
Mockito.verify(userDAO).existName(userCreate.getName());
}
根据不同的验证异常功能项,对四种抛出异常验证方式对比。结果如下:
综上所述,采用Assert.assertThrows方法验证抛出异常是最佳的,也是JUnit官方推荐使用的.
这里,以创建用户时抛出异常为例,来说明验证抛出异常时所存在的问题。代码案例:
private UserDAO userDAO;
public void createUser(@Valid UserCreateVO userCreateVO) {
try {
UserDO userCreateDO = new UserDO();
userCreateDO.setName(userCreateVO.getName());
userCreateDO.setDesc(userCreateVO.getDesc());
userDAO.create(userCreateDO);
} catch (RuntimeException e) {
log.error("创建用户异常: userName={}", userName, e)
throw new ExampleException(ErrorCode.DATABASE_ERROR, "创建用户异常", e);
}
}
反面案例:
在验证抛出异常时,很多人使用@Test注解的expected属性,并且指定取值为Exception.class,主要原因是:
@Test(expected = Exception.class)
public void testCreateUserWithException() {
// 模拟依赖方法
Throwable e = new RuntimeException();
Mockito.doThrow(e).when(userDAO).create(Mockito.any(UserCreateVO.class));
// 调用测试方法
UserCreateVO userCreateVO = ...;
userService.createUser(userCreate);
}
存在问题: 上面用例指定了通用异常类型,没有对抛出异常类型进行验证。所以,如果把 ExampleException异常 改为 RuntimeException异常 ,该单元测试用例是无法验证出来的.
throw new RuntimeException("创建用户异常", e);
反面案例: 既然需要验证异常类型,简单地指定@Test注解的expected属性为ExampleException.class即可.
@Test(expected = ExampleException.class)
public void testCreateUserWithException() {
// 模拟依赖方法
Throwable e = new RuntimeException();
Mockito.doThrow(e).when(userDAO).create(Mockito.any(UserCreateVO.class));
// 调用测试方法
UserCreateVO userCreateVO = ...;
userService.createUser(userCreate);
}
存在问题:
上面用例只验证了异常类型,没有对抛出异常属性字段(异常消息、异常原因、错误编码等)进行验证。所以,如果把错误编码 DATABASE_ERROR(数据库错误) 改为 PARAMETER_ERROR(参数错误) ,该单元测试用例是无法验证出来的.
throw new ExampleException(ErrorCode.PARAMETER_ERROR, "创建用户异常", e);
反面案例:
如果要验证异常属性,就必须用Assert.assertThrows方法捕获异常,并对异常的常用属性进行验证。但是,有些人为了偷懒,只验证抛出异常部分属性.
// 模拟依赖方法
Throwable e = new RuntimeException();
Mockito.doThrow(e).when(userDAO).create(Mockito.any(UserCreateVO.class));
// 调用测试方法
UserCreateVO userCreateVO = ...;
ExampleException exception = Assert.assertThrows("异常类型不一致", ExampleException.class, () -> userService.createUser(userCreateVO));
Assert.assertEquals("异常编码不一致", ErrorCode.DATABASE_ERROR, exception.getCode());
存在问题:
上面用例只验证了异常类型和错误编码,如果把错误消息 "创建用户异常" 改为 "创建用户错误" ,该单元测试用例是无法验证出来的.
throw new ExampleException(ErrorCode.DATABASE_ERROR, "创建用户错误", e);
反面案例:
先捕获抛出异常,再验证异常编码和异常消息,看起来很完美了.
// 模拟依赖方法
Throwable e = new RuntimeException();
Mockito.doThrow(e).when(userDAO).create(Mockito.any(UserCreateVO.class));
// 调用测试方法
UserCreateVO userCreateVO = ...;
ExampleException exception = Assert.assertThrows("异常类型不一致", ExampleException.class, () -> userService.createUser(userCreateVO));
Assert.assertEquals("异常编码不一致", ErrorCode.OBJECT_EXIST, exception.getCode());
Assert.assertEquals("异常消息不一致", “创建用户异常”, exception.getMessage());
存在问题:
通过代码可以看出,在抛出ExampleException异常时,最后一个参数e是我们模拟的userService.createUser方法抛出的RuntimeException异常。但是,我们没有对抛出异常原因进行验证。如果修改代码,把最后一个参数e去掉,上面的单元测试用例是无法验证出来的.
throw new ExampleException(ErrorCode.DATABASE_ERROR, "创建用户异常");
反面案例:
很多人认为,验证抛出异常就只验证抛出异常,验证依赖方法调用不是必须的.
// 模拟依赖方法
Throwable e = new RuntimeException();
Mockito.doThrow(e).when(userDAO).create(Mockito.any(UserCreateVO.class));
// 调用测试方法
UserCreateVO userCreateVO = ...;
ExampleException exception = Assert.assertThrows("异常类型不一致", ExampleException.class, () -> userService.createUser(userCreateVO));
Assert.assertEquals("异常编码不一致", ErrorCode.OBJECT_EXIST, exception.getCode());
Assert.assertEquals("异常消息不一致", “创建用户异常”, exception.getMessage());
Assert.assertEquals("异常原因不一致", e, exception.getCause());
存在问题:
如果不验证相关方法调用,如何能证明代码走过这个分支?比如:我们在创建用户之前,检查用户名称无效并抛出异常.
// 检查用户名称有效
String userName = userCreateVO.getName();
if (StringUtils.length(userName) < USER_NAME_LENGTH) {
throw new ExampleException(ErrorCode.INVALID_USERNAME, "无效用户名称");
}
一个完美的异常验证,除对异常类型、异常属性、异常原因等进行验证外,还需对抛出异常前的依赖方法调用进行验证.
完美案例:
// 模拟依赖方法
Throwable e = new RuntimeException();
Mockito.doThrow(e).when(userDAO).create(Mockito.any(UserCreateVO.class));
// 调用测试方法
String text = ResourceHelper.getResourceAsString(getClass(), path + "userCreateVO.json");
UserCreateVO userCreateVO = JSON.parseObject(text, UserCreateVO.class);
ExampleException exception = Assert.assertThrows("异常类型不一致", ExampleException.class, () -> userService.createUser(userCreateVO));
Assert.assertEquals("异常编码不一致", ErrorCode.OBJECT_EXIST, exception.getCode());
Assert.assertEquals("异常消息不一致", “创建用户异常”, exception.getMessage());
Assert.assertEquals("异常原因不一致", e, exception.getCause());
// 验证依赖方法
ArgumentCaptor<UserDO> userCreateCaptor = ArgumentCaptor.forClass(UserDO.class);
Mockito.verify(userDAO).create(userCreateCaptor.capture());
text = ResourceHelper.getResourceAsString(getClass(), path + "userCreateDO.json");
Assert.assertEquals("用户创建不一致", text, JSON.toJSONString(userCreateCaptor.getValue()));
在单元测试中,必须验证所有抛出异常:
具体内容可以参考 《抛出异常来源方式》 章节.
在验证抛出异常时,必须验证异常类型、异常属性、异常原因等.
正例:
ExampleException exception = Assert.assertThrows("异常类型不一致", ExampleException.class, () -> userService.createUser(userCreateVO));
Assert.assertEquals("异常编码不一致", ErrorCode.OBJECT_EXIST, exception.getCode());
Assert.assertEquals("异常消息不一致", "用户已存在", exception.getMessage());
Assert.assertEquals("异常原因不一致", e, exception.getCause());
反例:
@Test(expected = ExampleException.class)
public void testCreateUserWithException() {
...
userService.createUser(userCreateVO);
}
在验证抛出异常后,必须验证相关方法调用,来保证单元测试用例走的是期望分支.
正例:
/ 调用测试方法
...
// 验证依赖方法
ArgumentCaptor<UserDO> userCreateCaptor = ArgumentCaptor.forClass(UserDO.class);
Mockito.verify(userDAO).create(userCreateCaptor.capture());
text = ResourceHelper.getResourceAsString(getClass(), path + "userCreateDO.json");
Assert.assertEquals("用户创建不一致", text, JSON.toJSONString(userCreateCaptor.getValue()));
在单元测试中,验证方法调用是为了验证依赖方法的调用次数和顺序以及是否传入了期望的参数值.
最常见的方法调用就是对注入依赖对象的方法调用.
private UserDAO userDAO;
public UserVO getUser(Long userId) {
UserDO user = userDAO.get(userId); // 方法调用
return convertUser(user);
}
有时候,也可以通过输入参数传入依赖对象,然后调用依赖对象的方法.
public <T> List<T> executeQuery(String sql, DataParser<T> dataParser) {
List<T> dataList = new ArrayList<>();
List<Record> recordList = SQLTask.getResult(sql);
for (Record record : recordList) {
T data = dataParser.parse(record); // 方法调用
if (Objects.nonNull(data)) {
dataList.add(data);
}
}
return dataList;
}
private UserHsfService userHsfService;
public User getUser(Long userId) {
Result<User> result = userHsfService.getUser(userId);
if (!result.isSuccess()) { // 方法调用1
throw new ExampleException("获取用户异常");
}
return result.getData(); // 方法调用2
}
在Java中,静态方法是指被static修饰的成员方法,不需要通过对象实例就可以被调用。在日常代码中,静态方法调用一直占有一定的比例.
String text = JSON.toJSONString(user); // 方法调用
在单元测试中,验证依赖方法调用是确认模拟对象的依赖方法是否被按照预期调用的过程.
// 1.验证无参数依赖方法调用
Mockito.verify(userDAO).deleteAll();
// 2.验证指定参数依赖方法调用
Mockito.verify(userDAO).delete(userId);
// 3.验证任意参数依赖方法调用
Mockito.verify(userDAO).delete(Mockito.anyLong());
// 4.验证可空参数依赖方法调用
Mockito.verify(userDAO).queryCompany(Mockito.anyLong(), Mockito.nullable(Long.class));
// 5.验证必空参数依赖方法调用
Mockito.verify(userDAO).queryCompany(Mockito.anyLong(), Mockito.isNull());
// 6.验证可变参数依赖方法调用
Mockito.verify(userService).delete(1L, 2L, 3L);
Mockito.verify(userService).delete(Mockito.any(Long.class)); // 匹配一个
Mockito.verify(userService).delete(Mockito.<Long>any()); // 匹配多个
// 1.验证依赖方法默认调用1次
Mockito.verify(userDAO).delete(userId);
// 2.验证依赖方法从不调用
Mockito.verify(userDAO, Mockito.never()).delete(userId);
// 3.验证依赖方法调用n次
Mockito.verify(userDAO, Mockito.times(n)).delete(userId);
// 4.验证依赖方法调用至少1次
Mockito.verify(userDAO, Mockito.atLeastOnce()).delete(userId);
// 5.验证依赖方法调用至少n次
Mockito.verify(userDAO, Mockito.atLeast(n)).delete(userId);
// 6.验证依赖方法调用最多1次
Mockito.verify(userDAO, Mockito.atMostOnce()).delete(userId);
// 7.验证依赖方法调用最多n次
Mockito.verify(userDAO, Mockito.atMost(n)).delete(userId);
// 8.验证依赖方法调用指定n次
Mockito.verify(userDAO, Mockito.call(n)).delete(userId); // 不会被标记为已验证
// 9.验证依赖对象及其方法仅调用1次
Mockito.verify(userDAO, Mockito.only()).delete(userId);
// 1.使用ArgumentCaptor.forClass方法定义参数捕获器
ArgumentCaptor<UserDO> userCaptor = ArgumentCaptor.forClass(UserDO.class);
Mockito.verify(userDAO).modify(userCaptor.capture());
UserDO user = userCaptor.getValue();
// 2.使用@Captor注解定义参数捕获器
@Captor
private ArgumentCaptor<UserDO> userCaptor;
// 3.捕获多次方法调用的参数值列表
ArgumentCaptor<UserDO> userCaptor = ArgumentCaptor.forClass(UserDO.class);
Mockito.verify(userDAO, Mockito.atLeastOnce()).modify(userCaptor.capture());
List<UserDO> userList = userCaptor.getAllValues();
// 1.验证 final 方法调用
final方法的验证跟普通方法类似。
// 2.验证私有方法调用
PowerMockito.verifyPrivate(mockClass, times(1)).invoke("unload", any(List.class));
// 3.验证构造方法调用
PowerMockito.verifyNew(MockClass.class).withNoArguments();
PowerMockito.verifyNew(MockClass.class).withArguments(someArgs);
// 4.验证静态方法调用
PowerMockito.verifyStatic(StringUtils.class);
StringUtils.isEmpty(string);
// 1.验证模拟对象没有任何方法调用
Mockito.verifyNoInteractions(idGenerator, userDAO);
// 2.验证模拟对象没有更多方法调用
Mockito.verifyNoMoreInteractions(idGenerator, userDAO);
这里,以cacheUser(缓存用户)为例,来说明验证依赖方法时所存在的问题.
代码案例:
private UserCache userCache;
public boolean cacheUser(List<User> userList) {
boolean result = true;
for (User user : userList) {
result = result && userCache.set(user.getId(), user);
}
return result;
}
反面案例:
有些人觉得,既然已经模拟了依赖方法,并且被测方法已经按照预期返回了值,就没有必要对依赖方法进行验证.
// 模拟依赖方法
Mockito.doReturn(true).when(userCache).set(Mockito.anyLong(), Mockito.any(User.class));
// 调用测试方法
List<User> userList = ...;
Assert.assertTrue("处理结果不为真", userService.cacheUser(userList));
// 不验证依赖方法
存在问题:
模拟了依赖方法,并且被测方法已经按照预期返回了值,并不代表这个依赖方法被调用或者被正确地调用。比如:在for循环之前,把userList置为空列表,这个单元测试用例是无法验证出来的.
// 清除用户列表
userList = Collections.emptyList();
反面案例:
有些很喜欢用Mockito.verify的验证至少一次和任意参数的组合,因为它可以适用于任何依赖方法调用的验证.
// 验证依赖方法
Mockito.verify(userCache, Mockito.atLeastOnce()).set(Mockito.anyLong(), Mockito.any(User.class));
存在问题:
这种方法虽然适用于任何依赖方法调用的验证,但是基本上没有任何实质作用.
比如:我们不小心,把缓存语句写了两次,这个单元测试用例是无法验证出来的.
// 写了两次缓存
result = result && userCache.set(user.getId(), user);
result = result && userCache.set(user.getId(), user);
反面案例:
既然说验证至少一次有问题,那我就指定一下验证次数.
// 验证依赖方法
Mockito.verify(userCache, Mockito.times(userList.size())).set(Mockito.anyLong(), Mockito.any(User.class));
存在问题:
验证方法次数的问题虽然解决了,但是验证方法参数的问题任然存在.
比如:我们不小心,把循环缓存每一个用户写成循环缓存第一个用户,这个单元测试用例是无法验证出来的.
User user = userList.get(0);
for (int i = 0; i < userList.size(); i++) {
result = result && userCache.set(user.getId(), user);
}
反面案例:
不能用任意参数验证方法,那只好用实际参数验证方法了。但是,验证所有依赖方法调用代码太多,所以验证一两个依赖方法调用意思意思就行了.
Mockito.verify(userCache).set(user1.getId(), user1);
Mockito.verify(userCache).set(user2.getId(), user2);
存在问题:
如果只验证了一两个方法调用,只能保障这一两个方法调用没有问题.
比如:我们不小心,在for循环之后,还进行了一个用户缓存.
// 缓存最后一个用户
User user = userList.get(userList.size() - 1);
userCache.set(user.getId(), user);
反面案例:
既然不验证所有方法调用有问题,那我就把所有方法调用验证了吧.
for (User user : userList) {
Mockito.verify(userCache).set(user.getId(), user);
}
存在问题:
所有方法调用都被验证了,看起来应该没有问题了。但是,如果缓存用户方法中,存在别的方法调用。比如:我们在进入缓存用户方法之前,新增了清除所有用户缓存,这个单元测试用是无法验证的.
// 删除所有用户缓存
userCache.clearAll();
验证所有的方法调用,只能保证现在的逻辑没有问题。如果涉及新增方法调用,这个单元测试用例是无法验证出来的。所有,我们需要验证所有依赖对象没有更多方法调用.
完美案例:
// 验证依赖方法
ArgumentCaptor<Long> userIdCaptor = ArgumentCaptor.forClass(Long.class);
ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
Mockito.verify(userCache, Mockito.atLeastOnce()).set(userIdCaptor.capture(), userCaptor.capture());
Assert.assertEquals("用户标识列表不一致", userIdList, userIdCaptor.getAllValues());
Assert.assertEquals("用户信息列表不一致", userList, userCaptor.getAllValues());
// 验证依赖对象
Mockito.verifyNoMoreInteractions(userCache);
注意: 利用ArgumentCaptor(参数捕获器),不但可以验证参数,还可以验证调用次数和顺序.
在单元测试中,涉及到的所有模拟方法都要被验证:
具体案例可以参考 《方法调用来源方式》 章节.
在单元测试中,为了防止被测方法中存在或新增别的方法调用,必须验证所有的模拟对象没有更多方法调用.
正例:
// 验证依赖对象
Mockito.verifyNoMoreInteractions(userDAO, userCache);
备注:
作者喜欢在@After方法中对所有模拟对象进行验证,这样就不必在每个单元测试用例中验证模拟对象.
@After
public void afterTest() {
Mockito.verifyNoMoreInteractions(userDAO, userCache);
}
可惜Mockito.verifyNoMoreInteractions不支持无参数就验证所有模拟对象的功能,否则这段代码会变得更简洁.
验证依赖方法时,必须使用明确语义的参数值或匹配器,不能使用任何不明确语义的匹配器,比如:any系列参数匹配器.
正例:
Mockito.verify(userDAO).get(userId);
Mockito.verify(userDAO).query(Mockito.eq(companyId), Mockito.isNull());
反例:
Mockito.verify(userDAO).get(Mockito.anyLong());
Mockito.verify(userDAO).query(Mockito.anyLong(), Mockito.isNotNull());
最后,根据本文所表达的观点,即兴赋诗七言绝句一首:
《单元测试》 单元测试分真假, 工匠精神贯始终。 覆盖追求非目的, 回归验证显奇功.
意思是:
一定要知道如何去分辨单元测试的真假, 一定要把工匠精神贯彻单元测试的始终。 追求单测覆盖率并不是单元测试的目的, 回归验证代码才能彰显单元测试的功效.
最后此篇关于那些年,我们写过的无效单元测试的文章就讲到这里了,如果你想了解更多关于那些年,我们写过的无效单元测试的内容请搜索CFSDN的文章或继续浏览相关文章,希望大家以后支持我的博客! 。
我需要(我必须)将大量 float 写入 qdatastream 并且我只使用 4 个字节是必要的。setFloatingPointPrecision 或为 float 和 double 写入 4 或
我有一些 C 代码,我用 Python 对其进行了扩展。扩展的 C 代码有一个将一些结构附加到二进制文件的函数: void writefunction(const struct struct1* so
我正在用 C 语言开发一个小软件,用于在布告栏中读取和写入消息。每条消息都是一个以渐进数字命名的 .txt。 软件是多线程的,有很多用户可以并发操作。 用户可以进行的操作有: 阅读整个公告板(所有 .
我有 2 个线程同时访问同一个大文件 (.txt)。 第一个线程正在从文件中读取。第二个线程正在写入文件。 两个线程都访问同一个 block ,例如(开始:0, block 大小:10),但具有不同的
我做了很多谷歌搜索,但我仍然不确定如何继续。 Linux 下最常见的剪贴板读写方式是什么?我想要同时支持 Gnome 和 KDE 桌面。 更新:我是否认为没有简单的解决方案,必须将多个来源(gnome
1. 定义配置文件信息 有时候我们为了统一管理会把一些变量放到 yml 配置文件中 例如 图片 用 @ConfigurationProperties 代替 @Value 使用方法 定义对应字段的实体
在开始之前,我必须先声明我是 FORTRAN 的新手。我正在维护 1978 年的一段遗留代码。它的目的是从文件中读取一些数据值,处理这些值,然后将处理过的值输出到另一个文本文件。 给定以下 FORTR
我正在制作一个应用程序,我需要存储用户提供的一些信息。我尝试使用 .plist 文件来存储信息,我发现: NSString *filePath = @"/Users/Denis/Documents/X
在delphi类中声明属性时是否可能有不同类型的结果? 示例: 属性月份:字符串读取monthGet(字符串)写入monthSet(整数); 在示例中,我希望在属性(property)月份中,当我:读
我正在以二进制形式将文件加载到数组中,这似乎需要一段时间有没有更好更快更有效的方法来做到这一点。我正在使用类似的方法写回文件。 procedure openfile(fname:string); va
我想实现一个运行模拟的C#控制台应用程序。另外,我想给用户机会在控制台上按“+”或“-”来加速/减速模拟的速度。 有没有办法在编写控制台时读取控制台?我相信我可以为此使用多线程,但是我却不怎么做(我对
这是我的代码: use std::fs::File; use std::io::Write; fn main() { let f = File::create("").unwrap();
我有一个应用程序可以访问 csv 文本文件中的单词。由于它们通常不会更改,因此我将它们放置在 .jar 文件中,并使用 .getResourceAsStream 调用读取它们。我真的很喜欢这种方法,因
我使用kubeadm,docker 17.12.1-ce和法兰绒网络安装了Kubernetes 1.13.1集群 但是,我发现Kubernetes主服务器上有许多空文件,权限为666,该文件允许任何用
我的工作区中有一些 java 文件。现在我想编写一个java程序,它可以读取来自不同源的文本文件,一次一个,一行一行,并将这些行插入到工作区中各自的java文件中。 文本文件会告诉我将哪个文件插入到哪
用户A要求系统读取文件foo,同时用户B想要将他或她的数据保存到同一个文件中。在文件系统级别如何处理这种情况? 最佳答案 大多数文件系统(但不是全部)使用锁定来保护对同一文件的并发访问。锁可以是独占的
我对保护移动应用程序的 firebase 数据库有一些疑问。 例如,在反编译Android应用程序后,黑客可以获取firebase api key 然后访问firebase数据库,这是正确的吗? 假设
我想让文件从外部不可删除,并希望使用java从程序对该文件进行读/写操作。 S0,我使用以下代码使用java创建了不可删除的文件: Process pcs = Runtime.getRunti
当 Selector.select() 以阻塞模式等待读/写操作时,是否可以将写消息推送到客户端?如何将选择器从阻塞模式移至写入模式?触发器可以是一个后台线程,用于放置需要写入给定 channel 的
我目前正在学习在 Linux 环境中使用 C 进行套接字编程。作为一个项目,我正在尝试编写一个基本的聊天服务器和客户端。 目的是让服务器为每个连接的客户端派生一个进程。 我遇到的问题是读取一个 chi
我是一名优秀的程序员,十分优秀!