简介: 立足于“如何来编写单元测试用例”,让大家“有章可循”,快速编写出单元测试用例。
作者 | 常意
来源 | 阿里技术公众号
温馨提示:本文较长,同学们可收藏后再看 :)
前言
清代杰出思想家章学诚有一句名言:“学必求其心得,业必贵其专精。”
意思是:学习上一定要追求心得体会,事业上一定要贵以专注精深。做技术就是这样,一件事如果做到了极致,就必然会有所心得体会。作者最近在一个项目上,追求单元测试覆盖率到极致,所以才有了这篇心得体会。
上一篇文章《Java单元测试技巧之PowerMock》除了介绍单元测试基础知识外,主要介绍了“为什么要编写单元测试”。很多同学读完后,还是不能快速地编写单元测试用例。而这篇文章,立足于“如何来编写单元测试用例”,能够让同学们“有章可循”,能快速地编写出单元测试用例。
1.编写单元测试用例
1.1.测试框架简介
Mockito是一个单元测试模拟框架,可以让你写出优雅、简洁的单元测试代码。Mockito采用了模拟技术,模拟了一些在应用中依赖的复杂对象,从而把测试对象和依赖对象隔离开来。
PowerMock是一个单元测试模拟框架,是在其它单元测试模拟框架的基础上做出扩展。 通过提供定制的类加载器以及一些字节码篡改技术的应用,PowerMock实现了对静态方法、构造方法、私有方法以及final方法的模拟支持等强大的功能。但是,正因为PowerMock进行了字节码篡改,导致部分单元测试用例并不被JaCoco统计覆盖率。
通过作者多年单元测试的编写经验,优先推荐使用Mockito提供的功能;只有在Mockito提供的功能不能满足需求时,才会采用PowerMock提供的功能;但是,不推荐使用影响JaCoco统计覆盖率的PowerMock功能。在本文中,我们也不会对影响JaCoco统计覆盖率的PowerMock功能进行介绍。
下面,将以Mockito为主、以PowerMock为辅,介绍一下如何编写单元测试用例。
1.2.测试框架引入
为了引入Mockito和PowerMock包,需要在maven项目的pom.xml文件中加入以下包依赖:
其中,powermock.version为2.0.9,为当前的最新版本,可根据实际情况修改。在PowerMock包中,已经包含了对应的Mockito和JUnit包,所以无需单独引入Mockito和JUnit包。
1.3.典型代码案例
一个典型的服务代码案例如下:
/**
* 用户服务类
*/
@Service
public class UserService {
/** 服务相关 */
/** 用户DAO */
@Autowired
private UserDAO userDAO;
/** 标识生成器 */
@Autowired
private IdGenerator idGenerator;
/** 参数相关 */
/** 可以修改 */
@Value("${userService.canModify}")
private Boolean canModify;
/**
* 创建用户
*
* @param userCreate 用户创建
* @return 用户标识
*/
public Long createUser(UserVO userCreate) {
// 获取用户标识
Long userId = userDAO.getIdByName(userCreate.getName());
// 根据存在处理
// 根据存在处理: 不存在则创建
if (Objects.isNull(userId)) {
userId = idGenerator.next();
UserDO create = new UserDO();
create.setId(userId);
create.setName(userCreate.getName());
userDAO.create(create);
}
// 根据存在处理: 已存在可修改
else if (Boolean.TRUE.equals(canModify)) {
UserDO modify = new UserDO();
modify.setId(userId);
modify.setName(userCreate.getName());
userDAO.modify(modify);
}
// 根据存在处理: 已存在禁修改
else {
throw new UnsupportedOperationException("不支持修改");
}
// 返回用户标识
return userId;
}
}
1.4.测试用例编写
采用Mockito和PowerMock单元测试模拟框架,编写的单元测试用例如下:
UserServiceTest.java:
/**
* 用户服务测试类
*/
@RunWith(PowerMockRunner.class)
public class UserServiceTest {
/** 模拟依赖对象 */
/** 用户DAO */
@Mock
private UserDAO userDAO;
/** 标识生成器 */
@Mock
private IdGenerator idGenerator;
/** 定义被测对象 */
/** 用户服务 */
@InjectMocks
private UserService userService;
/**
* 在测试之前
*/
@Before
public void beforeTest() {
// 注入依赖对象
Whitebox.setInternalState(userService, "canModify", Boolean.TRUE);
}
/**
* 测试: 创建用户-新
*/
@Test
public void testCreateUserWithNew() {
// 模拟依赖方法
// 模拟依赖方法: userDAO.getByName
Mockito.doReturn(null).when(userDAO).getIdByName(Mockito.anyString());
// 模拟依赖方法: idGenerator.next
Long userId = 1L;
Mockito.doReturn(userId).when(idGenerator).next();
// 调用被测方法
String text = ResourceHelper.getResourceAsString(getClass(), "userCreateVO.json");
UserVO userCreate = JSON.parseObject(text, UserVO.class);
Assert.assertEquals("用户标识不一致", userId, userService.createUser(userCreate));
// 验证依赖方法
// 验证依赖方法: userDAO.getByName
Mockito.verify(userDAO).getIdByName(userCreate.getName());
// 验证依赖方法: idGenerator.next
Mockito.verify(idGenerator).next();
// 验证依赖方法: userDAO.create
ArgumentCaptor < UserDO> userCreateCaptor = ArgumentCaptor.forClass(UserDO.class);
Mockito.verify(userDAO).create(userCreateCaptor.capture());
text = ResourceHelper.getResourceAsString(getClass(), "userCreateDO.json");
Assert.assertEquals("用户创建不一致", text, JSON.toJSONString(userCreateCaptor.getValue()));
// 验证依赖对象
Mockito.verifyNoMoreInteractions(idGenerator, userDAO);
}
/**
* 测试: 创建用户-旧
*/
@Test
public void testCreateUserWithOld() {
// 模拟依赖方法
// 模拟依赖方法: userDAO.getByName
Long userId = 1L;
Mockito.doReturn(userId).when(userDAO).getIdByName(Mockito.anyString());
// 调用被测方法
String text = ResourceHelper.getResourceAsString(getClass(), "userCreateVO.json");
UserVO userCreate = JSON.parseObject(text, UserVO.class);
Assert.assertEquals("用户标识不一致", userId, userService.createUser(userCreate));
// 验证依赖方法
// 验证依赖方法: userDAO.getByName
Mockito.verify(userDAO).getIdByName(userCreate.getName());
// 验证依赖方法: userDAO.modify
ArgumentCaptor < UserDO> userModifyCaptor = ArgumentCaptor.forClass(UserDO.class);
Mockito.verify(userDAO).modify(userModifyCaptor.capture());
text = ResourceHelper.getResourceAsString(getClass(), "userModifyDO.json");
Assert.assertEquals("用户修改不一致", text, JSON.toJSONString(userModifyCaptor.getValue()));
// 验证依赖对象
Mockito.verifyNoInteractions(idGenerator);
Mockito.verifyNoMoreInteractions(userDAO);
}
/**
* 测试: 创建用户-异常
*/
@Test
public void testCreateUserWithException() {
// 注入依赖对象
Whitebox.setInternalState(userService, "canModify", Boolean.FALSE);
// 模拟依赖方法
// 模拟依赖方法: userDAO.getByName
Long userId = 1L;
Mockito.doReturn(userId).when(userDAO).getIdByName(Mockito.anyString());
// 调用被测方法
String text = ResourceHelper.getResourceAsString(getClass(), "userCreateVO.json");
UserVO userCreate = JSON.parseObject(text, UserVO.class);
UnsupportedOperationException exception = Assert.assertThrows("返回异常不一致",
UnsupportedOperationException.class, () -> userService.createUser(userCreate));
Assert.assertEquals("异常消息不一致", "不支持修改", exception.getMessage());
}
}
userCreateVO.json:
{"name":"test"}
userCreateDO.json:
{"id":1,"name":"test"}
userModifyDO.json:
{"id":1,"name":"test"}
通过执行以上测试用例,可以看到对源代码进行了100%的行覆盖。
2.测试用例编写流程
通过上一章编写Java类单元测试用例的实践,可以总结出以下Java类单元测试用例的编写流程:
单元测试用例编写流程
上面一共有3个测试用例,这里仅以测试用例testCreateUserWithNew(测试: 创建用户-新)为例说明。
2.1.定义对象阶段
第1步是定义对象阶段,主要包括定义被测对象、模拟依赖对象(类成员)、注入依赖对象(类成员)3大部分。
2.1.1.定义被测对象
在编写单元测试时,首先需要定义被测对象,或直接初始化、或通过Spy包装……其实,就是把被测试服务类进行实例化。
/** 定义被测对象 */
/** 用户服务 */
@InjectMocks
private UserService userService;
2.1.2.模拟依赖对象(类成员)
在一个服务类中,我们定义了一些类成员对象——服务(Service)、数据访问对象(DAO)、参数(Value)等。在Spring框架中,这些类成员对象通过@Autowired、@Value等方式注入,它们可能涉及复杂的环境配置、依赖第三方接口服务……但是,在单元测试中,为了解除对这些类成员对象的依赖,我们需要对这些类成员对象进行模拟。
/** 模拟依赖对象 */
/** 用户DAO */
@Mock
private UserDAO userDAO;
/** 标识生成器 */
@Mock
private IdGenerator idGenerator;
2.1.3.注入依赖对象(类成员)
当模拟完这些类成员对象后,我们需要把这些类成员对象注入到被测试类的实例中。以便在调用被测试方法时,可能使用这些类成员对象,而不至于抛出空指针异常。
/** 定义被测对象 */
/** 用户服务 */
@InjectMocks
private UserService userService;
/**
* 在测试之前
*/
@Before
public void beforeTest() {
// 注入依赖对象
Whitebox.setInternalState(userService, "canModify", Boolean.TRUE);
}
2.2.模拟方法阶段
第2步是模拟方法阶段,主要包括模拟依赖对象(参数或返回值)、模拟依赖方法2大部分。
2.2.1.模拟依赖对象(参数或返回值)
通常,在调用一个方法时,需要先指定方法的参数,然后获取到方法的返回值。所以,在模拟方法之前,需要先模拟该方法的参数和返回值。
Long userId = 1L;
2.2.2.模拟依赖方法
在模拟完依赖的参数和返回值后,就可以利用Mockito和PowerMock的功能,进行依赖方法的模拟。如果依赖对象还有方法调用,还需要模拟这些依赖对象的方法。
// 模拟依赖方法
// 模拟依赖方法: userDAO.getByName
Mockito.doReturn(null).when(userDAO).getIdByName(Mockito.anyString());
// 模拟依赖方法: idGenerator.next
Mockito.doReturn(userId).when(idGenerator).next();
2.3.调用方法阶段
第3步是调用方法阶段,主要包括模拟依赖对象(参数)、调用被测方法、验证参数对象(返回值)3步。
2.3.1.模拟依赖对象(参数)
在调用被测方法之前,需要模拟被测方法的参数。如果这些参数还有方法调用,还需要模拟这些参数的方法。
String text = ResourceHelper.getResourceAsString(getClass(), "userCreateVO.json");
UserVO userCreate = JSON.parseObject(text, UserVO.class);
2.3.2.调用被测方法
在准备好参数对象后,就可以调用被测试方法了。如果被测试方法有返回值,需要定义变量接收返回值;如果被测试方法要抛出异常,需要指定期望的异常。
userService.createUser(userCreate)
2.3.3.验证数据对象(返回值)
在调用被测试方法后,如果被测试方法有返回值,需要验证这个返回值是否符合预期;如果被测试方法要抛出异常,需要验证这个异常是否满足要求。
Assert.assertEquals("用户标识不一致", userId, userService.createUser(userCreate));
2.4.验证方法阶段
第4步是验证方法阶段,主要包括验证依赖方法、验证数据对象(参数)、验证依赖对象3步。
2.4.1.验证依赖方法
作为一个完整的测试用例,需要对每一个模拟的依赖方法调用进行验证。
// 验证依赖方法
// 验证依赖方法: userDAO.getByName
Mockito.verify(userDAO).getIdByName(userCreate.getName());
// 验证依赖方法: idGenerator.next
Mockito.verify(idGenerator).next();
//