那些年,我们写过的无效单元测试
Posted 阿里云云栖号
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了那些年,我们写过的无效单元测试相关的知识,希望对你有一定的参考价值。
前言
那些年,为了学分,我们学会了 面向过程编程;
那些年,为了就业,我们学会了 面向对象编程;
那些年,为了生活,我们学会了 面向工资编程;
那些年,为了升职加薪,我们学会了 面向领导编程;
那些年,为了完成指标,我们学会了 面向指标编程;
……
那些年,我们学会了 敷衍地 编程;
那些年,我们 编程只是为了 敷衍。
现在,领导要响应集团提高代码质量的号召,需要提升单元测试的代码覆盖率。当然,我们不能让领导失望,那就加班加点地补充单元测试用例,努力提高单元测试的代码覆盖率。至于单元测试用例的有效性,我们大抵是不用关心的,因为我们只是面向指标编程。
我曾经阅读过一个Java服务项目,单元测试的代码覆盖率非常高,但是通篇没有一个依赖方法验证(Mockito.verify)、满纸仅存几个数据对象断言(Assert.assertNotNull)。我说,这些都是无效的单元测试用例,根本起不到测试代码BUG和回归验证代码的作用。后来,在一个月黑风高的夜里,一个新增的方法调用,引起了一场血雨腥风。
编写单元测试用例的目的,并不是为了追求单元测试代码覆盖率,而是为了利用单元测试验证回归代码——试图找出代码中潜藏着的BUG。所以,我们应该具备工匠精神、怀着一颗敬畏心,编写出有效的单元测试用例。在这篇文章里,作者通过日常的单元测试实践,系统地总结出一套避免编写无效单元测试用例的方法和原则。
1. 单元测试简介
1.1. 单元测试概念
在维基百科中是这样描述的:
在计算机编程中,单元测试又称为模块测试,是针对程序模块来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类、抽象类、或者派生类中的方法。
1.2. 单元测试案例
首先,通过一个简单的服务代码案例,让我们认识一下集成测试和单元测试。
1.2.1. 服务代码案例
这里,以用户服务(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);
1.2.2. 集成测试用例
很多人认为,凡是用到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));
集成测试用例主要有以下特点:
- 依赖外部环境和数据;
- 需要启动应用并初始化测试对象;
- 直接使用
@Autowired
注入测试对象; - 有时候无法验证不确定的返回值,只能靠打印日志来人工核对。
1.2.3. 单元测试用例
采用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);
单元测试用例主要有以下特点:
- 不依赖外部环境和数据;
- 不需要启动应用和初始化对象;
- 需要用@Mock来初始化依赖对象,用@InjectMocks来初始化测试对象;
- 需要自己模拟依赖方法,指定什么参数返回什么值或异常;
- 因为测试方法返回值确定,可以直接用Assert相关方法进行断言;
- 可以验证依赖方法的调用次数和参数值,还可以验证依赖对象的方法调用是否验证完毕。
2.3. 单元测试原则
为什么集成测试不算单元测试呢?我们可以从单元测试原则上来判断。在业界,常见的单元测试原则有AIR原则和FIRST原则。
2.3.1. AIR原则
AIR原则内容如下:
- A-Automatic(自动的)单元测试应该是全自动执行的,并且非交互式的。测试用例通常是被定期执行的,执行过程必须完全自动化才有意义。输出结果需要人工检查的测试不是一个好的单元测试。单元测试中不准使用
System.out
来进行人肉验证,必须使用assert来验证。 - I-Independent(独立的)单元测试应该保持的独立性。为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能对外部资源有所依赖。
- R-Repeatable(可重复的)单元测试是可以重复执行的,不能受到外界环境的影响。单元测试通常会被放入持续集成中,每次有代码提交时单元测试都会被执行。
2.3.2. FIRST原则
FIRST原则内容如下:
- F-Fast(快速的)单元测试应该是可以快速运行的,在各种测试方法中,单元测试的运行速度是最快的,大型项目的单元测试通常应该在几分钟内运行完毕。
- I-Independent(独立的)单元测试应该是可以独立运行的,单元测试用例互相之间无依赖,且对外部资源也无任何依赖。
- R-Repeatable(可重复的)单元测试应该可以稳定重复的运行,并且每次运行的结果都是稳定可靠的。
- S-SelfValidating(自我验证的)单元测试应该是用例自动进行验证的,不能依赖人工验证。
- T-Timely(及时的)单元测试必须及时进行编写,更新和维护,以保证用例可以随着业务代码的变化动态的保障质量。
2.3.3. ASCII原则
阿里的夕华先生也提出了一条ASCII原则:
- A-Automatic(自动的)单元测试应该是全自动执行的,并且非交互式的。
- S-SelfValidating(自我验证的)单元测试中必须使用断言方式来进行正确性验证,而不能根据输出进行人肉验证。
- C-Consistent(一致的)单元测试的参数和结果是确定且一致的。
- I-Independent(独立的)单元测试之间不能互相调用,也不能依赖执行的先后次序。
- I-Isolated(隔离的)单元测试需要是隔离的,不要依赖外部资源。
2.3.4. 对比集测和单测
根据上节中的单元测试原则,我们可以对比集成测试和单元测试的满足情况如下:
原则名称 | 原则项目 | 集成测试 | 单元测试 |
---|---|---|---|
AIR原则 | Automatic(自动的) | 不一定支持 | 支持 |
Independent(独立的) | 不一定支持 | 支持 | |
Repeatable(可重复的) | 不一定支持 | 支持 | |
FIRST原则 | Fast(快速的) | 不一定支持 | 支持 |
Independent(独立的) | 不一定支持 | 支持 | |
Repeatable(可重复的) | 不一定支持 | 支持 | |
SelfValidating(自我验证的) | 不一定支持 | 支持 | |
Timely(及时的) | - | - | |
ASCII原则 | Automatic(自动的) | 不一定支持 | 支持 |
SelfValidating(自我验证的) | 不一定支持 | 支持 | |
Consistent(一致的) | 不一定支持 | 支持 | |
Independent(独立的) | 不一定支持 | 支持 | |
Isolated(隔离的) | 不一定支持 | 支持 |
通过上面表格的对比,可以得出以下结论:
- 集成测试基本上不一定满足所有单元测试原则;
- 单元测试基本上一定都满足所有单元测试原则。
所以,根据这些单元测试原则,可以看出集成测试具有很大的不确定性,不能也不可能完全代替单元测试。另外,集成测试始终是集成测试,即便用于代替单元测试也还是集成测试,比如:利用H2内存数据库测试DAO方法。
3. 无效单元测试
要想识别无效单元测试,就必须站在对方的角度思考——如何在保障单元测试覆盖率的前提下,能够更少地编写单元测试代码。那么,就必须从单元测试编写流程入手,看哪一阶段哪一方法可以偷工减料。
3.1. 单元测试覆盖率
在维基百科中是这样描述的:
代码覆盖(Code Coverage)是软件测试中的一种度量,描述程序中源代码被测试的比例和程度,所得比例称为代码覆盖率。
常用的单元测试覆盖率指标有:
- 行覆盖(Line Coverage):用于度量被测代码中每一行执行语句是否都被测试到了。
- 分支覆盖(Branch Coverage):用于度量被测代码中每一个代码分支是否都被测试到了。
- 条件覆盖(Condition Coverage):用于度量被测代码的条件中每一个子表达式(true和false)是否都被测试到了。
- 路径覆盖(Path 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;
覆盖指标 | 测试用例 | 覆盖率 | 备注信息 |
---|---|---|---|
行覆盖(Line Coverage) | combine(true, true) | 100% | 每一行执行语句都被执行到 |
分支覆盖(Branch Coverage) | combine(false, false)combine(true, true) | 100% | 每一个代码分支都被执行到 |
条件覆盖(Condition Coverage) | combine(false, true)combine(true, false) | 100% | 每一个条件子表达式都被执行到 |
路径覆盖(Path Coverage) | combine(false, false)combine(false, true)combine(true, false)combine(true, true) | 100% | 每一个代码分支组合都被执行到 |
单元测试覆盖率,只能代表被测代码的类、方法、执行语句、代码分支、条件子表达式等是否被执行,但是并不能代表这些代码是否被正确地执行并返回了正确的结果。所以,只看单元测试覆盖率,而不看单元测试有效性,是没有任何意义的。
3.2. 单元测试编写流程
首先,介绍一下作者总结的单元测试编写流程:
3.2.1. 定义对象阶段
定义对象阶段主要包括:定义被测对象、模拟依赖对象(类成员)、注入依赖对象(类成员)。
3.2.2. 模拟方法阶段
模拟方法阶段主要包括:模拟依赖对象(参数、返回值和异常)、模拟依赖方法。
3.2.3. 调用方法阶段
调用方法阶段主要包括:模拟依赖对象(参数)、调用被测方法、验证参数对象(返回值和异常)。
3.2.4. 验证方法阶段
验证方法阶段主要包括:验证依赖方法、验证数据对象(参数)、验证依赖对象 。
3.3. 是否可以偷工减料
针对单元测试编写流程的阶段和方法,在不影响单元测试覆盖率的情况,我们是否可以进行一些偷工减料。
测试阶段 | 测试方法 | 可否偷减 | 主要原因 |
---|---|---|---|
1.定义对象阶段 | ①定义测试对象 | 不可以 | 不定义测试对象,根本无法进行测试 |
②定义依赖对象(类成员) | 不可以 | 不定义依赖对象(类成员),测试时会抛出空指针或无法进入期望分支 | |
③注入依赖对象(类成员) | 不可以 | 不注入依赖对象(类成员),测试会抛出空指针异常或无法进入期望分支 | |
2.模拟方法阶段 | ②模拟依赖对象(参数、返回值和异常) | 不可以 | 不模拟依赖对象(参数、返回值和异常),无法进入期望分支 |
④模拟依赖方法 | 不可以 | 不模拟模拟依赖方法,无法进入期望分支 | |
3.调用方法阶段 | ②模拟依赖对象(参数) | 不可以 | 不模拟依赖对象(参数),无法进入期望分支 |
⑤调用测试方法 | 不可以 | 不执行调用测试方法,根本无法进行测试 | |
⑦验证数据对象(返回值和异常) | 可以 | 不验证验证数据对象(返回值和异常),对单元测试覆盖率无影响 | |
4.验证方法阶段 | ⑥验证依赖方法 | 可以 | 不验证依赖方法,对单元测试覆盖率无影响 |
⑦验证数据对象(参数) | 可以 | 不验证数据对象(参数),对单元测试覆盖率无影响 | |
⑧验证依赖对象 | 可以 | 不验证验证依赖对象,对单元测试覆盖率无影响 |
3.4. 最终可以得出结论
通过上表格,可以得出结论,偷工减料主要集中在验证阶段:
- 调用方法阶段
- 验证数据对象(返回值和异常)
- 验证方法阶段
- 验证依赖方法
- 验证数据对象(参数)
- 验证依赖对象
通过一些合并和拆分,后续将从以下三部分展开:
- 验证数据对象(包括属性、参数和返回值);
- 验证抛出异常;
- 验证依赖方法(包括依赖方法和依赖对象)。
4. 验证数据对象
在单元测试中,验证数据对象是为了验证是否传入了期望的参数值、返回了期望的返回值、设置了期望的属性值。
4.1. 数据对象来源方式
在单元测试中,需要验证的数据对象主要有以下几种来源。
4.1.1. 来源于被测方法的返回值
数据对象来源于调用被测方法的返回值,例如:
PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
4.1.2. 来源于依赖方法的参数捕获
数据对象来源于验证依赖方法的参数捕获,例如:
ArgumentCaptor<UserDO> userCreateCaptor = ArgumentCaptor.forClass(UserDO.class);
Mockito.verify(userDAO).create(userCreateCaptor.capture());
UserDO userCreate = userCreateCaptor.getValue();
4.1.3. 来源于被测对象的属性值
数据对象来源于获取被测对象的属性值,例如:
userService.loadRoleMap();
Map<Long, String> roleMap = Whitebox.getInternalState(userService, "roleMap");
4.1.4. 来源于请求参数的属性值
数据对象来源于获取请求参数的属性值,例如:
OrderContext orderContext = new OrderContext();
orderContext.setOrderId(12345L);
orderService.supplyProducts(orderContext);
List<ProductDO> productList = orderContext.getProductList();
当然,数据对象还有其它来源方式,这里就不再一一举例了。
4.2. 数据对象验证方式
在调用被测方法时,需要对返回值和异常进行验证;在验证方法调用时,也需要对捕获的参数值进行验证。
4.2.1. 验证数据对象空值
JUnit提供Assert.assertNull和Assert.assertNotNull方法来验证数据对象空值。
// 1. 验证数据对象为空
Assert.assertNull("用户标识必须为空", userId);
// 2. 验证数据对象非空
Assert.assertNotNull("用户标识不能为空", userId);
4.2.2. 验证数据对象布尔值
JUnit提供Assert.assertTrue和Assert.assertFalse方法来验证数据对象布尔值的真假。
// 1. 验证数据对象为真
Assert.assertTrue("返回值必须为真", NumberHelper.isPositive(1));
// 2. 验证数据对象为假
Assert.assertFalse("返回值必须为假", NumberHelper.isPositive(-1));
4.2.3. 验证数据对象引用
JUnit提供Assert.assertSame和Assert.assertNotSame方法来验证数据对象引用是否一致。
// 1. 验证数据对象一致
Assert.assertSame("用户必须一致", expectedUser, actualUser);
// 2. 验证数据对象不一致
Assert.assertNotSame("用户不能一致", expectedUser, actualUser);
4.2.4. 验证数据对象取值
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"));
当然,数据对象还有其它验证方法,这里就不再一一举例了。
4.3. 验证数据对象问题
这里,以分页查询公司用户为例,来说明验证数据对象时所存在的问题。
代码案例:
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;
4.3.1. 不验证数据对象
反面案例:
很多人为了偷懒,对数据对象不进行任何验证。
// 调用测试方法
userService.queryUser(companyId, startIndex, pageSize);
存在问题:
无法验证数据对象是否正确,比如被测代码进行了以下修改:
// 返回分页数据
return null;
4.3.2. 验证数据对象非空
反面案例:
既然不验证数据对象有问题,那么我就简单地验证一下数据对象非空。
// 调用测试方法
PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
Assert.assertNotNull("分页数据不为空", pageData);
存在问题:
无法验证数据对象是否正确,比如被测代码进行了以下修改:
// 返回分页数据
return new PageDataVO<>();
4.3.3. 验证数据对象部分属性
反面案例:
既然简单地验证数据对象非空不行,那么我就验证数据对象的部分属性。
// 调用测试方法
PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
Assert.assertEquals("数据总量不为空", totalSize, pageData.getTotalSize());
存在问题:
无法验证数据对象是否正确,比如被测代码进行了以下修改:
// 返回分页数据
return new PageDataVO<>(totalSize, null);
4.3.4. 验证数据对象全部属性
反面案例:
验证数据对象部分属性也不行,那我验证数据对象所有属性总行了吧。
// 调用测试方法
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);
备注:本方法仅适用于属性字段不可变的数据对象
4.3.5. 完美地验证数据对象
对于数据对象属性字段新增,有没有完美的验证方案?有的!答案就是利用JSON序列化,然后比较JSON文本内容。如果数据对象新增了属性字段,必然会提示JSON字符串不一致。
完美案例:
// 调用测试方法
PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
text = ResourceHelper.getResourceAsString(getClass(), path + "pageData.json");
Assert.assertEquals("分页数据不一致", text, JSON.toJSONString(pageData));
备注:本方法仅适用于属性字段可变的数据对象。
4.4. 模拟数据对象准则
由于没有模拟数据对象章节,这里在验证数据对象章节中插入了模拟数据对象准则。
4.4.1. 除触发条件分支外,模拟对象所有属性值不能为空
在上一节中,我们展示了如何完美地验证数据对象。但是,这种方法真正完美吗?答案是否定。
比如:我们把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;
所以,在单元测试中,除触发条件分支外,模拟对象所有属性值不能为空。
4.4.2. 新增数据类属性字段时,必须模拟数据对象的属性值
在上面的案例中,如果UserDO和UserVO新增了属性字段age(用户年龄),且新增了赋值语句如下:
userVO.setAge(userDO.getAge());
如果还是用原有的数据对象执行单元测试,我们会发现单元测试用例执行通过。这是因为,由于属性字段age为空,赋值不赋值没有任何差别。所以,新增属性类属性字段是,必须模拟数据对象的属性值。
注意:如果用JSON字符串对比,且设置输出空字段,是可以触发单元测试用例执行失败的。
4.5. 验证数据对象准则
4.5.1. 必须验证所有数据对象
在单元测试中,必须验证所有数据对象:
- 来源于被测方法的返回值
- 来源于依赖方法的参数捕获
- 来源于被测对象的属性值
- 来源于请求参数的属性值。
具体案例可以参考《数据对象来源方式》章节。
4.5.2. 必须使用明确语义的断言
在使用断言验证数据对象时,必须使用确定语义的断言,不能使用不明确语义的断言。
正例:
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)));
4.5.3. 尽量采用整体验证方式
如果一个模型类,会根据业务需要新增字段。那么,针对这个模型类所对应的数据对象,尽量采用整体验证方式。
正例:
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());
...
上面这种数据验证方式,如果模型类删除了属性字段,是可以验证出来的。但是,如果模型类添加了字段,是无法验证出来的。所以,如果采用了这种验证方式,在新增了模型类属性字段后,需要梳理并补全测试用例。否则,在使用单元测试用例回归代码时,它将会告诉你这里没有任何问题。
5. 验证抛出异常
异常作为Java语言的重要特性,是Java语言健壮性的重要体现。捕获并验证抛出异常,也是测试用例的一种。所以,在单元测试中,也需要对抛出异常进行验证。
5.1. 抛出异常来源方式
5.1.1. 来源于属性字段的判断
判断属性字段是否非法,否则抛出异常。
private Map<String, MessageHandler> messageHandlerMap = ...;
public void handleMessage(Message message)
...
// 判断处理器映射非空
if (CollectionUtils.isEmpty(messageHandlerMap))
throw new ExampleException("消息处理器映射不能为空");
...
5.1.2. 来源于输入参数的判断
判断输入参数是否合法,否则抛出异常。
public void handleMessage(Message message)
...
// 判断获取处理器非空
MessageHandler messageHandler = messageHandlerMap.get(message.getType());
if (CollectionUtils.isEmpty(messageHandler))
throw new ExampleException("获取消息处理器不能为空");
...
注意:这里采用的是Spring框架提供的Assert类,跟if-throw语句的效果一样。
5.1.3. 来源于返回值的判断
判断返回值是否合法,否则抛出异常。
public void handleMessage(Message message)
...
// 进行消息处理器处理
boolean result = messageHandler.handleMessage(message);
if (!reuslt)
throw new ExampleException("处理消息异常");
...
5.1.4. 来源于模拟方法的调用
调用模拟的依赖方法时,可能模拟的依赖方法会抛出异常。
public void handleMessage(Message message)
...
// 进行消息处理器处理
boolean result = messageHandler.handleMessage(message); // 直接抛出异常
...
这里,可以进行异常捕获处理,或打印输出日志,或继续抛出异常。
5.1.5. 来源于静态方法的调用
有时候,静态方法调用也有可能抛出异常。
// 可能会抛出IOException
String response = HttpHelper.httpGet(url, parameterMap);
除此之外,还有别的抛出异常来源方式,这里不再累述。
5.2. 抛出异常验证方式
在单元测试中,通常存在四种验证抛出异常方法。
5.2.1. 通过try-catch语句验证抛出异常
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());
5.2.2. 通过@Test注解验证抛出异常
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方法后将跳出方法,导致后续验证语句无法执行。所以,这种方式无法验证异常编码、消息、原因等内容,也无法验证依赖方法及其参数。
5.2.3. 通过@Rule注解验证抛出异常
如果想要验证异常原因和消息,就需求采用@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替换。
5.2.4. 通过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());
5.2.5. 四种抛出异常验证方式对比
根据不同的验证异常功能项,对四种抛出异常验证方式对比。结果如下:
对比内容 | try-catch语句 | @Test注解 | @Rule注解 | Assert.assertThrows方法 |
---|---|---|---|---|
验证异常类型 | 支持 | 支持 | 支持 | 支持 |
验证异常消息 | 支持 | 不支持 | 支持 | 支持 |
验证异常原因 | 支持 | 不支持 | 支持 | 支持 |
验证自定义属性 | 支持 | 不支持 | 不支持 | 支持 |
验证依赖方法及其参数 | 支持 | 不支持 | 不支持 | 支持 |
单元测试代码优雅性 | 不优雅 | 优雅 | 不优雅 | 优雅 |
JUnit官方推荐使用 | 不推荐 | 推荐 | 不推荐 | 推荐 |
综上所述,采用Assert.assertThrows方法验证抛出异常是最佳的,也是JUnit官方推荐使用的。
5.3. 验证抛出异常问题
这里,以创建用户时抛出异常为例,来说明验证抛出异常时所存在的问题。
代码案例:
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);
5.3.1. 不验证抛出异常类型
反面案例:
在验证抛出异常时,很多人使用@Test注解的expected属性,并且指定取值为Exception.class,主要原因是:
- 单元测试用例的代码简洁,只有一行@Test注解;
- 不管抛出什么异常,都能保证单元测试用例通过。
@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);
5.3.2. 不验证抛出异常属性
反面案例:
既然需要验证异常类型,简单地指定@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);
5.3.3. 只验证抛出异常部分属性
反面案例:
如果要验证异常属性,就必须用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);
5.3.4. 不验证抛出异常原因
反面案例:
先捕获抛出异常,再验证异常编码和异常消息,看起来很完美了。
// 模拟依赖方法
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, "创建用户异常");
5.3.5. 不验证相关方法调用
反面案例:
很多人认为,验证抛出异常就只验证抛出异常,验证依赖方法调用不是必须的。
// 模拟依赖方法
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, "无效用户名称");
5.3.6. 完美地验证抛出异常
一个完美的异常验证,除对异常类型、异常属性、异常原因等进行验证外,还需对抛出异常前的依赖方法调用进行验证。
完美案例:
// 模拟依赖方法
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()));
5.4. 验证抛出异常准则
5.4.1. 必须验证所有抛出异常
在单元测试中,必须验证所有抛出异常:
- 来源于属性字段的判断
- 来源于输入参数的判断
- 来源于返回值的判断
- 来源于模拟方法的调用
- 来源于静态方法的调用
具体内容可以参考《抛出异常来源方式》章节。
5.4.2. 必须验证异常类型、异常属性、异常原因
在验证抛出异常时,必须验证异常类型、异常属性、异常原因等。
正例:
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);
5.4.3. 验证抛出异常后,必须验证相关方法调用
那些年我写过的mysql命令