5个编写技巧,有效提高单元测试实践
一、什么是单元测试
“在计算机编程中,单元测试又称为模块测试,是针对程序模块来进行正确性检验的测试工作。 程序单元是应用的最小可测试部件。 在过程化编程中,一个单元就是单个程序、函数、过程等; 对于面向对象编程,最小单元就是方法,包括基类、抽象类、或者派生类中的方法。 ” 摘录来自 维基百科
单元测试(Unit Testing)顾名思义就是测试一个单元,这里的单元通常指一个函数或类,区别于集成测试中的模块和系统。 集成测试的测试过程通常存在跨系统模块的调用,是一种端到端的测试; 而单元测试关注对象的颗粒度较小,用来保障一个类或者函数是否按照预期正确的执行。
二、为什么要写单元测试
上面这张图,旨在说明两个问题:
- 85%的缺陷都在代码设计阶段产生;
-
发现bug的阶段越靠后,耗费成本就越高,呈指数级别的增长。
三、怎样编写单元测试
3.1.1 单元测试框架JUnit
- 用于测试期望结果的断言(Assertion)
-
用于共享共同测试数据的测试工具
-
用于方便的组织和运行测试的测试套件
-
图形和文本的测试运行器
3.1.2 单元测试Mock框架 项目中依赖关系往往往非常复杂,单元测试Mock框架做的事就是模拟被测试类的依赖项,提供预期的行为和状态,使得我们的单测可以聚焦在被测试类本身,而不必受到依赖项的复杂度的影响。
- 测试失败,mock工具与jacoco同时修改字节码时引入的冲突
-
某些类的覆盖率为0
1、 在版本3.4.0以后,Mockito支持静态方法的mock。并且作为SpringBootTest默认集成的Mock工具,所以建议大家使用高版本的Mockito,并通过它来完成静态方法的Mock。
2、 不提倡使用PowerMock,并不是一味追求单测覆盖率,而是当我们需要使用到具备高级特性mock工具时,我们需要审视代码的合理性,并尝试进行优化重构,使其具备较好的可测性。
3.1.3 依赖引入 3.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>
<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>
-
单元测试类需要放在工程的test目录下,比如 xxx/src/test/java
-
单测类的命名按照规范,应以被测类名开头,并追加Test作为结尾,比如
3.2.2.1 测试方法的命名
建议采用 should_{预期结果}_when_{被测方法}_given_{给定场景} 举个例子:
@Testpublic void should_returnFalse_when_deleteContent_given_invokeFailed() {...}
反例:
@Testpublic void testDeleteContent() {...}
3.2.2.2 单测方法实现分层
单测方法的实现如果分层清晰,能让代码便于理解,一目了然,同时也能提高后续的CR的效率。
这里我们建议采用 given-when-then 的三段落结构。
举个例子:
@Testpublic void should_returnFalse_when_deleteContent_given_invokeFailed() {// givenResult<Boolean> deleteDocResult = new Result<>();deleteDocResult.setEntity(Boolean.FALSE);when(docManageService.deleteContentDoc(anyLong())).thenReturn(deleteDocResult);when(docManageService.queryContentDoc(anyLong())).thenReturn(new DocEntity());// whenLong contentId = 123L;Boolean result = contentService.deleteContent(contentId);// thenverify(docManageService, times(1)).queryContentDoc(contentId);verify(docManageService, times(1)).deleteContentDoc(contentId);Assert.assertFalse(result);}
public class SnsFeedsShareServiceImpl {private SnsFeedsShareHandler snsFeedsShareHandler;@Autowiredpublic 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();}}// 省略代码...}
@RunWith(MockitoJUnitRunner.class)public class SnsFeedsShareServiceImplTest {@MockSnsFeedsShareHandler snsFeedsShareHandler;@InjectMocksSnsFeedsShareServiceImpl snsFeedsShareServiceImpl;@Testpublic void should_returnServiceError_when_shareFeeds_given_invokeFailed() {// givenResult<Boolean> invokeResult = new Result<>();invokeResult.setSuccess(Boolean.FALSE);invokeResult.setModel(Boolean.FALSE);when(snsFeedsShareHandler.batchShareFeeds(anyLong(), anyString(), anyList())).thenReturn(invokeResult);// whenLong feedsId = 123L;String platform = "TEST_SNS_PLATFORM";List<String> snsAccountList = Collections.singletonList("TEST_SNS_ACCOUNT");Result<List<String>> result = snsFeedsShareServiceImpl.shareFeeds(feedsId, platform, snsAccountList);// thenverify(snsFeedsShareHandler, times(1)).batchShareFeeds(feedsId, platform, snsAccountList);Assert.assertNotNull(result);Assert.assertEquals(result.getResponseCode(), ResponseEnum.SNS_SHARE_SERVICE_ERROR.getResponseCode());}}
@RunWith(MockitoJUnitRunner.class)public class ContentServiceTest {@MockDocManageService docManageService;@InjectMocksContentService contentService;...}
-
MockitoJUnitRunner 使Mockito的注解生效或者使用初始化方法 MockitoAnnotations.initMocks(this) -
利用 @Mock 模拟各种依赖对象 -
使用 @InjectMocks 将mock出的依赖对象注入到目标测试对象中。以上述代码为例,单测中将 docManageService 注入到 contentService
当然我们也可以使用直接初始化或者
@Spy
的方式来模拟对象,然后使用Setter方法来进行模拟对象的注入,这里介绍了较为简便的方式。
3.4.2.1 Mock无返回值方法
doNothing().when(contentService.deleteContent(anyLong()));
// givenResult<Boolean> deleteResult = new Result<>(Boolean.FALSE);when(contentService.deleteContent(anyLong())).thenReturn(deleteResult);
when(contentService.deleteContent(anyLong())).thenCallRealMethod();
when(contentService.deleteContent(anyLong())).thenThrow(NullPointerException.class);
3.4.3.1 验证依赖方法的调用
// 验证调用方法的入参,指定为"testTagId"verify(tagOrmService).queryByValue("testTagId");// 验证queryByValue方法被调用了2次verify(tagOrmService, times(2)).queryByValue(anyString());
对验证方法的返回值或异常进行验证
// thenAssert.assertNotNull(result);Assert.assertEquals(result.getResponseCode(), 200);// 其他常用的断言函数Assert.assertTrue(...);Assert.assertFalse(...);Assert.assertSame(...);Assert.assertEquals(...);Assert.assertArrayEquals(...);
MockedStatic<TagHandler> tagHandlerMockedStatic = Mockito.mockStatic(TagHandler.class);tagHandlerMockedStatic.when(() -> TagHandler.getSingleCommonTag(anyString())).thenReturn("tag");
在执行 mvn test 时,如果有多个测试方法mock了 Mockito.mockStatic(TagHandler.class) ,会报错,因为静态方法是类级别的,会出现注册多次的情况。可以参考下面两种解法:
@AfterClass 注解方法:只被执行一次;运行junit测试类时最后一个被执行的方法
示例:
@RunWith(MockitoJUnitRunner.class)public class ContentServiceTest {@MockDocManageService docManageService;@InjectMocksContentService contentService;private static MockedStatic<TagHandler> tagHandlerMockedStatic = null;@BeforeClasspublic static void beforeTest() {tagHandlerMockedStatic = Mockito.mockStatic(TagHandler.class);tagHandlerMockedStatic.when(() -> TagHandler.getSingleCommonTag(anyString())).thenReturn("testTag");}// 省略测试方法@AfterClasspublic static void afterTest() {tagHandlerMockedStatic.close();}}
2、
在
try-with-resources
构造中定义模拟
@RunWith(MockitoJUnitRunner.class)public class ContentServiceTest {@MockDocManageService docManageService;@InjectMocksContentService contentService;@Testpublic void should_returnEmptyList_when_queryContentTags_given_invokeParams() throws Exception {try (MockedStatic<TagHandler> tagHandlerMockedStatic = Mockito.mockStatic(TagHandler.class)) {tagHandlerMockedStatic.when(() -> TagHandler.getSingleCommonTag(anyString())).thenReturn("testTag");// 省略单测方法具体实现...}}}
3.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
@Testpublic void should_returnNull_when_select_given_invalidQueryCondition() throws Exception {// whenTableService tableService = mock(TableService.class, RETURNS_DEEP_STUBS);when(tableService.select().from(anyString()).where(any()).limit(anyInt())).thenReturn(null);Object result = lindormClient.select(new QueryCondition());// thenAssert.isNull(result);}
1、 安装:在IDEA设置中的Plguins插件里搜索TestMe,下载安装即可。
2、 使用:在code按钮找到入口,或者直接使用快捷键 option+shift+Q
四、如何落地单元测试
4.2.2 发布管控 当我们提交代码后,CI可以设置运行该分支的单元测试。在发布流程中,添加单测相关的管控,比如单元测试通过率以及单元测试增量覆盖率等
五、后记
单元测试是一件知易行难的事情,公司也在积极宣导和建设单测文化。工作方式的改变其实难度并不大,难的是能够建立一致的共识,并从心底认可单元测试的价值,只有这样才能有效落地。参考链接: [1]https://plugins.jetbrains.com/plugin/9471-testme [2]https://plugins.jetbrains.com/plugin/14946-diffblue-cover--create-complete-junit-tests-with-ai
写下你的使用体验,就有机会获得定制冲锋衣、蓝牙音箱、定制鲁班锁、520元云原生通用代金券、消息队列MNS免费使用半年等多重好礼。
点击阅读原文查看详情。