1. 怎样编写单元测试
1.1 单元测试框架的构建
1.1.1 单元测试框架JUnit
·用于测试期望结果的断言(Assertion)
· 用于共享共同测试数据的测试工具
· 用于方便的组织和运行测试的测试套件
· 图形和文本的测试运行器
多数Java的开发环境都已经集成了JUnit作为单元测试的工具,开源框架对JUnit 都有相应的支持
1.1.2 单元测试Mock框架
项目中依赖关系往往往非常复杂,单元测试Mock框架做的事就是模拟被测试类的依赖项,提供预期的行为和状态,使得我们的单测可以聚焦在被测试类本身,而不必受到依赖项的复杂度的影响。
这里我们讨论常用的Mockito与PowerMock,两者都是作为单元测试模拟框架,模拟应用中复杂的依赖对象。Mockito基于动态代理的方式实现,PowerMock在Mockito基础上增加了类加载器以及字节码篡改技术,使其可以实现完成对private/static/final方法的Mock。
公司使用JaCoCo来做单元覆盖率的检测,当我们使用支持字节码篡改的mock工具的时候,可能会造成:
· 测试失败,mock工具与jacoco同时修改字节码时引入的冲突
· 某些类的覆盖率为0
所以我们推荐使用Mockito来作为我们的单元测试Mock框架,原因有二:
1. 在版本3.4.0以后,Mockito支持静态方法的mock。并且作为SpringBootTest默认集成的Mock工具,所以建议大家使用高版本的Mockito,并通过它来完成静态方法的Mock。
2. 不提倡使用PowerMock,并不是一味追求单测覆盖率,而是当我们需要使用到具备高级特性mock工具时,我们需要审视代码的合理性,并尝试进行优化重构,使其具备较好的可测性。
1.1.3 依赖引入
1.1.3.1 添加JUnit的maven依赖
·Springboot项目
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
· SpringMVC项目
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
1.1.3.2 单测Mock框架的引入
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>4.7.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
<version>4.7.0</version>
<scope>test</scope>
</dependency>
1.2 单测方法的命名
1.2.1 单元测试类的规范
·单元测试类需要放在工程的test目录下,比如xxx/src/test/java
· 单测类的命名按照规范,应以被测类名开头,并追加Test作为结尾,比如ContentService -> ContentServiceTest
1.2.2 单元测试方法规范
1.2.2.1 测试方法的命名
好的单元测试方法名,能让我们快速知道测试的场景、意图及验证的预期。
建议采用should_{预期结果}_when_{被测方法}_given_{给定场景}
举个
@Test
public void should_returnFalse_when_deleteContent_given_invokeFailed() {
...
}
反例
@Test
public void testDeleteContent() {
...
}
1.2.2.2 单测方法实现分层
单测方法的实现如果分层清晰,能让代码便于理解,一目了然,同时也能提高后续的CR的效率
这里我们建议采用given-when-then的三段落结构
举个
@Test
public void should_returnFalse_when_deleteContent_given_invokeFailed() {
// given
Result<Boolean> deleteDocResult = new Result<>();
deleteDocResult.setEntity(Boolean.FALSE);
when(docManageService.deleteContentDoc(anyLong())).thenReturn(deleteDocResult);
when(docManageService.queryContentDoc(anyLong())).thenReturn(new DocEntity());
// when
Long contentId = 123L;
Boolean result = contentService.deleteContent(contentId);
// then
verify(docManageService, times(1)).queryContentDoc(contentId);
verify(docManageService, times(1)).deleteContentDoc(contentId);
Assert.assertFalse(result);
}
1.3 单测方法的示例
1.3.1 代码案例
public class SnsFeedsShareServiceImpl {
private SnsFeedsShareHandler snsFeedsShareHandler;
@Autowired
public void setSnsFeedsShareHandler(SnsFeedsShareHandler snsFeedsShareHandler) {
this.snsFeedsShareHandler = snsFeedsShareHandler;
}
public Result<Boolean> shareFeeds(Long feedsId, String platform, List<String> snsAccountList) {
if (!validateParams(feedsId, platform, snsAccountList)) {
return ResponseBuilder.paramError();
}
try {
Result<Boolean> snsResult = snsFeedsShareHandler.batchShareFeeds(feedsId, platform, snsAccountList);
if (Objects.isNull(snsResult) || !snsResult.isSuccess() || Objects.isNull(snsResult.getModel())) {
return ResponseBuilder.buildError(ResponseEnum.SNS_SHARE_SERVICE_ERROR);
}
return ResponseBuilder.successResult(snsResult.getModel());
} catch (Exception e) {
LOGGER.error("shareFeeds error, feedsId:{}, platform:{}, snsAccountList:{}",
feedsId, platform, JSON.toJSONString(snsAccountList), e);
return ResponseBuilder.systemError();
}
}
// 省略代码...
}
1.3.2 单元测试代码案例
@RunWith(MockitoJUnitRunner.class)
public class SnsFeedsShareServiceImplTest {
@Mock
SnsFeedsShareHandler snsFeedsShareHandler;
@InjectMocks
SnsFeedsShareServiceImpl snsFeedsShareServiceImpl;
@Test
public void should_returnServiceError_when_shareFeeds_given_invokeFailed() {
// given
Result<Boolean> invokeResult = new Result<>();
invokeResult.setSuccess(Boolean.FALSE);
invokeResult.setModel(Boolean.FALSE);
when(snsFeedsShareHandler.batchShareFeeds(anyLong(), anyString(), anyList())).thenReturn(invokeResult);
// when
Long feedsId = 123L;
String platform = "TEST_SNS_PLATFORM";
List<String> snsAccountList = Collections.singletonList("TEST_SNS_ACCOUNT");
Result<List<String>> result = snsFeedsShareServiceImpl.shareFeeds(feedsId, platform, snsAccountList);
// then
verify(snsFeedsShareHandler, times(1)).batchShareFeeds(feedsId, platform, snsAccountList);
Assert.assertNotNull(result);
Assert.assertEquals(result.getResponseCode(), ResponseEnum.SNS_SHARE_SERVICE_ERROR.getResponseCode());
}
}
1.4 单测的编码技巧
1.4.1 Mock依赖对象
@RunWith(MockitoJUnitRunner.class)
public class ContentServiceTest {
@Mock
DocManageService docManageService;
@InjectMocks
ContentService contentService;
...
}
·MockitoJUnitRunner使Mockito的注解生效或者使用初始化方法MockitoAnnotations.initMocks(this)
· 利用@Mock模拟各种依赖对象
· 使用@InjectMocks将mock出的依赖对象注入到目标测试对象中。以上述代码为例,单测中将docManageService注入到contentService
当然我们也可以使用直接初始化或者@Spy的方式来模拟对象,然后使用Setter方法来进行模拟对象的注入,这里介绍了较为简便的方式。
1.4.2 Mock返回值
1.4.2.1 Mock无返回值方法
doNothing().when(contentService.deleteContent(anyLong()));
1.4.2.2 Mock方法返回值
// given
Result<Boolean> deleteResult = new Result<>(Boolean.FALSE);
when(contentService.deleteContent(anyLong())).thenReturn(deleteResult);
1.4.2.3 执行方法的真实调用
when(contentService.deleteContent(anyLong())).thenCallRealMethod();
1.4.2.4 Mock方法调用异常
when(contentService.deleteContent(anyLong())).thenThrow(NullPointerException.class);
1.4.3 自动化验证
1.4.3.1 验证依赖方法的调用
// 验证调用方法的入参,指定为"testTagId"
verify(tagOrmService).queryByValue("testTagId");
// 验证queryByValue方法被调用了2次
verify(tagOrmService, times(2)).queryByValue(anyString());
1.4.3.2 验证返回值
对验证方法的返回值或异常进行验证
// then
Assert.assertNotNull(result);
Assert.assertEquals(result.getResponseCode(), 200);
// 其他常用的断言函数
Assert.assertTrue(...);
Assert.assertFalse(...);
Assert.assertSame(...);
Assert.assertEquals(...);
Assert.assertArrayEquals(...);
1.4.4 其他单测技巧处理
1.4.4.1 使用Mockito模拟静态方法
MockedStatic<TagHandler> tagHandlerMockedStatic = Mockito.mockStatic(TagHandler.class);
tagHandlerMockedStatic.when(() -> TagHandler.getSingleCommonTag(anyString())).thenReturn("tag");
1.4.4.2 处理Mockito注册静态方法范围
在执行mvn test时,如果有多个测试方法mock了Mockito.mockStatic(TagHandler.class),会报错,因为静态方法是类级别的,会出现注册多次的情况。可以参考下面两种解法:
1. 使用@BeforeClass与@AfterClass
@BeforeClass注解方法:只被执行一次;运行junit测试类时第一个被执行的方法
@AfterClass注解方法:只被执行一次;运行junit测试类时最后一个被执行的方法
示例:
@RunWith(MockitoJUnitRunner.class)
public class ContentServiceTest {
@Mock
DocManageService docManageService;
@InjectMocks
ContentService contentService;
private static MockedStatic<TagHandler> tagHandlerMockedStatic = null;
@BeforeClass
public static void beforeTest() {
tagHandlerMockedStatic = Mockito.mockStatic(TagHandler.class);
tagHandlerMockedStatic.when(() -> TagHandler.getSingleCommonTag(anyString())).thenReturn("testTag");
}
// 省略测试方法
@AfterClass
public static void afterTest() {
tagHandlerMockedStatic.close();
}
}
2.在try-with-resources构造中定义模拟
@RunWith(MockitoJUnitRunner.class)
public class ContentServiceTest {
@Mock
DocManageService docManageService;
@InjectMocks
ContentService contentService;
@Test
public void should_returnEmptyList_when_queryContentTags_given_invokeParams() throws Exception {
try (MockedStatic<TagHandler> tagHandlerMockedStatic = Mockito.mockStatic(TagHandler.class)) {
tagHandlerMockedStatic.when(() -> TagHandler.getSingleCommonTag(anyString())).thenReturn("testTag");
// 省略单测方法具体实现
...
}
}
}
1.4.4.3 如何mock一条链式调用
public T select(QueryCondition queryCondition) throws Exception {
LindormQueryParam params = queryCondition.generateQueryParams();
if (Objects.isNull(params)) {
LOGGER.error("Invalid query condition:{}", queryCondition.toString());
return null;
}
Select select = tableService.select()
.from(params.getTableName())
.where(params.getCondition())
.limit(1);
QueryResults results = select.execute();
return convert(results.next());
}
Mockito提供了形如tableService.select().from(params.getTableName()).where(params.getCondition()).limit(1)链式调用解决办法,mock对象的时候增加参数RETURNS_DEEP_STUBS
@Test
public void should_returnNull_when_select_given_invalidQueryCondition() throws Exception {
// when
TableService tableService = mock(TableService.class, RETURNS_DEEP_STUBS);
when(tableService.select().from(anyString()).where(any()).limit(anyInt())).thenReturn(null);
Object result = lindormClient.select(new QueryCondition());
// then
Assert.isNull(result);
}
1.5 单测生成插件
IDEA有两款比较好用的单测自动生成插件TestMe与Diffblue,这里主要介绍TestMe,如果大家有比较好的插件也可以推荐。
1. 安装:在IDEA设置中的Plguins插件里搜索TestMe,下载安装即可。
2. 使用:在code按钮找到入口,或者直接使用快捷键option+shift+Q。
生成的代码如下:
自动生成插件方便初始化部分代码,可以提升单测编写的效率,但是也存在局限性:单测名称规范、具体实现等还是需要我们完善、补充后才能正常使用。
2. 如何落地单元测试
2.1 清晰单测的价值认知
不难发现,公司内的项目还是外网开源项目,少有工程具备完善、高质量的单元测试。上文讲了为什么要写单测,这里就不再赘述了。短期来看,单测无疑会带来开发工作量和开发时长的增加,但是我们要从整个迭代周期来看单测的优势。从最终的效果来看,坚持单元测试会有效的减少迭代中的缺陷数以及缩短需求的交付周期。
2.2 将单测纳入流程规范
2.2.1 将单元测试纳入CR标准
以往我们CR只关注核心的业务代码,大多数情况下,我们在评审中可以指出代码较为明显的缺陷或者不合理的设计,但是各种条件case、边界及异常情况很难通过肉眼review出来。如果提交的CR中包含完善、高质量的单元测试,提交、评审双方的的信心都会增强。
2.2.2 发布管控
当我们提交代码后,CI可以设置运行该分支的单元测试。在发布流程中,添加单测相关的管控,比如单元测试通过率以及单元测试增量覆盖率等。
2.3 单测工作量评估
对于单元测试工作量的评估,没有一个固定的标准,主要视业务逻辑复杂度而定。一般来说,如果之前没有编写过单元测试,在熟悉阶段可以根据需求的工作量对应增加20%~30%;后期熟练掌握后,增加需求工作量的10%就足够了。当业务需求涉及的case较多,单测需要覆盖这些必要流程时,我们评估工作量时,可以给自己加些时间来保障高质量的单测。
3. 后记
单元测试是一件知易行难的事情,公司也在积极宣导和建设单测文化。工作方式的改变其实难度并不大,难的是能够建立一致的共识,并从心底认可单元测试的价值,只有这样才能有效落地。
本文内容不用于商业目的,如涉及知识产权问题,请权利人联系51Testing小编(021-64471599-8017),我们将立即处理