单元测试实践(SpringCloud+Junit5+Mockito+DataMocker)
网上看过一句话,单元测试就像早睡早起,每个人都说好,但是很少有人做到。从这么多年的项目经历亲身证明,是真的。
这次借着项目内实施单元测试的机会,记录实施的过程和一些总结经验。
项目情况
首先是背景,项目是一个较大型的项目,多个团队协作开发,采用的是SpringCloud作为基础微服务的架构,中间件涉及Redis,MySQL,MQ等等。新的起点开始起步,团队中讨论期望能够利用单元测试来提高代码质量。单元测试的优点很多,但是我觉得最终最终的目标就是质量,单元测试代码如果最终没有能够提高项目质量,说明过程是有问题或者团队没有真正接纳方法,不如放弃来节省大家的开发时间。
一说到单元测试大家肯定会先想起TDD。TDD(Test Dirven Development,测试驱动开发)是以单元测试来驱动开发的方法论。
- 开发一个新功能前,首先编写单元测试用例
- 运行单元测试,全部失败(红色)
- 编写业务代码,并且使对应的单元测试能够通过(绿色)
- 时刻维护你的单元测试,使其始终可运行
一个团队一开始就直接实施TDD的可能性是比较小的,因为适合团队的研发流程、测试底层框架封装、单元测试原则与规范都还没有敲定或者摸索出最佳的实践。直接一开始就完整实施,往往过程会变形,最终目标慢慢会偏离正轨,整个团队也不愿意再接受单元测试。所以建议是逐步开始,让团队切身能够体会到单元测试带来的收益再慢慢加码。
我们的项目基础技术架构是基于SpringCloud,做了一些基础的底层封装。项目之间的调用都是基于Feign,各个项目都是规范要提供各自的Feign接口以及Hystrix的FallbackFactory。我们将对于外部的调用都是封装在底层的service中。
单元测试范围
一个项目需要实施单元测试,首先要界定(或者说澄清)单元测试负责的范围。最常见的疑惑就是与外部系统或者其他中间件的关联,单元测试是否要实际的调用其他中间件/外部系统。
我们先来看看单元测试的定义:
Unit tests are typically automated tests written and run by software developers to ensure that a section of an application (known as the "unit") meets its design and behaves as intended.
单元测试首先应当是自动化的,由开发者编写,为了保证代码片段(最小单元)是按照预期设计实现的。我们理解就是说单元测试要保障的是项目(代码片段逻辑)自身按照设计意图正确执行,所以确认了单元测试的范围仅限于单个项目内部,因此要尽量屏蔽所有的外部系统或中间件。代码的业务逻辑覆盖80%-90%,其他部分(工具类等)不做要求。
我们项目涉及到了一些中间件(Mysql,Redis,MQ等),但是更多涉及到的内部其他支撑系统。用项目内的实际情况我们当前定义的单元测试覆盖的范围就是,单元测试从controller作为入口,尽量覆盖到controller和service所有的方法与逻辑,所有的外部接口调用全部mock,中间件尽量使用内存中间件进行mock。
单元测试基础框架
既然项目是基于SpringCloud,那测试肯定会引入基础的spring-boot-test,底层的测试框架选择是junit。
Junit主流还是junit4(Github地址)最新版本是4.12(2014年12月5日),现在最新的是junit5(JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage)。junit5正式版本的发布日期是2017年9月11日,目前最新的版本是5.5.2(2019年9月9日)。我们项目底层选择了junit5。
目前,在 Java 阵营中主要的 Mock 测试工具有 Mockito,JMock,EasyMock 等。我们选择了Mockito,这个是没有经过特别的选型。简单比较之后选择了比较容易上手并且能够满足当前需求的一款。
redis使用了redis-mock (ai.grakn:redis-mock:0.1.6)
数据库自然是使用h2(com.h2database:h2:1.4.192)(不过在一期项目我们主要服务编排,没有涉及到数据库的实例)
模拟数据生成参考了jmockdata(com.github.jsonzou:jmockdata:4.1.2),但是做了一些小小的调整增加了一些其他的类型
另外,Mockito不支持static的的方法的mock,要使用PowerMock来模拟。但是PowerMock似乎现在还不支持junit5,我们没有使用。
单元测试实施
基本框架搭建完毕,基本就进入了编码阶段。第一期的编码,我们实际上还是先写了业务代码,然后再写单元测试。接下来就详细介绍一下单元测试类的结构。这里给的示例仅仅是我们在实践过程中有使用到的,并非junit5的完整注解或者使用讲解,具体需要了解大家可以参考官网。
单元测试基本结构
先看一下头部的几个注解,这些都是Junit5的
// 替换了Junit4中的RunWith和Rule @ExtendWith(SpringExtension.class) //提供spring依赖注入 @SpringBootTest // 运行单元测试时显示的名称 @DisplayName("Test MerchantController") // 单元测试时基于的配置文件 @TestPropertySource(locations = "classpath:ut-bootstrap.yml") class MerchantControllerTest{ private static RedisServer server = null; // 下面三个mock对象是由spring提供的 @Resource MockHttpServletRequest request; @Resource MockHttpSession session; @Resource MockHttpServletResponse response; // junit4中 @BeforeClass @BeforeAll static void initAll() throws IOException { server = RedisServer.newRedisServer(9379); server.start(); } // junit4中@Before @BeforeEach void init() { request.addHeader("token", "test_token"); } // junit4中@After @AfterEach void tearDown() { } // junit4中@AfterClass @AfterAll static void tearDownAll() { server.stop(); server = null; } }
这些都是比较基础的注解,基本也和junit4一一对应。这里没有太多可说的,可以看到我们在初始化方法中加载了虚拟的redis服务器,在前置方法中设置了Header的值
单元测试的主体方法
我们测试的主要的就是MerchantController这个类,这个类下面还有一层service方法。先看一下大概的代码印象。
@Resource MerchantController merchantController; @MockBean private IOrderClient orderClient; @Test void getStoreInfoById() { MockConfig mockConfig = new MockConfig(); mockConfig.setEnabledCircle(true); mockConfig.sizeRange(2, 5); MerchantOrderQueryVO merchantOrderQueryVO = Mock.mock(MerchantOrderQueryVO.class); StoreInfoDTO storeInfoDTO = Mock.mock(StoreInfoDTO.class,mockConfig); Mockito.when(orderClient.bizInfoV3(Mockito.any())).thenReturn(R.data(storeInfoDTO)); Mockito.when(orderClient.getOrderCount(Mockito.any())).thenReturn(R.data(merchantOrderQueryVO)); R<StoreInfoBizVO> r = merchantController.getStoreInfoById(); assertEquals(r.getData().getAvailableOrderCount(), merchantOrderQueryVO.getOrderNum()); assertEquals(r.getData().getId(), storeInfoDTO.getId()); assertEquals(r.getData().getBranchName(), storeInfoDTO.getBranchName()); } @ParameterizedTest @ValueSource(ints = {1, 0}) void logoutCheck(Integer onlineValue) { MockConfig mockConfig = new MockConfig(); mockConfig.setEnabledCircle(true); mockConfig.sizeRange(2, 5); MerchantOrderQueryVO merchantOrderQueryVO = Mock.mock(MerchantOrderQueryVO.class); StoreInfoDTO storeInfoDTO = Mock.mock(StoreInfoDTO.class,mockConfig); storeInfoDTO.setOnline(onlineValue); Mockito.when(orderClient.bizInfoV3(Mockito.any())).thenReturn(R.data(storeInfoDTO)); Mockito.when(orderClient.getOrderCount(Mockito.any())).thenReturn(R.data(merchantOrderQueryVO)); R r = merchantController.logoutCheck(); if (1==onlineValue) { assertEquals(ResourceAccessor.getResourceMessage( MerchantbizConstant.USER_LOGOUT_CHECK_ONLINE), r.getMsg()); } else { assertEquals(ResourceAccessor.getResourceMessage( MerchantbizConstant.USER_LOGOUT_CHECK_UNCOMPLETED), r.getMsg()); } } @ParameterizedTest @CsvSource({"1,Selma,true", "2,Lisa,true", "3,Tim,false"}) void forTest(int id,String name,boolean t) { System.out.println("id="+id+" name="+name+" tORf="+t); merchantController.forTest(null); }
首先看变量的部分,这里给了两个例子,一个注解是@Resource,这个是让spring来注入的。另外一个是@MockBean,这就是Mockito提供的,并且结合下面的Mockito.when方法。
接下来看方法体,我将方法主体分为三部分:
- Mock数据与方法
使用Mock拦截底层的外部接口方法,并且返回随机的Mock数据(大部分数据可以使用DataMocker生成,有一些特殊有限制的,可以手动生成)。 - 测试方法执行
执行目标测试方法(基本都是一行,直接调用目标方法并且返回结果) - 结果断言
根据业务逻辑预期进行断言的编写(这部分基本上没有自动化的方式,因为断言的条件和业务逻辑相关只能手动编写)
这样写下来是基本逻辑的验证,还有内部有分支逻辑,如何验证?
代码当中实际上也提到了,就是junit5提供的@ParameterizedTest注解,配合@ValueSource, @CsvSource来使用,分别可以设置指定类型或者复杂类型到单元测试中,使用方法的参数接受,定义测试不同的分支。
单元测试的执行
单元测试的执行实际上分成2部分:
- IDE中我们要去验证单元测试是否能够成功执行
- CI/CD作为执行的先决条件保障
IDE可以直接指定测试框架,我们选择junit5直接生成单元测试代码,可以直接在测试包或者类上右键执行单元测试。这个方法可以作为我们开发过程中验证待遇测试有效性的手段。但是真正要能在生产开发流程中更好的体现单元测试的价值,还是需要持续集成的支持,我们项目使用的是jenkins。依赖是Maven,以及maven-surefire-plugin插件。要特别注意一点,由于junit5还比较新,所以maven-surefire-plugin插件支持junit5还是稍微有点特殊的,参考官网说明。我们需要引入插件:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>3.0.0-M3</version> <configuration> <excludes> <exclude>some test to exclude here</exclude> </excludes> </configuration> </plugin>
这样在jenkins构建时就会执行单元测试,如果单元测试失败,不会触发构建后操作(Post Steps)。
总结
目前我们的项目中,单元测试的应用还在第一期,但是投入在上面的时间和精力,实际上到实际开发时间的2-3倍。因为涉及到基础框架的搭建,新框架的引入整合,底层开发编写测试代码的审核,团队的培训等等。我预计在后期,成熟的框架和流程支持下,覆盖核心业务代码的单元测试耗时应该能到实际开发工时的50%-80%左右。但是这部分的投入是能够减少测试以及线上的问题发生的概率,节省了修复的时间。
团队目前还不能完全习惯单元测试的节奏,目前带来的直接益处还不够明显,但是一个好的习惯的养成,还是需要管理者投入精力同时从上而下的推动的。
后期应该对于单元测试的执行还有一些调整或改进,而且对其概念、流程等方面应该也会有更深入和实际的理解。届时还会再次整理,并且分享给大家。