作者:京东零售 陈志良

作为一名京东的软件匠人,我们开发的软件支撑着数亿的用户,责任是重大的,因此我们深深地敬畏每一行代码,那如何将我们的失误降到最低呢?那就是单元测试,它会让我们树立对代码的自信心。为此我们期望能打造一台生产Java单元测试代码的“永动机”,源源不断地为开发者生产代码,辅助大家高效地做好单元测试,节省精力能投入到更多的业务创新中去。

一、开发者对代码的自信心来自哪里?

京东随着业务高速发展,我们缔造的、承载着数亿用户的、功能强大的系统,在经过十多年的打磨,也变得日益复杂。作为JD软件开发者,我们是自豪的,但我们承担的责任也是重大的。我们每一次的创新,就像打造一座下图这样的过山车。我们在为客户带来如此顶级体验的同时,更重要的是保障每一次的旅行都可以安全地着陆。所以我们深深敬畏每一行代码,努力将我们的失误降到最低,为业务保驾护航。

然而,业务的迭代速度之快,交付压力之大,作为“过山车”的缔造者,你是否有以下的经历?

1)每一次上线也像坐了一次过山车呢?

2)你亲手打造的“过山车”,自己是否亲身体验过呢?

3)你是否曾对测试同学说,“你们先上去坐坐看,遇到了问题再下来找我”?

如果你的答案是:每一次上线也像坐了一次过山车,我们自己打造的“过山车”自己不敢坐,我们的代码要靠测试同学兜底,那么就说明我们对自己的代码是缺乏信心的,我们的工作还有待提升的空间;反之则说明,作为一个开发者你已经相当优秀了。

那么如何让我们开发者建立对自己代码的信心呢,一般来说有两种方式:

1)对“过山车”的每个零件都进行充分的测试,保证每一部分在各种场景下都可以正常工作,对所有的异常也能够处理得当,这即是单元测试。

2)对“过山车”启动前做好充分“检查”,这即是代码评审,我们邀请其他大佬帮我们把关,及时发现问题。

这两部分工作在开发阶段都是必要的工作,二者缺一不可。

代码评审是借助了外力,单元测试则是内功,靠自己,靠开发者自测来增强对代码的信心

本文主要和大家一起探讨单元测试,如何把这单元测试的内功练好。

二、做好单测,慢即是快

对于单元测试的看法,业界同仁理解多有不同,尤其是在业务变化快速的互联网行业,通常的问题主要有,必须要做吗?做到多少合适?现在没做不也挺好的吗?甚至一些大佬们也是存在不同的看法。我们如下先看一组数字:

“在 STICKYMINDS 网站上的一篇名为 《 The Shift-Left Approach to Software Testing 》 的文章中提到,假如在编码阶段发现的缺陷只需要 1 分钟就能解决,那么单元测试阶段需要 4 分钟,功能测试阶段需要 10 分钟,系统测试阶段需要 40 分钟,而到了发布之后可能就需要 640 分钟来修复。”——来自知乎网站节选

对于这些数字的准确性我们暂且持保留意见。大家可以想想我们实际中遇到的线上问题大概需要消耗多少工时,除了要快速找到bug,修复bug上线,还要修复因为bug引发的数据问题,最后还要复盘,看后续如何能避免线上问题,这样下来保守估计应该不止几人日吧。所以这篇文章作者所做的调研数据可信度还是很高的,

缺陷发现越到交付流程的后端,其修复成本就越高

有人说写单测太耗费时间了,会延长交付时间,其实不然:

1)研测同学大量的往返交互比编写单测的时间要长的多,集成测试的时间被拖长。

2)没经过单测的代码bug会多,开发同学忙于修复各种bug,对代码debug跟踪调试找问题,也要消耗很多精力。

3)后期的线上问题也会需要大量的精力去弥补。

如果有了单元测试的代码,且能实现一个较高的行覆盖率,则可以将问题尽可能消灭在开发阶段。同时有了单测代码的积累,每次代码改动后可以提前发现这次改动引发的其他关联问题,上线也更加放心。单测虽然使提测变慢了一些,软件质量更加有保障,从而节省了后续同学的精力,从整体看其实效率更高。

所以做好单测,慢即是快。

我们集团技术委员会大佬们从去年开始也在倡议大家做单元测试,

做为一名开发者我们需要对自己的代码质量负责,

也更能体现我们大厂开发者的工匠精神。

三、如何编写单元测试

1、单元测试的主流框架及核心思想

以下我们先通过一个案例介绍下主流框架的思想。下图为一个简单的函数执行逻辑,在函数体内直接调用了函数1、函数2、函数3,间接调用了函数2.1,其中1和2分别是普通函数,2.1和3涉及到外部系统调用,例如JSF、Redis、MySQL等操作,最后返回结果。

代码大致如下:

public class MyObject {
@Autowired
private RedisHelper redisHelper; public MyResult myFunction(InputParam inputParam){ MyResult myResult = new MyResult(); //普通代码块 if(inputParam.isFlag()) {
//如果标记flag为true,则执行函数1
String f1 = invokeFunction1();
//调用函数3,函数3封装了redis中间件操作
String f3 = redisHelper.get(f1); myResult.setResult(f3); } else {
//调用函数2,在函数2内部又调用远程服务接口2.1
String f2 = invokeFunction2();
myResult.setResult(f2);
} return myResult;
}

在当下微服务时代,系统间的交互变得更加日益复杂,以上图例只是简化的例子,实际系统中的上下游外部依赖多达十几个,甚至几十个。

在这种情况下,如果过度依赖外部服务就很难保障每次用例执行成功,会影响到单元测试的执行效果。

所以,当前主流的单元测试框架大都采用了mock技术,来屏蔽对外部服务的依赖,例如:mockito、powermock、Spock等。

图例中2.1和3即是对外部系统的调用,单元测试代码中需要将其API进行mock,在用例运行时运用mock技术模拟外部API接口的返回值,具体写法此处不作举例。

要注意的是,使用Mock技术的框架需要注意两个前提:

1)接口契约是相对稳定的(例如redis的api暂时不会发生变化),否则就需要调整测试用例代码以适应最新的接口契约,如果不调整则此单元测试用例代码是无效的。

2)接口调用是幂等的,同样的入参需要返回相同的结果,否则用例中的断言会失败或者需要对断言进行特殊的处理,例如比较时忽略某些变化的内容(如id、时间等)。

2、第1种单元测试用例的编写方案

接下来写一段基于mockito框架的测试代码,下图中的做法是,开发者编写了一个用例,对外部函数2.1和3进行了mock,然后在测试用例中调用待测函数,再对返回值进行断言。

示意代码如下:

    //创建函数2.1的mock对象
@MockBean
private JSFService myJSFService; //创建函数3的mock对象
@MockBean
private RedisHelper redisHelper; @Autowired
MyObject myObject; @Test
public void testMyFunction(InputParameter parameter) { //根据入参mock返回数据
when(myJSFService.invoke(parameter.getX())).thenReturn(X);
when(redisHelper.get(parameter.getY())).thenReturn(Y); //期望结果
MyResult expect = new Result(XXX); //实际调用被测试函数,返回结果
MyResult actual = vmyObject.myFunction(parameter); //断言
Assert.assertEquals(actual.toString(), expect.toString());

运行该用例后,除了待测函数,连带函数1、2一起都被测试到了,在实际中调用链路会更加复杂,那么这种写法如何呢?我们做个简要的分析:

1)优点:用例的编码量较少,实现速度快,一个用例覆盖了3个函数,整个业务执行路径也都被测试到了,另外单测覆盖率的指标不受影响,只要执行过的代码都会被统计到。

2)缺点:如果用例失败,那么去定位问题会较慢,实际项目中链路会更加复杂,因此排查问题的时间会大幅度增加,假设问题发生在函数1或2中,那么就需要通过debug跟踪逐步排查。

那么这样的做法究竟如何?到这里如果测试的同学看到肯定会有疑问,这样做的用例跟集成测试阶段的自动化用例有啥区别?是的,从效果上看是一样的,只不过将运行转移到了开发阶段。对于排查和定位问题仍然比较困难,所以从真正的效果出发,不建议只是这样做,请往下看。

3、第2种单元测试用例的编写方案

第2种方案是对每一个方法都写用例代码,每个方法是独立的功能单元,隔离该被测方法的全部依赖,将外部依赖的调用都做好mock。大致的做法类似下图:

待测函数的测试用例中会涉及到3个mock,分别是函数1、2、3;函数1、函数2也都有自己的测试用例,这样做出来的单元测试效果会更好。在Java中方法是一个最小存在的可测试单元,所以对每个方法进行独立的充分测试,那么组装后就可以充分保障代码的整体质量,同时也能快速的定位问题,实现快速交付。

目前,业界开发者大多采用第一种偏集成测试的写法,因其工作量相对较小,在交付压力较大的时候,甚至会放弃单元测试,这种情况在互联网行业尤为普遍。在单元测试不足的情况下,则需要靠增强测试人员的人力来缓解质量问题,但当前业务增长压力渐渐显现,各大公司都聚焦于内部提效,人力成本控制更加严格。打铁还需自身硬,当下我们每一位开发者都需要加强自身的内功修炼。

综合以上两种方案,小结如下:

1)为每个方法写单元测试的测试用例,本方法外部调用均为mock。

2)编写一小部分集成测试用例,对整体功能进行部分验证,集成测试主要工作还是交给测试同学。

四、单元测试应遵循的一些原则

目前行业比较流行的有FIRST原则,整理如下

1)Fast,快速

单元测试用例是执行一个特定任务的一小段代码。与集成测试不同的是,单元测试很小很轻,尽量做到没有网络通信,不执行数据库操作,不启动web容器等耗时操作,使它们能快速执行。开发者在实现应用程序功能时,或者调试bug时,需要频繁去运行单元测试验证结果是否正确。如果单元测试足够快速,就可以省去不必要浪费的时间,提高工作效率。

2)Independent/Isolated,独立/隔离

单元测试的用例需要是相互独立的。一个单元测试不要依赖其它单元测试所产生的结果,因为在大多数情况下,单元测试是以随机的顺序运行的。另外,用例代码也不应该依赖和修改外部数据或服务等共享资源,做到测试前后共享资源数据一致,可以用mock或stub的方式对依赖项进行模拟,屏蔽这些依赖项的不确定性,确保单元测试结果的准确性。

3)Repeatable,可重复

单元测试需要保持运行稳定,在不同的计算机、不同的时间点多次运行,都应该产生相同的结果,如果间歇性的失败,会导致我们不断的去查看这个测试,不可靠的测试也就失去了意义。

4)Self-Validating,自我验证

单元测试需要采用Assert相关断言函数等进行自我验证,即当单元测试执行完毕之后就可得知测试结果,全程无需人工介入,不应该在测试完成后做任何额外的人工检查。注意在单元测试中不要添加任何打印日志的语句,避免通过打印出日志才能判断单元测试是否通过。

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)单元测试代码容易失效:单元测试的代码需要持续维护,新业务需求引发的代码变更会导致原有的单测代码失效,在业务高速迭代的情况下,没有额外精力投入,要么忽略,要么删除,在这种情况下,很难持续维持一个较高的覆盖率指标。

归根结底,单元测试最大的困难就是成本问题,做好单元测试,我们的开发者需要持续投入大量的精力,而在业务需求高速迭代的情况下,我们该如何破局?答案就是:自动化技术

六、单元测试自动化调研

其实,单测自动化技术的发展至少已有15年以上的历史,目前主流的技术是静态代码分析技术,它是指无需运行被测代码,仅通过分析或检查源程序的语法、结构、过程、接口等来检查程序的正确性,找出代码隐藏的错误和缺陷。主要的代表产品有:EvoSuite、Squaretest等。

上图是EvoSuite工具根据现有被测代码自动生成的测试代码,目前这类产品生成的单测代码的行覆盖率一般可以达到30%左右,代码越复杂效果越差,它们可以作为简单业务场景的单测代码生成方案。

主要的优点有:纯客户端工具,安装即可使用,不需复杂配置。支持多种开发平台:支持idea、eclipse、命令行等多种工具。

主要的不足:生成代码质量不高、单测覆盖率较低:受限于代码分析技术和现实技术框架的复杂多样,生成的代码质量不高,单测覆盖率较低,只能适用于简单业务场景,且生成的代码需要人工判断有效性。例如订单sendpay这样的标记包含了丰富的业务语义,则很难通过静态分析生成有效的用例代码。

七、我们的一些想法与技术突破

1、将录制的数据转化为单元测试用例

基于静态代码分析局限性,我们需要寻找一个新的方向,那么如何能够获得更加丰富的业务数据呢,而不是通过一些策略生产数据,前年咱们零售交易研发创新了月光宝盒,完全可以将数据录制下来,于是我们就想到是否可以利用宝盒录制到的数据,反向生成测试用例呢,以此来实现快速生产单元测试的用例代码。大致的方案思路如下:

2、标杆验证的效果给了我们信心

乍一听这个想法有点疯狂,我们针对这个想法做了效果验证,虽然还没有达到奇效,但整体思路得到了检验,事实证明,这个方案虽然很难,但是是可行的,以下为Y侧做的标杆案例的尝试。通过4个标杆的试运行情况分析,接入一周内,生成代码2.3万行,单测行覆盖率提升幅度均在30%以上。

3、然而,该方案还并不完美,我们还有些建议

如果你仔细看过前面提到的单元测试原则,针对该方案一定会有疑问,没错,它违反了及时性原则,我们应该在写代码时或者提测前完成啊,测试阶段再录制生成已经晚了。的确,该方案不是完美的,为此我们给出的建议是:

1)针对存量代码,由于目前我们的存量代码数量较大,该方案将会产生较大的效果,开发者只要将录制工具集成到被测应用即可,接入成功后,如果测试同学能帮忙跑一次全量回归测试最佳,则可以快速生成大量的用例代码,如果测试同学时间不充足,则借助测试同学的日常测试逐渐积累数据,经过一两周后也能获得大量用例代码。

2)对于新开发代码,在开发者完成编码后的自测阶段,由开发者自己本地运行程序进行自测、录制,也能帮助我们生成一大批用例,然后可以基于生成的用例,再通过复制、手工调整进行快速扩充用例,从而保证单元测测的及时性。

3)特殊业务场景处理,对于边界或异常用例很难录制到,则可以通过手工复制用例,再修改用例数据,来扩充用例,这种方式比纯手工编写还是快很多,尤其是mock对象非常复杂的时候,用该方案可以在1分钟内即可基于已有用例扩展一个新用例。

4、生成的单元测试用例是什么样子

下面举一个生成单元测试用例代码的实际例子,该例子基于Mockito框架,每一个用例方法对应一个JSON文件,JSON文件中存储着用例运行时需要的出入参、全部外部调用的数据,用例代码和数据全部由工具自动生成,生成的大部分代码都是在帮助开发者将录制的数据组装Mock对象,这部分工作量在实际开发中是最大的,因此可以大幅度减小开发者自己纯手工编码工作。当需要手工扩充用例时,只需要将用例方法和数据文件复制一份,再对用例数据做出调整即可制作出新的用例。

数据文件样例:

/artt/StockStatusReOccupySplitServiceImpl1#HpCm.json

5、我们所遇到的技术挑战

我们遇到了很多技术难点,由于基于宝盒录制的数据在还原代码时信息还不足,需要增加更多的录制信息与特殊应用场景处理,主要难点有:

1)结构化数据的录制与还原,复杂泛型的还原、复杂对象的序列化和反序列化

2)基于动态代理技术实现代码的特殊处理,如mybatis、JSF

3)用例的采样控制,重复用例的识别与剔除,

4)用例结果断言的多样性,需要丰富的比对策略

期间涉及到了大量的底层技术研究,截至目前我们仍然有很多技术点需要攻克。例如,我们正在做的应用接入提升,将Spring AOP的方式用agent+ASM方式进行替换,实现代码增强在不重启服务的情况下动态挂载、卸载,也进一步降低接入成本,减少对应用的入侵。

八、单测自动化平台的架构

整体分为三部分:

1)录制端,采用月光宝盒为基座,基于Spring AOP和ASM字节码增强agent技术,开发者在应用内部进行集成,同时在应用启动中增加agent代理脚本设置。

2)平台端,采集到的数据将被发往平台端,平台端主要负责应用注册、录制用例的统一管理等,并为生成端提供用例抽取服务。

3)生成端,以idea插件、命令行脚本的形式,为用户的应用生成代码,并且按照每个用例覆盖业务代码的行号进行去重。最终生成的代码提交到代码库,bamboo集成获取代码进行单测运行与指标的采集。

九、单测平台的共建与接入

单元测试自动化技术是当今软件领域的一个难题,行业的开发者也都在积极寻求突破

我们愿意做一只啄木鸟

帮助开发者找到代码里的虫子

通过自动化技术建立单测的信心

但啄木鸟还做不到全面自动化

大家不要因为它的存在而变得懈怠

每位开发者仍然要发扬:

工匠精神,以人为本,工具为辅

在提测前轻松做好单元测试

一台不容错过的Java单元测试代码“永动机”的更多相关文章

  1. 立足GitHub学编程:13个不容错过的Java项目

    立足GitHub学编程:13个不容错过的Java项目 今天我们将整理一大波干货满满的Java示例代码与能力展示素材. GitHub可谓一座程序开发的大宝库,有些素材值得fork,有些则能帮助我们改进自 ...

  2. 13个不容错过的Java项目

    今天我们将整理一大波干货满满的Java示例代码与能力展示素材. GitHub可谓一座程序开发的大宝库,有些素材值得fork,有些则能帮助我们改进自有代码或者学习编程技能.无论如何,开发工作当中我们几乎 ...

  3. Java框架介绍-13个不容错过的框架项目

    本文转自互联网,个人收藏所用. 下面,我们将一同分享各有趣且颇为实用的Java库,大家请任取所需.不用客气~ 1.极致精简的Java Bootique是一项用于构建无容器可运行Java应用的极简技术. ...

  4. 转载-使用 Feed4JUnit 进行数据与代码分离的 Java 单元测试

    JUnit 是被广泛应用的 Java 单元测试框架,但是它没有很好的提供参数化测试的支持,很多测试人员不得不把测试数据写在程序里或者通过其它方法实现数据与代码的分离,在后续的修改和维护上有诸多限制和不 ...

  5. iOS开发人员不容错过的10大工具

    内容简介 1.iOS简介 2.iOS开发十大实用工具之开发环境 3.iOS开发十大实用工具之图标设计 4.iOS开发十大实用工具之原型设计 5.iOS开发十大实用工具之演示工具 6.iOS开发十大实用 ...

  6. 每位iOS开发人员不容错过的10大实用工具

    内容简介 1.iOS简介 2.iOS开发十大实用工具之开发环境 3.iOS开发十大实用工具之图标设计 4.iOS开发十大实用工具之原型设计 5.iOS开发十大实用工具之演示工具 6.iOS开发十大实用 ...

  7. 每位iOS开发者不容错过的10大有用工具

    内容简单介绍 1.iOS简单介绍 2.iOS开发十大有用工具之开发环境 3.iOS开发十大有用工具之图标设计 4.iOS开发十大有用工具之原型设计 5.iOS开发十大有用工具之演示工具 6.iOS开发 ...

  8. php中正则表达式总结(不容错过)

    php中正则表达式总结(不容错过) 一.总结 一句话总结: 无论js,php,java,python里面中的正则都是差不多一样的,所以用点脑子 用到正则的地方很多,比如 nginx的配置文件 1.ph ...

  9. 常用 Java 静态代码分析工具的分析与比较

    常用 Java 静态代码分析工具的分析与比较 简介: 本文首先介绍了静态代码分析的基 本概念及主要技术,随后分别介绍了现有 4 种主流 Java 静态代码分析工具 (Checkstyle,FindBu ...

  10. FastDFS搭建及java整合代码【转】

    FastDFS软件介绍 1.什么是FastDFS FastDFS是用C语言编写的一款开源的分布式文件系统.FastDFS为互联网量身定制,充分考虑了冗余备份.负载均衡.线性扩容等机制,并注重高可用.高 ...

随机推荐

  1. KubeEdge发布云原生边缘计算威胁模型及安全防护技术白皮书

    摘要:本文将基于KubeEdge项目详细分析云原生边缘计算业务过程的威胁模型并给出对应的安全加固建议. 本文分享自华为云社区<KubeEdge发布云原生边缘计算威胁模型及安全防护技术白皮书> ...

  2. 性能测量工具-DevTools/PageSpeed/LightHouse

    前端的性能优化有诸多有迹可循的理论和方法,比如 Yahoo!性能军规(Best Practices for Speeding Up Your Web Site).Google PageSpeed In ...

  3. JVM学习-自动内存管理

    文章原文:https://gaoyubo.cn/blogs/6997cf1f.html 一.运行时数据区 Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域.这些区域 ...

  4. 【django-vue】主页前端搭建 git介绍和安装 git工作流程 git常用命令 git过滤文件 重写drf方法 跨域中间件 导出项目依赖

    目录 上节回顾 1 主页前端 Header组件 Banner组件 Footer组件 2 git介绍和安装 git和svn比较 pycharm中配置git svn,git ,github,gitee,g ...

  5. docker 容器关闭与springboot/springcloud client关闭

    问题的提出: 在docker容器中运行springboot.springcloud client,如何进行优雅的关闭,防止eureka出现: EMERGENCY! EUREKA MAY BE INCO ...

  6. MySQL 的 crash-safe 原理解析

    本文首发于 vivo互联网技术 微信公众号 链接:https://mp.weixin.qq.com/s/5i9wmJs4_Er7RaYfNnETyA作者:xieweipeng MySQL作为当下最流行 ...

  7. vue-cli3开启gzip压缩

    首先添加yarn add 插件 "compression-webpack-plugin" 然后在vue.config.js里引入 在configureWebpack里增加插件代码. ...

  8. C#对字符串进行加密解密

    首先上效果图 加解密接口 internal string ToEncrypt(string encryptKey, string str) { try { byte[] P_byte_key = // ...

  9. 图文ASP.Net MVC Razor页面中HtmlHelper帮助程序的写法

    将以下内容复制到cshtml文件中 @using Microsoft.AspNetCore.Html @{ ViewData["Title"] = ""; } ...

  10. 4.Prometheus之存储及WAL

    一.整体介绍 二.block 2.1 head block 三.WAL(Write-ahead logging, 预写日志) 3.1 数据流向 四.和存储相关的启动参数 五.总结 一.整体介绍 Pro ...