谈谈如何使用好单元测试这把武器 - 阿里技术

本文作者结合我们日常的工作,讨论如何使用好单元测试这把武器。

前言

如《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 的时候就会需要连着工具类一起测试了。

比如下面这段代码:

@Service 
public 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方法也需要加入更多组测试数据覆盖工具类代码,耦合度太高。

如果稍加修改:

@Service 
public 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/reference/html/testing.html#integration-testing

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

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


10