乐趣区

关于单元测试:谈谈如何使用好单元测试这把武器

前言

如《Unit Testing》书里提到,

学习单元测试不应该仅仅停留在技术层面,比方你喜爱的测试框架,mocking 库等等,单元测试远远不止「写测试」这件事,你须要始终致力在单元测试中投入的工夫回报最大化,尽量减少你在测试中投入的精力,并最大化测试提供的益处,实现这两点并不容易。

和咱们在日常开发中遇到的问题一样,学会一门语言,把握一种办法并不艰难,艰难的是把投入的工夫回报最大化。unit test 有很多基础知识和框架,在 google 上一搜就一大堆,最佳实际的方法论也十分多,本文不筹备探讨这些问题,而是联合在咱们日常的工作,探讨如何应用好单元测试这把武器。

单元测试的定义

什么是单元测试?来自百度

单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。至于【单元】的含意,一般来说,要依据理论状况断定具体含意,如 Java 里单元指一个类等。

讲人话,单元测试就是为了验证一个类的准确性的测试。区别于集成测试和零碎测试。他是前置的,由开发人员主导的最小规模的测试。

一些学者们通过统计,还绘制出了下图:

  • 85% 的缺点都在代码设计阶段产生;
  • 发现 bug 的阶段越靠后,消耗老本就越高,呈指数级别的增长。

由此看来,单测代码的编写对于交付品质以及人工消耗老本都有极其重要的影响

常见的误区

浪费时间,影响开发速度不同我的项目的开发测试工夫曲线不同,你要综合思考你的代码的生命周期,你 debug 的能力,你平时花多少工夫 review 有问题的代码。随着我的项目的进行,这些工夫会递增,如果你想你所写的代码可能始终用上来,不让起初人吐槽这写的什么玩意,单元测试十分有必要。测试应该是测试的工作开发是代码的第一责任人,最相熟代码的人,在设计阶段编辑单元测试,岂但能够让你更自信的交付,还能够缩小测试问题的产生。同时你本人的全栈能力也有所晋升。代码不是我写的,我不懂咱们常常埋怨老代码有坑难懂,或者是不足 CR。其实在编写单元测试的过程中,也是 CR 和学习的一个过程,对于代码的主流程,边界,异样等有了深刻的了解。同时也是自我扫视代码标准、逻辑、设计的过程。我倡议在重构中写单测,在写单测中重构,相辅相成。

如何写出好的单测

方法论上,有 AIR 准则,像空气一样不会被感触到即 Automatic(自动化)、Independent(独立性)、Repeatable(可反复)。我集体的了解就是 1、主动运行,通过 CI 集成的形式,保障单测可能主动运行,通过 assert 保障单元测试的验证后果,而不是 print 输入。确保单元测试可能自动化运行,不须要人工染指测试。2、单元测试必须独立,不能相互调用,也不能有依赖的程序。每个测试用例之间包保障独立。3、不能够受运行的环境、数据库、中间件等影响。在编写单测的时候,须要把内部的依赖 mock 掉。从覆盖率的标准上来讲,不论是阿里外部还是业界,都有很多规范。

语句覆盖率达到 70%;外围模块的语句覆盖率和分支覆盖率都要达到 100%。—《阿里巴巴 Java 开发手册》
单测覆盖度分级参考 Level1:失常流程可用,即一个函数在输出正确的参数时,会有正确的输入 Level2:异样流程可抛出逻辑异样,即输出参数有误时,不能抛出零碎异样,而是用本人定义的逻辑异样告诉下层调用代码其谬误之处 Level3:极其状况和边界数据可用,对输出参数的边界状况也要独自测试,确保输入是正确无效的 Level4:所有分支、循环的逻辑走通,不能有任何流程是测试不到的 Level5:输入数据的所有字段验证,对有简单数据结构的输入,确保每个字段都是正确的

从下面的摘录看,语句覆盖率和分支覆盖率都有数值上和方法论上的要求,那在理论工作中,实际状况如何呢?笔者曾在一个季度,工作中提交的代码综合增量覆盖率简直达到了 100%。我能够谈谈我的教训和实际。60% 左右的单测覆盖率能够十分轻松达到,但达到 95% 以上的覆盖率,须要笼罩各种代码分支和异常情况等,甚至是配置和 bean 的初始化办法,所投入的工夫十分微小,但边际效应递加。我想测试 toString,getter/setter 这样的办法也没有意义。多少适合,我认为没有一个固定的规范。高代码覆盖率百分比不示意胜利,也不意味着高代码品质。该舍弃测试的局部就大胆的 ignore 掉。

最佳实际

这个题目未免有些题目党。单元测试相干的书籍、ata 文章,不可胜数,我的所谓“最佳实际”是在理论阿里工作中的一些本人踩过的坑,或者我集体认为一些重要的点,班门弄斧,如有谬误,欢送探讨。

1、暗藏的测试边界值

public ApiResponse<List<Long>> getInitingSolution() {    List<Long> solutionIdList = new ArrayList<>();    SolutionListParam solutionListParam = new SolutionListParam();    solutionListParam.setSolutionType(SolutionType.GRAPH);    solutionListParam.setStatus(SolutionStatus.INIT_PENDING);    solutionListParam.setStartId(0L);    solutionListParam.setPageSize(100);    List<OperatingPlan> operatingPlanList =  operatingPlanMapper.queryOperatingPlanListByType(solutionListParam);    for(; !CollectionUtils.isEmpty(operatingPlanList);){/*            do something            */        solutionListParam.setStartId(operatingPlanList.get(operatingPlanList.size() - 1).getId());        operatingPlanList =  operatingPlanMapper.queryOperatingPlanListByType(solutionListParam);    }    return ResponsePackUtils.packSuccessResult(solutionIdList);}

下面这段代码,如何写单元测试?

很天然的,咱们写单测的时候会 mock 掉数据库查问,并且查出信息。然而如果查问的内容超过 100,因为 for 循环进入一次,无奈通过 jacoco 的主动覆盖率发现。实际上没有笼罩这个边界 case,只能通过开发者的习惯来解决这些边界状况。如何解决这些暗藏的边界值,开发者不能依赖集成测试或者代码 CR,必须要在本人写单元测试的时候思考到这一状况,能防止起初保护的人掉坑。

2、不要在 springboot 测试中应用 @Transactional 以及操作实在数据库

单元测试的上下文应该是洁净的,设计 transactional 的初衷是为了集成测试(如 spring 官网介绍):

尽管间接操作 DB 能更容易验证 DAO 层的正确性,然而也容易被线下数据库的脏数据净化,导致单测无奈通过的问题。笔者以前遇到直连数据库的单测代码,常常改个 5 分钟代码,数据库里脏数据清一个小时。第二就是集成测试须要启动整个利用的容器,违反了提高效率的初衷。如果切实要测 DAO 层的正确性,能够整合 H2 嵌入式数据库。这个网上教程十分多,不再赘述。

3、单测里工夫相干的内容

笔者已经在工作中遇到过一个极其 case,一个 CI 平时都失常运行,有一次深夜公布,CI 跑不过,起初通过第二天 check 才发现有前人在单测中取了以后工夫,在业务逻辑中含有夜间逻辑(夜间音讯不发),导致了 CI 无奈通过。那么工夫在单测中要如何解决呢?在应用 Mockito 时,能够应用 mock(Date.class) 来模仿日期对象,而后应用 when(date.getTime()).thenReturn(time) 来设置日期对象的工夫。如果你应用了 calendar.getInstance(),如何获取以后工夫?Calendar.getInstance() 是 static 办法,无奈通过 Mockito 进行 mock。须要引入 powerMock,或者降级到 mockito 4.x 能力反对:

@RunWith(PowerMockRunner.class) @PrepareForTest({Calendar.class, ImpServiceTest.class})    public class ImpServiceTest {@InjectMocks    private ImpService impService = new ImpServiceImpl();
    @Before    public void setup(){        MockitoAnnotations.initMocks(this);
        Calendar now = Calendar.getInstance();        now.set(2022, Calendar.JULY, 2 ,0,0,0);
        PowerMockito.mockStatic(Calendar.class);        PowerMockito.when(Calendar.getInstance()).thenReturn(now);    }}

4、final 类,static 类等的单元测试

如第 3 点提到的 calendar 的例子,static 类的 mock 须要 mockito4.x 的版本。否则就要引入 powermock,powermock 不兼容 mockito3.x 版本,不兼容 mockito 4.x 版本。因为老的利用引入了十分多的 mockito3.x 的版本,间接应用 mockito4.x 对 final 和 static 类进行 mock 须要排包。实际中看,JUnit、Mockito、Powermock 三者之间的版本号有兼容性问题,可能会呈现 java.lang.NoSuchMethodError,须要依据理论的状况抉择版本进行 mock。然而在新我的项目立项的时候,要确定好应用的 mockito 和 junit 版本,是否引入 powermock 等框架,确保环境稳固可用。老我的项目倡议不要大规模改变 mockito 和 powermock 的版本,容易排包排到狐疑人生。

5、利用启动报 Can not load this fake sdk class 的异样

这是因为阿里的 tair,metaq 基于 pandora 容器的,fake-sdk 默认是 pandora 模块类加载加载的。具体原理能够参考下图:

解决方案 1,引入 pandoraboot 环境。@RunWith(PandoraBootRunner.class) 这样其实减慢了单测的运行速度,是违反了高效性原理的。然而相比拟运行整个容器,运行 pandora 容器的工夫大略在 10s 左右,还是可能答应的。那么有没有不让 pandoraboot 起来,纯 mock 的办法。我集体认为 mock 要比 ut 更优先,特地是有些内部依赖,常常迁徙或者下线,可能改了 1 行代码,须要修 1 个小时测试用例。tair,lindorm 等中间件也没有方法本地起环境进行 mock,间接依赖内部资源十分不优雅。解决方案 2,间接 mock 以 tair 为例:

@RunWith(PowerMockRunner.class)@PrepareForTest({DataEntry.class})public class MockTair {    @Mock    private DataEntry dataEntry;
    @Before    public void hack() throws Exception {        //solve it should be loaded by Pandora Container. Can not load this fake sdk class. please refer to http://gitlab.alibaba-inc.com/middleware-container/pandora-boot/wikis/faq for the solution        PowerMockito.whenNew(DataEntry.class).withNoArguments().thenReturn(dataEntry);    }        @Test    public void mock() throws Exception {        String value = "value";        PowerMockito.when(dataEntry.getValue()).thenReturn(value);        DataEntry tairEntry = new DataEntry();        // 值相等        Assert.assertEquals(value.equals(tairEntry.getValue()));    }}

6、metaq 怎么写单测

MessageExt 的 mock 办法参考 5,然而单测中怎么运行一个 MetaPushConsumer 的 bean,并调用 listener 办法。那就只能启动 context 的上下文。托管 SpringRunner 的形式。

@RunWith(PandoraBootRunner.class)@DelegateTo(SpringRunner.class)public class EventProcessorTest {
    @InjectMocks    private EventProcessor eventProcessor;    @Mock    private DynamicService dynamicService;    @Mock    private MetaProducer dynamicEventProducer;
    @Test    public void dynamicDelayConsumer() throws MQClientException, RemotingException, InterruptedException, MQBrokerException {        // 获取 bean        MetaPushConsumer metaPushConsumer = eventProcessor.dynamicEventConsumer();                // 获取 Listener        MessageListenerConcurrently messageListener = (MessageListenerConcurrently)metaPushConsumer.getMessageListener();        List<MessageExt> list = new ArrayList<>();                // 这个须要依赖 PandoraBootRunner        MessageExt messageExt = new MessageExt();        list.add(messageExt);        Event event = new Event();        event.setUserType(3);        String text = JSON.toJSONString(event);        messageExt.setBody(text.getBytes());        messageExt.setMsgId(""+System.currentTimeMillis());                // 测试 consumeMessage 办法        messageListener.consumeMessage(list, new ConsumeConcurrentlyContext(new MessageQueue()));        doThrow(new RuntimeException()).when(dynamicService).triggerEventV2(any());        messageListener.consumeMessage(list, new ConsumeConcurrentlyContext(new MessageQueue()));        messageExt.setBody(null);        messageListener.consumeMessage(list, new ConsumeConcurrentlyContext(new MessageQueue()));    }}

总结一下什么时候应用容器:

// 1. 应用 PowerMockRunner@RunWith(PowerMockRunner.class)
// 2. 应用 PandoraBootRunner, 启动 pandora,应用 tair,metaq 等 @RunWith(PandoraBootRunner.class)
// 3. springboot 启动,退出 context 上下文,能够间接获取 bean@SpringBootTest(classes = {TestApplication.class})

7、尽量应用 ioc

应用 IOC 能够解耦对象,使得测试更加不便。常常有这样的状况,在某个 service 中应用到某个工具类,这个工具类内的办法都是 static 的,这样的话,测试 service 的时候就会须要连着工具类一起测试了。比方上面这段代码:

@Servicepublic class LoginServiceImpl implements LoginService{public Boolean login(String username, String password,String ip) {// 校验 ip        if (!IpUtil.verify(ip)) {return false;}        /*          other func        */        return true;    }}

通过 IpUtil 校验登录用户的 ip 信息,而如果咱们这样应用,就须要测试 IpUtil 的办法,违反了隔离性的准则。测试 login 办法也须要退出更多组测试数据笼罩工具类代码,耦合度太高。

如果稍加批改:

@Servicepublic class LoginServiceImpl implements LoginService{public Boolean login(String username, String password,String ip) {// 校验 ip        if (!IpUtil.verify(ip)) {return false;}        /*          other func        */        return true;    }}

这样咱们只须要独自测试 IpUtil 类和 LoginServiceImpl 类就行了。测试 LoginServiceImpl 的时候 mock 掉 IpUtil 就能够了,这样就隔离了 IpUtil 的实现。

8、不要为了覆盖率测没意义的代码

比方 toString,比方 getter,setter,都是机器生成的代码,单测没意义。如果是为了整体测试覆盖率的进步,那么请在 CI 中排掉这部分包:

9、如何测试 void 办法

  • 如果 void 办法外部造成了数据库的变更,比方 insertPlan(Plan plan),并通过 H2 操作过数据库,那么能够验证数据库的条数变动等,校验 void 办法的正确性。
  • 如果 void 办法调用了函数,能够通过 verify 验证办法失去调用次数:
userService.updateName(1L,"qiushuo");verify(mockedUserRepository, times(1)).updateName(1L,"qiushuo");
  • 如果 void 办法可能会造成抛出异样。

能够通过 dothrow 来 mock 办法抛出的异样:

@Test(expected = InvalidParamException.class)public void testUpdateNameThrowExceptionWhenIdNull() {   doThrow(new InvalidParamException())      .when(mockedUserRepository).updateName(null,anyString();   userService.updateName(null,"qiushuo");}

参考资料

1、https://scottming.github.io/2021/04/07/unit-testing/

2、https://docs.spring.io/spring-framework/docs/current/referenc…

3、https://yuque.antfin-inc.com/fangqintao.fqt/pu2ycr/eabim6

4、https://yuque.antfin-inc.com/aone613114/en7p02/pdtwmb

点击立刻收费试用云产品 开启云上实际之旅!

原文链接

本文为阿里云原创内容,未经容许不得转载。

退出移动版