单元测试就像胖人说减肥,每个人都说好,但是很少有人做到。从这么多年的项目经历亲身证明,还真是的。
这次借着项目内实施单元测试的机会,记录实施的过程和一些总结经验。
项目情况
首先是背景,项目是一个较大型的历史二手项目,前任团队移交过来的项目,与多个外部服务有交互,采用的是SpringCloud作为基础微服务的架构,中间件涉及Redis,MySQL,RabbitMQ等等。,团队中讨论期望能够利用单元测试来提高服务质量,减少业务投诉。
单元测试的优点很多,但是我觉得最终的目标就是服务质量,单元测试代码如果最终没有能够提高项目质量,说明过程是有问题或者团队没有真正接纳方法,为了完成任务而写测试,以时间不足为由拖延测试用例的开发。
说到单元测试大家肯定会先想起TDD。TDD(Test Dirven Development,测试驱动开发)是以单元测试来驱动开发的方法论。
- 开发一个新功能前,首先编写单元测试用例,用于模拟验收或目标功能情况
- 运行单元测试,全部失败,即目标已设定
- 编写业务代码,并且使对应的单元测试能够通过,目标达成。
- 时刻维护你的单元测试,使其始终可通过运行
一开始就直接实施TDD的可能性是比较小的,需要慢慢培训,渗透思想,在长期的运维与应用中让团队感受到测试带来的益处。慢慢建立测试相关规范和体制。让团队成员爱上单元测试。
单元测试范围
一个项目需要实施单元测试,首先要界定(或者说澄清)单元测试负责的范围。最常见的疑惑就是与外部系统或者其他中间件的关联,单元测试是否要实际的调用其他中间件/外部系统。
在敏捷和DevOps的思想指导下,单元测试首先应当是自动化的,由开发者编写,为了保证代码片段(最小单元)是按照预期设计实现的。我们理解就是说单元测试要保障的是项目(代码片段逻辑)自身按照设计意图正确执行,所以确认了单元测试的范围仅限于单个项目内部,因此要尽量屏蔽所有的外部系统或中间件以及外部系统的依赖。代码的业务逻辑覆盖率80%-90%,其他部分(工具类等覆盖率更高)不做要求。
项目涉及到了一些中间件(Mysql,Redis,MQ等),但是更多涉及到的内部其他支撑系统。用项目内的实际情况我们当前定义的单元测试覆盖的范围就是,单元测试从controller作为入口,尽量覆盖到controller和service所有的方法与逻辑,所有的外部接口调用全部mock,中间件尽量使用内存中间件进行mock。
单元测试基础框架
既然项目是基于SpringCloud,那测试肯定会引入基础的spring-boot-test,底层的测试框架选择是junit5。现在最新的是junit5(JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage)。
目前,在 Java 阵营中主要的 Mock 测试工具很多。项目中选择了Mockito,
模拟数据生成参考了jmockdata(com.github.jsonzou:jmockdata:4.1.2),
单元测试实施
基本框架选型完毕,基本就进入了编码阶段。前期的编码,实际上还是先写抽取核心业务进行小规模的验证。
单元测试基本结构
先看一下头部的几个注解,这些都是Junit5的
// 替换了Junit4中的RunWith和Rule
@ExtendWith(SpringExtension.class)
//提供spring依赖注入
@SpringBootTest
// 运行单元测试时显示的名称
@DisplayName("Test DebitController")
// 单元测试时基于的配置文件
@TestPropertySource(locations = "classpath:it-bootstrap.yml")
class DebitControllerTest{
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();
}
@BeforeEach
void init() {
request.addHeader("token", "XXXXXXXXXXXXXXXXXXXX");
}
@AfterEach
void tearDown() {
}
@AfterAll
static void tearDownAll() {
server.stop();
server = null;
}
}
在前置方法中设置了Header的值用于模拟身份验证
单元测试的主体方法
我们测试的主要的就是DebitController这个类,这个类下面还有一层service方法。先看一下大概的代码印象。
@Resource
DebitController debitController;
@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 = debitController.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 = debitController.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) {
log.info("id={} name={} t={}"+",id,name,t);
debitController.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-M5</version>
<configuration>
<argLine> -XX:-UseSplitVerifier</argLine>
<excludes>
<exclude>**/*IT.java</exclude>
<exclude>**/*IntegrationTest.java</exclude>
</excludes>
</configuration>
</plugin>
这样在jenkins构建时就会执行单元测试,如果单元测试失败,不会触发构建后操作(Post Steps)。