Springboot单元测试Junit深度实践
前言
单元测试的好处估计大家也都知道了,但是大家可以发现在国内IT公司中真正推行单测的很少很少,一些大厂大部分也只是在核心产品推广单测来保障质量,今天这篇文章就是介绍下单测的方法论和如何在Springboot中解决类之间的依赖来实施junit单元测试。
先来他轮下大家不做单元测试的原因:
- 产品经理天天催进度,哪有时间写UT。
- UT是测试自己的代码,自测?那要QA何用?
- 自测能测出bug?都是基于自身思维,就像考试做完第一遍,第二遍检查一样,基本检查不出什么东西。
- UT维护成本太高,投入产出比太低
- 不会写UT
只有真正尝到UT的好处的甜头才会意识到UT的价值。
其实这篇文章大部分章节是讲述单元测试的难点和解决办法,springboot如何集成其实很简单文章中会讲到。
单元测试的困难
假设我们一个service实现依赖某个RPC Service那我们要测试的这个类的话需要做哪些工作。
第一步:数据准备
跑到别人家的数据库插几条数据?或者跟PRC Service的Owner商量好,搭一个测试环境供我们测试?有些公司还真有专门的自动化测试环境,那么即使有测试环境,那如何实现各种case场景下,第三方Service很配合的返回数据给我们?想想都蛋疼。
第二步:执行方法
假设我们成功的解决了第一步中的问题,皆大欢喜。现在来看第二步,假设我们的service里面调用了另一个RPC Service创建了很多数据,跑了无数次case,结果….RPC Service对应的数据库都是我们的脏数据,如何清理?而且他们敢随便删数据吗?想想也蛋疼。
第三步:输出验证
假设我们又愉快的解决了第二步中的问题。现在来看第三步,假设我们的方法执行最终输出是创建了一个订单,订单当然是调用订单Service接口了,那么我们如何验证订单是否成功创建了呢?或许可以调用订单Service查询订单的接口来验证。很明显大多数情况下并没有这么完美。想想也蛋疼呀。
通过以上分析,Local Integration Test是可行的,Remote Integration Test基本不可行。
Mock解决单测问题
对各个模块的依赖可能是我们做单元测试过程中遇到最讨厌的问题,从我单元测试经验来看,解决这类问题一般有两个方法:
1)建立挡板环境,对于外部依赖的系统接口都建立挡板。因为可能依赖的系统接口很多并且建立挡板环境会浪费很多资源,所以对于单元测试而言这样其实还是会依赖于挡板环境,每次切挡板也是一个痛苦的过程,修改挡板数据也会比较麻烦 ,所以我们一般不建议采用挡板来进行单元测试。
2)建立Mock类,对被测试的每个类中依赖的类都建立mock,并且对mock类用到的方法要写桩和桩数据,这个听起来感觉工作量很大,写mock确实是单元测试过程中工作量最大的地方,但是一旦mock写好以后,我会会发现被测类可以独立于任何模块,可以和一切解耦,当你写单元测试过程中发现写的mock很多的时候,这就说明我们这个类外部依赖太多,可以思考着么设计这个类是不是有问题,有没有更好的设计松耦合的方案。所以为什么很多公司推崇TDD就是这个原因,TDD让前期的设计环节考虑的更多,让类和模块的设计更合理。
Springboot+Junit+Mockito
Mockito目前已经被集成到了springboot-test包中,只需要在工程的pom文件中引入spring-boot-starter-test就可以了,其中包括了junit和mockito类库。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>
写junit本身是比较简单的,最复杂的地方就在于写mock类和对应的桩,网上的大部分例子都是比较简单的在springboot中集成junit后简单的assert,并且被测类都是没有任何依赖的简单功能类,但是我们在实际开发过程中不可能都是这样的没有依赖的简单模块,这里笔者详细讲述下我以前遇到的比较麻烦的问题,相信也是刚开始写单元测试的朋友会遇到的两个问题,一个是怎么mock Spring启动自动注入的bean,还有一个比较麻烦的就是如何给mock类写桩。
解决@Autowired对象的mock
首先我们说明下场景,笔者比较懒直接拿最近写的代码来举例子说明。
APMInfoServiceImpl是我们的被测业务类,这个Service类具体业务逻辑大家不用关心,大家只要知道这个被测类中有一个spring容器自动注入的APMInfoMapper的对象实例就可以了,我们下面就要对APMInfoServiceImpl中的APMInfoMapper实例做mock,并注入到APMInfoServiceImpl中。
@Service public class APMInfoServiceImpl implements APMInfoService { @Autowired private APMInfoMapper apmInfoMapper; @Override public List<HotAppTimeEntity> queryHotAppTime(int limit){ ... return hotAppTimeEntityList; } @Override public List<HotInterfaceTimeEntity> queryHotInterfaceTime(int limit) { ... return hotInterfaceTimeEntityList; } }
下面就是我们的测试类了,首先在测试类名前面需要加上 @RunWith(SpringRunner.class)
,这句表示该测试类运行的时候会先加载spring框架所需的相关类库并将所有有注解的类进行自动依赖注入。
在测试类中,我们需要在被测类对象声明的时候加上@InjectMocks
,这个注解从名字也很好理解,就是将所有的mock类注入到这个对象实例中,注意这里对APMInfoService的创建必须要通过new来初始化,不能像@Autowired那样靠spring自动注入依赖类,因为这里APMInfoService内部依赖的类都是Mock的对象,必须要显式创建类实例Mockito才能注入成功。这样你就会发现在下面测试方法调用的时候被测类就不会再是null了。
@RunWith(SpringRunner.class) public class APMInfoServiceImplTest { @InjectMocks private APMInfoService apmInfoService = new APMInfoServiceImpl(); @Mock private APMInfoMapper apmInfoMapper; @Before public void setUpHotAppData() { //准备桩数据,queryHotAppTime mock normal data List<HotAppTimeEntity> hotAppTimeEntityList = new ArrayList<>(); HotAppTimeEntity hopAppTimeEntity1 = new HotAppTimeEntity(); //省略一堆set方法调用。。。 HotAppTimeEntity hopAppTimeEntity2 = new HotAppTimeEntity(); //省略一堆set方法调用。。。 hotAppTimeEntityList.add(hopAppTimeEntity1); hotAppTimeEntityList.add(hopAppTimeEntity2); when(apmInfoMapper.queryHotAppTime(5, DateUtil.today())).thenReturn(hotAppTimeEntityList); HotAppTimeEntity hopAppTotal = new HotAppTimeEntity(); hopAppTotal.setTotalNum(new Long(100)); //写桩方法 when(apmInfoMapper.queryHotTotal(DateUtil.today())).thenReturn(hopAppTotal); //queryHotAppTime mock null data when(apmInfoMapper.queryHotAppTime(4, DateUtil.today())).thenReturn(null); } @Test public void queryHotAppTime() throws Exception { //normal data,正常数据 List<HotAppTimeEntity> hotAppTimeEntityList = apmInfoService.queryHotAppTime(5); Assert.assertEquals("10001", hotAppTimeEntityList.get(0).getAppID()); Assert.assertEquals(6.0, hotAppTimeEntityList.get(0).getAvgDuration(), 0.0000); Assert.assertEquals(0.8, hotAppTimeEntityList.get(1).getSuccessRate(), 0.0000); Assert.assertEquals(0.1, hotAppTimeEntityList.get(1).getRatio(), 0.0000); //null data,null数据处理 List<HotAppTimeEntity> hotAppTimeEntityNullList = apmInfoService.queryHotAppTime(4); Assert.assertNull(hotAppTimeEntityNullList); }
为mock类依赖方法写桩
被测类和其中依赖的类我们已经通过Mockito创建好了,那么这样是不是就可以测试了?当然不是因为依赖的APMInfoMapper对象都是我们Mock出来的,都是假的,要让测试能正常运行起来,我们还需要给APMInfoMapper被调用到的方法写桩,桩很好理解,就是我们常说的挡板,当方法的输入A返回B。Mockito提供的桩方法也很简单就是when(A).thenReturn(B)这样的结构,when有很多种重载方法,具体如何使用建议参考Mockito的接口文档,官方是英文的,也可以看网友翻译的中文版 https://blog.csdn.net/bboyfeiyu/article/details/52127551。
从这个例子中我们可以看到我给apmInfoMapper写了queryHotAppTime()和queryHotTotal()两个桩方法,为什么是这两个,很好理解,因为我在APMInfoServiceImpl中用到了这两个方法,这样当我在测试类中调用APMInfoServiceImpl的queryHotAppTime方法时,方法内部使用了apmInfoMapper的queryHotAppTime()和queryHotTotal()的地方就会返回我设置的两个桩方法,并return我提前设置好的返回内容,这样APMInfoServiceImpl的queryHotAppTime方法就不会报错了,并且我可以根据方法实现内部的逻辑设计不同的桩方法返回来覆盖到所有的逻辑分支,所有都在自己的掌控之中,最关键的是这个测试方法不再依赖其他任何一个类,唯一以来的类也自己实现了桩方法,并且大家可以发现不用把springboot的应用容器运行起来,所以测试速度非常快。
总结
看到这里大家是不是对单元测试有了深入的了解,并且也能够知道如何解除对外部的依赖,那么就让我们开始把单测写起来吧。这时候肯定有人会说,如果所有类都这样写单元测试,那么工作量太大了,而且单元测试代码量比实际业务代码量还要大,甚至大好几倍。这个确实是这样的,测试代码一般是业务代码的2-3倍,是不是所有类所有方法都要测到呢?
这个问题也是比较经典的,一个方法要是所有的路径都覆盖到,那么要写很多的case,工作量绝对会死人,而且项目经理和产品经理也不会同意开发人员把时间都花在测试代码上。我的建议是两个原则:
- 核心逻辑,容易出错的逻辑一定要覆盖到。
- 根据自己的时间。 没必要写的非常多,毕竟case维护成本很高,业务逻辑一改,case得跟着改。
当开发完功能,跑完UT全部绿灯,那一刻是作为一个开发人员最爽的,因为你可以从你的立场安全上线你的代码了,这是一件非常有成就感非常自豪的事情,记得以前每次提交代码都会担心测试测出bug、上线出现问题等等,天天心惊胆战的,写了单测以后真的可以让开发人员放心去休息了,希望大家也能达到这种状态,这个状态非常美好:)