作者:京东零售陈志良作为京东的软件工匠,我们开发的软件支撑着亿万用户,责任重大,让我们深深敬畏每一行代码,我们如何才能最大限度地减少我们的错误?那就是单元测试,它会让我们对代码建立信心。为此,我们期望打造一台生产Java单元测试代码的“永动机”,为开发者源源不断地生产代码,辅助大家高效地做单元测试,节省精力投入更多的业务创新。1、开发者对代码的信心从何而来?随着京东业务的快速发展,我们打造的这个承载着亿万用户的强大系统,经过十几年的打磨,变得越来越复杂。作为京东软件开发者,我们自豪,但我们承担的责任也很大。我们所做的每一项创新都像创造了一个如下图所示的过山车。我们在为客户带来如此顶级体验的同时,更重要的是确保每一次出行都能安全落地。所以我们对每一行代码都深深敬畏,力求把自己的错误降到最低,为业务保驾护航。但业务迭代速度快,交付压力大。作为“过山车”的创造者,你是否有以下经历?1)每次上网都像坐过山车吗?2)你亲身体验过自己搭建的“过山车”吗?3)你有没有对你的测试同学说过,“上去坐着看,遇到问题再下来找我”?如果你的回答是:每次上网都像坐过山车。我们不敢坐自己造的“过山车”。我们的代码是靠测试同学才知道真相的,那就说明我们对自己的代码缺乏信心。我们的工作还有改进的余地;相反,它表明你作为开发人员已经相当不错了。那么我们开发人员如何建立对自己代码的信心呢?一般来说有两种方式:1)对“过山车”的每个部分进行全面测试,确保每个部分在各种场景下都能正常工作,所有异常都能得到妥善处理,这就是单元测试。2)在“过山车”开始之前做一次全面的“检查”,也就是codereview。我们请其他大佬帮我们排查,及时发现问题。这两部分工作是开发阶段的必要工作,缺一不可。代码审查靠的是外力,而单元测试是内功,靠自己和开发者的自测来增强对代码的信心。本文主要和大家探讨单元测试,如何练好这个单元测试的内功。2、做好单元测试,慢就是快对于单元测试,业内同仁有不同的理解,尤其是在业务变化快的互联网行业,常见的问题主要是,一定要做吗?多少合适?现在不做不好吗?就连一些大佬也有不同的看法。我们来看一组数字如下:“在STICKYMINDS网站上一篇名为《 The Shift-Left Approach to Software Testing 》的文章中提到,如果在编码阶段发现的缺陷只需要1分钟就可以解决,那么单元测试阶段需要4分钟。功能测试阶段需要10分钟,系统测试阶段需要40分钟,发布后修复可能需要640分钟。”——摘自知乎网站对于这些数字的准确性我们暂时保留意见,大家可以想一想我们在实践中遇到的线上问题需要多少工时,除了快速找bug,修复bug,去在线上,我们还需要修复bug导致的数据问题,最后需要对磁盘进行review,看看以后有什么办法可以避免。所以本文作者做的研究数据还是很靠谱的,缺陷发现越接近交付环节后端,修复成本越高,有人说写单元测试太费时间-耗费时间,会延长交付时间,其实不然:1)研究和测试同学之间大量的往返交互,比写单元测试的时间要长很多,而集成测试时间延长。2)没有通过单机测试的代码会有很多bug。开发人员忙于修复各种BUG,需要花费大量精力跟踪调试代码来发现问题。3)后期上线的问题也需要大量的精力去弥补。如果你有单元测试代码,并且能够做到很高的线路覆盖率,那么在开发阶段就可以尽可能的排除问题。同时,随着单测代码的积累,每次代码变更后,可以提前发现本次变更导致的其他相关问题,上线更放心。虽然单机测试让测试慢了一点,但是软件质量更有保障,节省了后续同学的精力,其实整体上效率更高。所以做单测,慢就是快。我们组技术委员会的领导从去年开始就提倡大家做单元测试。作为开发者,我们需要对自己代码的质量负责,这更能体现我们大厂开发者的匠心。三、如何编写单元测试1、单元测试的主流框架和核心思想我们先通过一个案例来介绍一下主流框架的思想。下图展示了一个简单的函数执行逻辑。函数体中直接调用函数1、函数2、函数3,间接调用函数2.1,其中1、2为普通函数,2.1、3涉及外部系统调用,例如JSF、Redis、MySQL和其他操作,最后返回结果。代码大致如下:publicclassMyObject{@AutowiredprivateRedisHelperredisHelper;publicMyResultmyFunction(InputParaminputParam){MyResultmyResult=newMyResult();//普通代码块if(inputParam.isFlag()){//如果flag标志为真,执行函数1Stringf1=invokeFunction1();//调用函数3,函数3封装了redis中间件操作Stringf3=redisHelper.get(f1);myResult.setResult(f3);}else{//调用Function2,调用函数2内部的远程服务接口2.1Stringf2=invokeFunction2();myResult.setResult(f2);}返回我的结果;}在现在的微服务时代,系统之间的交互变得越来越复杂,上面的插图这只是一个简化的例子。实际系统中的上下游外部依赖多达十几个甚至几十个。在这种情况下,如果过于依赖外部服务,就很难保证每个用例的成功,进而影响单元测试的执行效果。因此,目前主流的单元测试框架大多采用mock技术来屏蔽对外部服务的依赖,例如:mockito、powermock、Spock等。图中2.1和3是对外部系统的调用。在单元测试代码中需要对API进行mock,mock技术用于模拟用例运行时外部API接口的返回值。具体的写法这里就不举例了。需要注意的是,使用Mock技术的框架需要注意两个前提条件:1)接口契约比较稳定(比如redis的API暂时不会改变),否则需要调整测试用例代码为适应最新的接口契约,本单元测试用例代码如不调整则失效。2)接口调用是幂等的,相同的入参需要返回相同的结果,否则用例中的断言会失败或者需要对断言进行特殊处理,比如忽略比较中的某些变化(比如id,时间等)。2.第一类单元测试用例编写方案接下来,基于mockito框架编写测试代码。下图的做法是开发者写一个用例,mock外部函数2.1和3,然后在测试用例中调用被测函数,然后断言返回值。原理图代码如下://创建函数2.1的mock对象@MockBeanprivateJSFServicemyJSFService;//创建函数3的mock对象@MockBeanprivateRedisHelperredisHelper;@AutowiredMyObjectmyObject;@TestpublicvoidtestMyFunction(InputParameterparameter){//根据Entermock返回数据when(myJSFService.invoke(parameter.getX())).thenReturn(X);当(redisHelper.get(parameter.getY())).thenReturn(Y);//预期结果MyResultexpect=newResult(XXX);//实际调用被测函数,并返回结果MyResultactual=vmyObject.myFunction(parameter);//断言Assert.assertEquals(actual.toString(),expect.toString());运行测试用例后,除了要测试的功能外,功能1和2也一起测试了。实际中,调用环节会比较复杂,那么这种写法呢?下面简单分析一下:1)优点:用例编码量小,实现速度快。一个用例涵盖3个功能,测试了整个业务执行路径。另外,单次测试覆盖率指标不受影响,只要执行过的代码都会被统计。2)缺点:如果用例失败,定位问题会比较慢,实际项目中的环节会比较复杂,因此排查问题的时间会大大增加。如果问题出现在功能1或2,那么就需要通过debug一步步跟进。那么这种做法究竟如何呢?这一点,如果让测试生看到,肯定会产生疑惑。这种方式的用例和集成测试阶段的自动化用例有什么区别?是的,效果是一样的,只是操作移到了开发阶段。问题的排查和定位还是比较困难的,所以从实际效果出发,不建议就这么干,请往下看。3、编写单元测试用例的第二种方案第二种方案是为每个方法编写用例代码,每个方法都是一个独立的功能单元,将被测方法的所有依赖隔离开来,对外部依赖调用好mock。一般做法类似于下图:待测函数的测试用例会涉及到3个mock,即函数1、2、3;功能1和功能2也有自己的测试用例,这样得到的单元测试结果会更好。在Java中,方法是存在的最小可测试单元,所以每个方法都独立进行全面测试,组装后代码的整体质量才能得到充分保证,快速定位问题,实现快速交付。目前业界大部分开发者采用第一种方式编写部分集成测试。因为他们的工作量比较小,所以在交付压力大的时候,他们甚至可能会放弃单元测试。这种情况在互联网行业尤其普遍。在单元测试不足的情况下,需要增加测试人员的人力来缓解质量问题。不过,当前业务增长压力逐渐显现,各大公司都着力提升内部效率,人工成本管控更加严格。打铁还是要靠自己努力。目前我们每一位开发者都需要加强自己的内功。结合以上两种方案,总结如下:1)为每个方法编写单元测试测试用例,该方法的外部调用均为mock。2)编写一小部分集成测试用例,部分验证整体功能。集成测试的主要工作还是交给测试同学。4、单元测试应该遵循的一些原则目前业界比较流行的是FIRST原则,整理如下1)快速,一个快速的单元测试用例是执行特定任务的一小段代码。与集成测试不同,单元测试很小很轻。尽量避免网络通信、数据库操作、Web容器启动等耗时的操作,这样可以快速执行。开发人员在实现应用功能或调试bug时,需要经常运行单元测试来验证结果是否正确。如果单元测试足够快,可以节省不必要的时间浪费,提高工作效率。2)Independent/Isolated,独立/隔离单元测试的用例需要相互独立。单元测试不应该依赖于其他单元测试产生的结果,因为在大多数情况下单元测试以随机顺序运行。此外,用例代码不应依赖和修改外部数据或服务等共享资源,使测试前后的共享资源数据保持一致,可以通过mock或stub的形式模拟依赖关系屏蔽这些依赖关系的不确定性,保证单元测试结果的准确性。3)可重复、可重复的单元测试需要保持稳定运行。在不同的计算机和不同的时间点运行多次应该会产生相同的结果。如果间歇性的故障会导致我们不断地检查这个测试,那么没有必要进行可靠的测试就没有意义了。4)Self-Validating,自验证单元测试需要使用Assert相关的断言函数进行自验证,即单元测试执行后即可知道测试结果,不需要人工干预整个过程,测试完成后不应再进行人工检查。注意不要在单元测试中加入任何打印log的语句,以免打印出log来判断单元测试是否通过。5)Thorough/Timely,在测试一个功能的时候,除了要考虑主要的逻辑路径,还要注意边界或者异常场景。因此,在大多数情况下,除了创建输入参数有效的单元测试外,我们还需要准备其他输入参数无效的单元测试。例如,如果被测方法有一系列输入参数,从MIN到MAX,那么应该创建一个额外的单元测试来测试输入是否可以正确处理MIN和MAX。另一个是及时性。在完成单元测试之前等待代码稳定运行可能是低效的。最有效的方法是在编写功能接口之后(实现功能之前)进行单元测试。五、单元测试的现状及痛点1、通过调研行业现状,我们发现:1)从行业特征来看:传统行业软件(ERP、CRM、等)至少达到了80%,而互联网行业软件则相对较低,一般不到50%,而且大部分都没有。2)从软件特性来看:用户量大的软件(工具、中间件等)基础软件覆盖率比较高,至少80%以上,需求快速变化的业务软件相对较低。3)从开发习惯来看:国外开发的软件要求比较高,更注重软件的质量。大多数开源软件的覆盖率至少为60%。国内大部分开发者还没有养成这个习惯。2、单元测试这么重要的东西,为什么在企业实践中很难做好?主要有以下痛点:1)开发者需要投入更多的工作量:应用系统的单元测试代码行数代码行数与应用功能的比例至少为1:1,以及对于复杂的应用程序,它更高。一般来说,单条测试线的覆盖率每提高1%,就需要编写业务代码1%的测试代码,因此开发者需要付出更多的劳动。随着单元测试覆盖率的提高,每增加1%就需要编写大量用例,因为后续用例至少80%甚至90%以上的代码运行路径重叠,最坏情况增加。一个用例,只是多了一条覆盖线。2)存量代码量巨大:我们目前关注的指标只是核心系统的覆盖率,全代码覆盖率的提升难度更大。多年积累的应用程序中保持活跃的代码数量仍然庞大,现有代码单元测试编码是劳动密集型的。3)单元测试代码容易失效:单元测试代码需要持续维护,新的业务需求引起的代码变更会导致原有的单元测试代码失效。在业务高速迭代的情况下,没有额外的精力投入,要么忽略,要么在这种情况下,很难持续保持高覆盖指标。说到底,单元测试最大的难点还是成本问题。做好单元测试,需要我们的开发人员持续投入大量的精力,但是在业务需求高速迭代的情况下,我们应该如何破局呢?答案是:自动化技术6.单元测试自动化研究事实上,单元测试自动化技术的发展至少有15年的历史。目前的主流技术是静态代码分析技术。或者检查源程序的语法、结构、过程、接口等,检查程序的正确性,找出代码中隐藏的错误和缺陷。主要代表产品有:EvoSuite、Squaretest等。上图是EvoSuite工具根据已有测试代码自动生成的测试代码。目前这类产品生成的单条测试代码的线路覆盖率一般可以达到**30%**左右。代码越复杂,效果越差。它们可以作为简单业务场景的单一测试代码生成解决方案。主要优点是:纯客户端工具,安装后即可使用,无需复杂配置。支持多种开发平台:支持idea、eclipse、命令行等工具。主要缺点:生成代码质量不高,单测覆盖率低:受限于代码分析技术和真实技术框架的复杂性和多样性,生成代码质量不高,单测覆盖率低覆盖率低,只能应用于简单的业务场景,生成的代码需要人工判断有效性。例如ordersendpay等标签,包含丰富的业务语义,很难通过静态分析生成有效的用例代码。七、我们的一些想法和技术突破1.将记录的数据转化为单元测试用例基于静态代码分析的局限性,我们需要找到一个新的方向,那么我们如何获取更丰富的业务数据而不是一些策略生产数据。去年我们零售交易开发创新了月光盒子,它可以完整记录数据,所以我们就想能不能利用盒子记录的数据反向生成测试用例,实现快速生产单元。测试用例代码。方案总体思路如下:2、对标验证的效果给了我们信心。乍一看,这个想法有点疯狂。我们已经验证了这个想法的效果。解决办法虽然困难,但可行。下面是Y端的一个benchmarkcase的尝试。通过四大benchmark的试运行分析,接入一周内生成23000行代码,单条测试线覆盖率提升30%以上。3.但是解决方案并不完美,我们还是有一些建议。如果你仔细阅读了上面提到的单元测试原理,你一定对解决方案有疑惑。是的,它违反了及时性原则。我们应该写在代码完成的时候或者测试完成之前,来不及在测试阶段记录和生成。确实,这个方案并不完美,所以我们的建议是:1)对于股票代码,由于我们目前有大量的股票代码,这个方案会有更大的效果。开发者只需要集成录制工具到被测应用中即可。接入成功后,如果测试同学能帮忙跑个全回归测试最好,这样就可以快速生成大量用例代码。如果测试同学时间不够,就利用测试同学每天的测试,逐渐积累Data,一两周后就可以得到大量的用例代码。2)对于新开发的代码,在开发者完成编码后的自测阶段,开发者可以在本地运行程序进行自测和记录,这样也可以帮助我们生成大量的用例,然后基于生成的用例,复制、手动调整,快速扩展用例,从而保证单元测试的时效性。3)特殊业务场景处理,难以记录边界或异常用例,可以手工复制用例,然后修改用例数据扩展用例,这种方式比纯手工编写要快很多,尤其是模拟对象非常复杂使用此解决方案时,可以在1分钟内基于现有用例扩展新用例。4.生成的单元测试用例是什么样的?下面是生成单元测试用例代码的实际例子。本示例基于Mockito框架。每个用例方法对应一个JSON文件,存储用例运行时需要的输入输出参数。所有外部调用的数据、用例代码和数据都是由工具自动生成的,生成的代码大部分是为了帮助开发者从记录的数据中组装Mock对象。这部分工作量在实际开发中是最大的,因此可以大大减少开发者自己的手工编码工作。当需要手动扩展用例时,只需复制用例方法和数据文件,然后对用例数据进行调整,即可创建新的用例。示例数据文件:/artt/StockStatusReOccupySplitServiceImpl1#HpCm.json五、我们遇到的技术挑战我们遇到了很多技术困难,因为恢复代码时基于宝箱记录的信息不够,需要添加更多的记录信息处理应用场景特殊,难点主要有:1)结构化数据的记录和恢复,复杂泛型的恢复,复杂对象的序列化和反序列化2)基于动态代理技术的代码特殊处理,如mybatis,JSF3)采样控制4)用例结果断言的多样性,需要丰富的比较策略这期间涉及到大量的底层技术研究。到现在为止,我们还有很多技术点需要攻克。比如我们在做的应用接入增强,就是将SpringAOP方式替换为agent+ASM方式,实现代码增强,动态挂载卸载,无需重启服务,进一步降低了接入成本,减少了对应用的需求。入侵。8、单测自动化平台架构整体分为三部分:1)记录端以MoonlightBox为基础,基于SpringAOP和ASM字节码增强代理技术,开发者在应用内集成,同时在应用程序启动时添加代理代理脚本设置。2)在平台端,将采集到的数据发送给平台端。平台端主要负责应用注册、记录用例统一管理等,为生成端提供用例抽取服务。3)生成器以idea插件和命令行脚本的形式为用户应用生成代码,并对每个用例覆盖的业务代码行号进行去重。最终生成的代码提交到代码库,Bamboo集成获取单测操作和索引采集的代码。9、单元测试平台的共建与接入单元测试自动化技术是当今软件领域的一大难题,业界开发者也在积极寻求突破。我们愿意做一只啄木鸟,通过自动化技术帮助开发者发现代码中的bug,建立单元测试的信心,但是啄木鸟做不到完全自动化。不要因为它的存在而偷懒。每个开发者还是应该发扬:工匠精神,以人为本,工具辅助,测试前容易做单元测试。
