一、单测的目的
1、单测可以很好保证代码质量。
从而增加开发人员的信心,这点就不再赘述了。
2、单测可以一定程度提高代码合理性。
当我们发现给一个方法写单测非常困难,比如单测需要覆盖的分支非常多,那可能说明方法可以拆分;又比如单测需要mock的调用非常多,那可能说明方法违背了单一责任原则,处理了太多的逻辑,也可以拆分等等。
3、单测能够有效防止回溯问题(regression issue)的出现。
所谓回溯问题,指的就在之前版本没有在新版本才出现的问题。这种问题的严重程度是最高的,影响也是最恶劣的。原因很简单:用户可以接受一个本来在老版本就不存在的功能不可用,但是一定无法接受一个本来在老版本用地好好的功能突然失效了。在新功能开发完后,运行老功能单测,如果发现未变更逻辑的老功能单测报错,则很有可能是出现了回溯问题。
4、单测能够帮助测试人员确定回归范围。
这一点其实是第三点扩展。在新功能提测的时候,开发人员需要提供测试范围,毕竟随着功能的不停增加,全量回归已经变得越来越不可能了。有些开发同学,为了安全起见,随意增加回归范围,这无疑增加了测试人员不必要的工作,是一种严重浪费测试资源的行为。在新功能开发完后,运行老功能单测,如果发现单测报错,则说明这部分老功能的逻辑可能发生了变化,单测需要进行相应的调整,且相关功能应该属于回归的范围。
二、单元测试规范
1、可衡量:单测的编写应该是可以用具体的指标衡量的
单测通过率要求100%,行覆盖率要求60%(目前mapyasign和query系统要求)
解释:通过率100%没啥好多说的,如果单测跑不通过,那不是单测有问题就是代码逻辑有问题。覆盖率的话可以根据具体的工程进行微调,建议不应小于40%,越底层的代码覆盖率应该越高,越新的代码覆盖率也应该越高。
老代码有逻辑变更时,单测也应该做相应的变更。
解释:这点的目的也是为了保证单测通过率100%。同时,这部分功能应该也属于改次功能的测试回归范围内。
新业务提测前,必须保证老单测的通过率也保持100%。
解释:这点的目的是为了防止回溯问题的出现。
2、独立性:单测应该是独立且相互隔离的
一个单测只测试一个方法。
解释:保证了单测的独立性。当单测出错的时候也能够明确知道是哪个方法出了问题。但这并不是说一个方法只对应一个单测,因为为了覆盖方法内的不同分支,我们可以为一个方法创建多个单测。
单测不应该依赖于别的单测。
解释:保证了单测的独立性。每个单测应该都能独立运行。不应该有A单测跑完才能跑B单测的情况。
单测如果涉及到数据变更,必须进行回滚或隔离。
解释:保证了单测的隔离性。如果单测运行后在数据库中产生了数据,那这些脏数据可能干扰测试同学的测试工作,且也可能影响别的单测的运行结果。
3、 规范性:单测的编写需要符合一定规范
对实现类进行测试而非接口。
解释:面向接口编程,面向实现测试。
单测应该是无状态的。
解释:即单测应该可以重复执行,且无论跑几次都应该保证通过率。比如有些方法会对当前时间进行判断,对于这类方法的单测也需根据当前时间的不同而进行不同的测试。
覆盖范围应包括所有提供了逻辑的类:service层、manager层、自定义mapper等,甚至还有部分提供业务逻辑的controller层代码。
解释:只要是提供了逻辑的就应该测试,不过个人并不建议在controller层提供业务逻辑,具体原因参考《设计之道-controller层的设计》。
覆盖范围不应包括自动生成的类:如MyBatis Generator生成的Mapper类、Example类,不应包括各种POJO(DO,BO,DTO,VO...),也不应包括无业务逻辑的controller类。
解释:自动生成的类有啥好测的?POJO的getter/setter有啥好测的?没有提供业务逻辑的controller类有啥好测的?这些被排除的类应该在覆盖率统计中被剔除。
私有方法通过调用类的单测进行测试。
解释:因为私有方法在测试类内没法直接调用,除非使用反射或其他Mock框架(PowerMock, TestableMock等)。
三、单测原则
强制:
-
以Class为最小测试单元
-
尽量不依赖Spring环境
-
mock依赖的下游服务或基础组件
四、最佳实践
创建单测类
1、使用idea快捷键command+shift+T创建单测类
2、引入junit断言,mockito依赖--自动import时一定要看清是否和下面一样
import static org.junit.jupiter.api.Assertions.*; import static org.mockito.BDDMockito.*;
3、创建test类与被测试类在同package下
有些实现会使用抽象类,在实现类中没有public方法,只有protect和private方法,若test类与被测试类不在同一package下,则无法对被测类中的方法进行测试;在同一目录则可以通过protect方法进行测试。
规范:test类命名应该为 被测类名+Test 。
4、测试类必不可少的
//InjectMocks注解定义被测试类,注意是实现类,而非接口类 @InjectMocks private HRTokenThriftServiceImpl hrTokenThriftService; //使用Mock注解定义被测试类中依赖的bean @Mock private TokenService tokenService; @BeforeEach void setup() { //启用mockito注解 MockitoAnnotations.openMocks(this); }
实践分享
1、写单测时使用的given、willThrow应是org.mockito.BDDMockito.*
2、目前各服务中存在2种以上(包括2种)的测试包依赖,在写标准单测时要注意@Test是否是属于org.junit.jupiter.api
3、在测试controller类中的http接口时,需要从Request中获取userId、userType等字段,实现方法如下
try (MockedStatic rch = mockStatic(RequestContextHolder.class)) { //不同点 HttpServletRequest http = new MockHttpServletRequest(); http.setAttribute("mainUserId", 123456L); ServletRequestAttributes request = new ServletRequestAttributes(http); rch.when(RequestContextHolder::getRequestAttributes).thenReturn(request); ...... }
try (MockedStatic rch = mockStatic(RequestContextHolder.class)) { //不同点 MockHttpServletRequest http = new MockHttpServletRequest(); http.setParameter("payMemberId", "123456"); http.setParameter("idType", "merchant"); http.setParameter("bankcardId", "34215633"); ServletRequestAttributes request = new ServletRequestAttributes(http); rch.when(RequestContextHolder::getRequestAttributes).thenReturn(request); ...... }
try (MockedStatic rch = mockStatic(RequestContextHolder.class); MockedStatic api = mockStatic(APIParamsSecurityHolder.class); MockedStatic responseVoMockedStatic = mockStatic(ResponseVo.class) ) { MockHttpServletRequest http = new MockHttpServletRequest(); http.setParameter("payMemberId", "123456"); http.setParameter("idType", "merchant"); http.setParameter("bankcardId", "34215633"); ServletRequestAttributes request = new ServletRequestAttributes(http); rch.when(RequestContextHolder::getRequestAttributes).thenReturn(request); responseVoMockedStatic.when(()->ResponseVo.checkStatus(anyString())).thenReturn(true); api.when(()->APIParamsSecurityHolder.getString("bankcardId", true)).thenReturn("34215633"); ...... }
4、mock异常场景与assert校验
GetOneClickPayOpenInfoReq getOneClickPayOpenInfoReq = new GetOneClickPayOpenInfoReq(); getOneClickPayOpenInfoReq.setUserId(12345L); getOneClickPayOpenInfoReq.setMtPlanId(1585L); //构造抛出异常 willThrow(new TException()).given(thriftOneClickPayService).getOneClickPayOpenInfo(getOneClickPayOpenInfoReq); try { UserQuotaInfo userQuotaInfo = oneClickPayService.getUserQuotaInfo(1585L, 12345L); Assert.assertNull(userQuotaInfo); } catch (CommonException e) { //校验是否是符合预期的异常 Assert.assertEquals(CommonExceptionEnum.PAYMEMBER_MPAYSIGN_BUSINESS_ONE_CLICK_PAY_QUOTA_INFO_FAIL, e.getCommonExceptionEnum()); }
5、对于需要构造大量数据的可以自己写简单测试方法来获取线下库中数据,将其输出成json格式,然后进行JsonToBean转化
String str2 = "text"; MtScorePayPlanWrapperVo mtScorePayPlanWrapperVo = JSONObject.parseObject(str2, MtScorePayPlanWrapperVo.class); given(mtScorePayPlanWrapperService.findMtScorePayPlanWrapperVoByPlanId(scorePlanId)).willReturn(mtScorePayPlanWrapperVo);
6、对于使用了@Builder的对象,无法使用工具进行JsonToBean的转化--大家有好的解决方法吗??
DeductMtPlanVo.DeductMtPlanVoBuilder deductMtPlanVo = DeductMtPlanVo.builder(); List<MtPlan_PayType> list = Lists.newArrayList(); list.add(MtPlan_PayType.card); list.add(MtPlan_PayType.balance); list.add(MtPlan_PayType.maidan); deductMtPlanVo.optimal(MtPlan_PayType.maidan) .paytypes(list) .order_quota(500) .deductText("优先从扣款") .unifyDoubleOpenButton(false).sequentialModel(4) .memo("优先从扣款").rule(MtPlan_Rule.mt).scene(2) .uuidCare(MtPlan_UuidCare.no) .mt_plan_content("先享后付").iph(41807181701589231L) .wxDoubleOpenButton(false).begintime(1582536536) .showList(false).mt_plan_name("先享后付") .cancel_duration(0).partnerId(0) .mtPlanId(103943).day_quota(3000); DeductMtPlanVo deductMtPlanVo = deductMtPlanVo.build(); given(mpmMtPlanIdAdapter.getMtPlanInfo(mtScorePayPlan.getCreditConfigVo().getConfigForMaidan().getMtPlanId(), false)).willReturn(deductMtPlanVo);
7、mock时有时使用any()、anyInt()、anyLong()等做入参时,会出现mock结果返回不符合预期的情况,这时可以将设置过的参数换成实际值,而不是全部使用这类匹配参数
8、mock多线程Future下游
String dis = "text"; AgreementDisplay agreementDisplay = JSONObject.parseObject(dis, AgreementDisplay.class); Future<AgreementDisplay> agreementDisplayFuture = mock(Future.class); given(agreementDisplayFuture.get()).willReturn(agreementDisplay); given(asyncCreditPayAdapter.asynGetAgreementDisplay(userId, sellerId)).willReturn(agreementDisplayFuture);
9、当一个class中有A()、B()两个对外的方法时,在测试A()方法时,内部又调用了B(),这时如果想把B()方法mock掉,需要对该class进行spy处理
import static org.mockito.Mockito.spy; class WalletPaypassManageControllerTest { @InjectMocks WalletPaypassManageController walletPaypassManageController; @BeforeEach void setup() { MockitoAnnotations.openMocks(this); } @Test void paypassTest() { ResponseVo touchVo = new ResponseVo(); touchVo.setStatus("success"); touchVo.setData(Maps.newHashMap()); WalletPaypassManageController spy = spy(walletPaypassManageController); willReturn(touchVo).given(spy).getTouchDetail(http, 1, "finger"); ResponseVo<?> paypass = spy.paypass(http, "imsi", 12, 1, "1", "modelKey", "finger"); //Asesert }
10、对于多次会用到的变量,如userId、mtPlanId、mtIph等可以设置成静态变量
11、进行最后的Assert时,要保证能够检验到代码逻辑变动
12、对@value注解进行mock
ReflectionTestUtils.setField(bean, "fieldName", "value")
13、异步方法的测试
例如下面这个方法的测试:
测试样例:
参考文献
《阿里巴巴Java开发手册.pdf》第三章