测试覆盖率 Java 覆盖率 Jacoco 插桩的不同形式总结和踩坑记录

风雨夜阑听 · September 23, 2019 · Last by 小佐料 replied at December 02, 2019 · 8733 hits
本帖已被设为精华帖!

关于Jacoco的小结和踩坑记录

一、概述

测试覆盖率,老生常谈的话题。因为我测试理论基础不是很好,就不提什么需求覆盖率啦这样那样的主题了,直奔主题,咱主要指Java后端的测试覆盖率。

由于历史原因,公司基本不做UT,所以对测试来说,咱最关心的还是手工执行、接口执行(人工Postman之类的)、接口自动化、WebUI自动化对一个应用系统的覆盖度。

此文也是对另一篇文章关于Java端覆盖率探索的一个细化,当时主要是为了描述App端的踩坑,所以关于服务端一笔带过了,现得空详细补充一下。
小记 Java 服务端和 Android 端手工测试覆盖率统计的实现

二、站在巨人的肩膀上

本文提到的内容,多数还是得力与站在巨人的肩膀上。

国际惯例,感谢以下文章带来的灵感:
有赞测试 浅谈代码覆盖率
地址:https://testerhome.com/articles/16981

腾讯移动品质中心TMQ [腾讯 TMQ] JAVA 代码覆盖率工具 JaCoCo-踩坑篇
地址:https://testerhome.com/topics/5876

腾讯移动品质中心TMQ [腾讯 TMQ] JAVA 代码覆盖率工具 JaCoCo-实践篇
地址: https://testerhome.com/topics/5823

测试覆盖率 代码变更覆盖率平台-针对手工测试的代码变更覆盖率实现之路
地址: https://testerhome.com/topics/19077

以上等等,都是在覆盖率这个坑里蹲着的时候,给我递了绳子的,也让我顺利从坑里爬了出来!谢谢

三、写本文的初衷

本来Jacoco已经流行了很多年了,各种文档和帖子已经描述的很完美了,但是多数文章都是针对某一特定形式做了总结和使用。相信很多负责整个公司项目的覆盖率任务的人们来说,还是要一种一种去研究、去应对,入坑、出坑不厌其烦。

也得益于今年上半年一直负责整个公司不同类型的项目的覆盖率统计技术的适配,对不同形式的项目均有一定的了解,在此记录一下,也不让千疮百孔的自己浪费掉这半年的精力,如果说可以帮到别人一星半点,那这篇文章就算是造福了。

由于本人能力有限、表达能力有限,也难免在文中会有错别字、技术描述不到位、或者总结有误的地方,还请大家原谅则个,我也争取不误导大家。

四、 投入覆盖率之前的思路

因为之前了解过一部分Jacoco的机制,也知道它提供了很多强大的功能,以满足不同形式的项目。但归根结底,Jacoco提供了api,可以让大家屏蔽不同类型的项目带来的困扰。

Jacoco官方的Api示例
地址: https://www.jacoco.org/jacoco/trunk/doc/api.html

个人认为,以Api的方式来进行操作,可以有以下好处:
可以屏蔽不同方式的构建部署,如果你想把这个功能做成平台,那api想必是很好的一种方式。

也就是说,我只需要把jacoco插桩到测试服务器上,暴露tcp的ip和端口,剩余的提取代码执行数据、生成覆盖率报告,就可以用统一的方式进行就好了。
众所周知,jacoco官方提供了Maven插件方式、Ant的xml方式,均有对应的dump和report来进行覆盖率数据的dump和报告生成,如果有兴趣可以研究一下,我也不过于啰嗦。

五、对我司项目的梳理

我在另一篇关于覆盖率文章中有提到,我司是个老牌公司,项目杂乱无章,技术五花八门。截至2019年9月23日仍然有j泡在jdk6上的。所以我个人认为,影响jacoco使用过程的,可能存在于以下几点。

  1. jdk版本。我司现有jdk6、7、8.但实际上jdk6是个分水岭,其他的都基本可以用jdk8来适配。
  2. 构建工具。我司现有Maven构建、ANT构建,想必有的公司还有用gradle的。
  3. 部署方式。Ant、Maven插件启动、java -jar启动、tomcat启动war包(打包方式就随便了)

稍后内容也都基于这几种不同实现方式做描述。如果接触项目多的,基本就知道,很多时候测试还是不介入测试环境的发布,这一方面源于开发的不信任,他们认为发布还是要抓在开发自己手里;另一方面也源于测试人员能力的跟不上,至少在我司很多测试人员确实不太懂如何发布(虽然现在慢慢有所缓解,越来越都的测试人员都从开发手中接了过来)。

线上部署、测试部署、开发部署,这几个不同场景,可能用的方式都不同,至少在我接触的项目大都是这样。开发喜欢用插件的方式启动部署,因为快嘛,而且IDE也支持,右键运行一下基本在ide就启动了,想想看如果你是开发,在你本地IDE里调试的时候,需要打个war包然后丢到tomcat里,再启动tomcat,你也不太乐意。

六、jacoco插桩的本质

废话不多说,步入正题。
在上面提到的几篇文章里,多数都提到了jacoco介入部署过程的本质,就是插桩,至于怎么插桩,那就跟接入阶段有关系了。可以是编译时插桩、也可以是运行时插桩,这就是所谓Offline模式和On-the-fly模式,我们也不过多于纠结,我们选择on-the-fly模式。

所以归结到本质,jacoco的on-the-fly模式的插桩过程,其实就是在测试环境部署的时候,让jacoco的相关工具,介入部署过程,也就是介入class文件的加载,在加载class的时候,动态改变字节码结构,插入jacoco的探针。

本质: jacoco以tcpserver方式进行插桩的本质,就是如果应用启动过程中,进行了jacoco插桩,且成功了。它会在你当前这个启动服务器中,在一个端口{$port}上,开启一个tcp服务,这个tcp服务,会一直接收jacoco的执行覆盖率信息并传到这个tcp服务上进行保存。他既然是个tcp服务,那jacoco也提供了一种以api的方式连接到这个tcp服务上,进行覆盖率数据的dump操作。
(细节可能描述的不是很精确,但差不多就是这么个过程。这个tcp服务,在你没有关闭应用的时候,是一直开着的,可以随时接受连接)

那最后再本质一点,就是介入下面这个命令的启动过程:

java -jar 

那问题就好办了,一种一种来对应起来。

七、 不同形式的插桩配置

提到介入启动过程,那就免不了提一下一个jar包。

jacocoagent.jar
下载地址:https://www.eclemma.org/jacoco/
下载后解压文件夹里,目录如下:

这个jacocoagent.jar,就是启动应用时主要用来插桩的jar包。
请注意不要写错名称,里面有个很像的jacocoant.jar,这个jar包是用ant xml方式操作jacoco时使用的,不要混淆。

以测试环境部署在linux服务器上为例,如果想在windows上测试也可以,把对应的值改成windows上识别的即可。

假设jacocoagent.jar的存放路径为:/home/admin/jacoco/jacocoagent.jar
以下都以$jacocoJarPath来替代这个路径,请注意这个路径不是死的,你可以修改。

依然是基于上述的几种不同方式,那我们针对不同形式·做插桩,也就是改变这几种不同形式的底层启动原理,也就是改动不同方式的java的启动参数,这对每一种启动方式都不太一样。但是改动java启动参数本质也是一样的,就是在java -jar启动的时候,加入-javaagent参数

-javaagent:$jacocoJarPath=includes=*,output=tcpserver,port=2014,address=192.168.110.1"

换成实际的信息为如下,请注意替换真实路径,这一句是需要介入应用启动过程的主要代码,针对每种不同的部署方式,需要加到不同的地方

-javaagent:/home/admin/jacoco/jacocoagent.jar=includes=*,output=tcpserver,port=2014,address=192.168.110.1

7.1 这句话的解释

  1. -javaagent
    jdk5之后新增的参数,主要用来在运行jar包的时候,以一种方式介入字节码加载过程,如有兴趣自行百度。注意后面有个冒号:

  2. /home/admin/jacoco/jacocoagent.jar
    需要用来介入class文件加载过程的jar包,想深入了解的,百度“插桩”哈。
    这是一个jar包的绝对路径。

  3. includes=*
    这个代表了,启动时需要进行字节码插桩的包过滤,*代表所有的class文件加载都需要进行插桩。
    假如你们公司内部代码都有相同的包前缀:com.mycompany
    你可以写成:

    includes=com.mycompany.*
  4. output=tcpserver
    这个地方不用改动,代表以tcpserver方式启动应用并进行插桩

  5. port=2014
    这是jacoco开启的tcpserver的端口,请注意这个端口不能被占用

  6. address=192.168.110.1
    这是对外开发的tcpserver的访问地址。可以配置127.0.0.1,也可以配置为实际访问ip
    配置为127.0.0.1的时候,dump数据只能在这台服务器上进行dump,就不能通过远程方式dump数据。
    配置为实际的ip地址的时候,就可以在任意一台机器上(前提是ip要通,不通都白瞎),通过ant xml或者api方式dump数据。
    举个栗子:
    我如上配置了192.168.110.1:2014作为jacoco的tcpserver启动服务,
    那我可以在任意一台机器上进行数据的dump,比如在我本机windows上用api或者xml方式调用dump。
    如果我配置了127.0.0.1:2014作为启动服务器,那么我只能在这台测试机上进行dump,其他的机器都无法连接到这个tcpserver进行dump。

  7. 总结:
    这句内容,如下,格式是固定的,只有括号内的东西方可改变,其它尽量不要动,连空格都不要多:

    -javaagent:(/home/admin/jacoco/jacocoagent.jar)=includes=(*),output=tcpserver,port=(2014),address=(192.168.110.1)
比如我可以改成其他的:

```shell
-javaagent:/home/admin/jacoco_new/jacocoagent.jar=includes=com.company.*,output=tcpserver,port=2019,address=192.168.110.111

注意其他地方基本不用改动。

7.2 war包方式启动

tomcat的war包方式启动,假设tomcat路径为:$CATALINA_HOME= /usr/local/apache-tomcat-8.5.20,我们常用的命令存在于:$CATALINA_HOME\bin下,有startup.sh和shutdown.sh(windows请自觉改为bat,后续不再声明),其实这两个只是封装之后的脚本,底层调用的都是$CATALINA_HOME\bin\catalina.sh(或者bat),如图源码:

因此,只需要改动catalina.sh中的启动参数即可。
前面提到过,主要改动主要是改动java -jar,tomcat是通过一个JAVA_OPTS参数来控制额外的java启动参数的,我们只需要在合适的地方把上面的启动命令追加到JAVA_OPTS即可
打开catalina.sh,找到合适的地方修改JAVA_OPTS参数:

理论上,任何地方修改JAVA_OPTS参数均可,但我们实验过后,在以下位置加入,是一定可以启动成功的,当然您也可以尝试其他位置.

JAVA_OPTS="$JAVA_OPTS -Dorg.apache.catalina.security.SecurityListener.UMASK=`umask`"

源脚本中有这个注释掉的地方,我们在下方修改JAVA_OPTS:
在其下方,加一句:

JAVA_OPTS="$JAVA_OPTS -javaagent:$jacocoJarPath=includes=*,output=tcpserver,port=2014,address=192.168.110.1"

改完之后如下所示:

改完之后,就可以进行startup.sh的启动了,应用启动成功之后,可以在服务器上进行调试,查看tcpserver是否真的起来了。
判别方式如下(该图中是现有的已经开启的服务,所以ip和端口跟前面的命令不一样,这点请注意,这里只是为了展示;后续几种方式判别方式相同,不再赘述了哈),这个端口在应用启动时被占用,在应用关闭时被释放,这个请注意检查:

如此,这个端口已经在监听了,证明这个测试环境已经把jacoco注入进去,那你对该测试环境的任何操作,代码执行信息都会被记录到这个ip:port开启的tcp服务中。

7.3 Maven命令的插件启动方式

在我司,有的开发会喜欢用插件方式启动,在代码pom文件层级中,运行如下命令:

mvn clean install

mvn tomcat7:run -Dport=xxx

或者还有

mvn clean install

mvn spring-boot:run -Dport=xxx

这两套命令,本质上没什么差别,只是运行插件不一样,具体用什么命令,如果不清楚,最好是跟开发请教一下。
他们的意思是,在当前代码的pom文件层级运行,意思是通过maven的tomcat插件启动这个服务,这个服务启动在端口xxx上,注意这个端口是应用的访问端口,和jacoco的那个端口不是一回事.

对这种方式注入jacoco,也是可以的。这种可以不用修改任何的配置文件,只需要在你启动的时候,临时修改变量就行了。
这种方式改变java的启动参数方式是这样:

export MAVEN_OPTS="-javaagent:$jacocoJarPath=includes=*,output=tcpserver,port=2014,address=192.168.110.1"

这句命令加在哪里呢?就是run之前。为什么呢,因为这样一改,你的所有的mvn命令都会生效,但其实我们只想介入启动过程。
因此,前面提到的两套启动命令,就可以改成如下方式:

mvn clean install
export MAVEN_OPTS="-javaagent:$jacocoJarPath=includes=*,output=tcpserver,port=2014,address=192.168.110.1"
mvn tomcat7:run -Dport=xxx
export MAVEN_OPTS=""

mvn clean install
export MAVEN_OPTS="-javaagent:$jacocoJarPath=includes=*,output=tcpserver,port=2014,address=192.168.110.1"
mvn spring-boot:run -Dport=xxx
export MAVEN_OPTS=""

当然,你的run命令,也可能是其他变种,比如:nohup mvn .... & 这种后台启动的方式,也是可以的。
最后修改为""是因为担心对后续的mvn命令产生影响,其实如果你切换了terminal窗口,这个临时变量就会失效,不会对环境造成污染。
如果应用启动成功了,就可以按照前面的方式,netstat叛别一下tcp服务是否真的启动。

如果你设置了这个变量的位置不对,那你用mvn命令的时候,可能会出现如下的异常:

java.net.BindException: Address already in use: bind

这时候,就需要去检查一些,你配置的jacoco端口是不是在启动应用服务时已经被占用。
或者你临时设置了MAVEN_OPTS这个变量,启动之后又没有改回来,然后接着运行了mvn命令,这时候也会出现这种错误。
这里请务必关注。

提一句题外话,ANT的方式是不是也可以通过临时修改ANT_OPTS参数进行启动(因为ANT和MAVEN本是一家子吗,我才底层可能差异不是很大),我不曾做尝试,有兴趣的可以尝试下

7.4 ANT构建,通过xml配置文件启动

这种方式可能实现启动应用的阶段不同,但大都配置在build.xml里,这里请根据不同的项目做不同的适配.
它的原理是,在ant的启动target中,有个的标签,给她增加一个jvmarg参数的子标签,如下代码:

<jvmarg value=-javaagent:$jacocoJarPath=includes=*,output=tcpserver,port=2014,address=192.168.110.1 />

比如我们的启动命令是这样:

ant -f build.xml clean  build  startJetty

以此启动之后,将会注入jacoco的代理,最终可以按照上面的方式判断端口是否启动。

7.5 java -jar方式启动

这种最简单直接:

java -javaagent: $jacocoJarPath=includes=*,output=tcpserver,port=2014,address=192.168.110.1 -jar  xxxxxxxxxx.jar 

注意,javaagent参数,一定要在jar包路径之前,尽量在-jar之前,不然可能不会生效。
请注意java -jar命令的使用方式,在jar包前面传进去的是给jvm启动参数的,在jar包之后跟的是给main方法的。

启动后,依然按照前面的方式判断是否启动了监听端口。

八、关于启动之后

启动之后,就进行测试就可以了,跟平常不注入jacoco代理是无异的。

九、关于注意事项(可能前面有啰嗦重复的,但需要注意)

  1. 修改JAVA_OPTS参数时,如果位置不对,可能造成代理无法启动。
  2. java -jar启动时,-javaagent参数,不能错误,否则可能造成代理不生效
  3. Export MAVEN_OPTS参数时,后续的所有mvn命令,都会带上此参数,因此相当于每次执行mvn命令,都会尝试启动代理,因此可能会出现address bind already in use之类的异常抛出。 因此,我们只有在mvn tomcat7:run启动服务器时才需要启动代理,其他如mvn的编译、install命令都不需要,所以在启动之后,把MAVEN_OPTS参数置空,或者重启一个terminal来执行命令
  4. 同一个ip地址上,部署多套服务器需要收集覆盖率时,端口自己规划好,不可重复。
  5. 测试执行信息的收集(在应用的测试服务器)
  6. 测试执行信息的获取、以及生成覆盖率报告(可在测试服务器上、也可在统一的服务器上)
  7. 5的收集在测试服务器上,6的操作可以在测试服务器是,也可以是统一的服务器(我们选择后者)。
  8. 关闭应用服务时,务必不要强杀,请使用kill -15 杀进程(当然有时候,会出现kill -15 杀不掉进程的时候,用kIll -9 也无妨,这一点并不是很确定),否则,很有可能会造成覆盖率数据来不及保存而丢失。

十、说给想做平台的你

按照原来的流程,如果想做增量的覆盖率,那么有如下的步骤需要涉及,我们需要做的事情:

  1. 部署测试服务器(加入jacoco的代理,按照上面的方式进行即可) 2 需要知道上述部署时的版本代码,需要知道待比较的基线版本代码,并下载两个代码到某个路径下,并编译最新的代码(至于需不需要编译,看你的需求,也可以用测试服务器上的,这样最准确。现编译的话,可能会编译机跟测试机的不同,造成生成的class文件不一致,这会导致覆盖率数据不准确)
  2. Dump覆盖率执行数据
  3. 根据dump出来的执行数据exec文件,以及刚才对最新代码的编译出来的字节码class文件和src中的源代码进行报告生成
  4. 导出覆盖率数据报告(一般是在linux中执行,查看时需要到自己的windows或者mac上查看) 以上五个步骤,对获取覆盖率数据缺一不可,不然无法出增量覆盖率数据。

那么上述的步骤,其实可以都进行自动化配置。

  1. 部署。
    如果有devops平台的话,可以集成进去,端口要规划好。

  2. 基线代码、和最新代码
    可以用jgit和svnkit这两个工具进行代码下载和克隆。

  3. dump.
    用API去dump,可以屏蔽不同启动方式,只需要有tcp的serverip和端口即可。

  4. report。
    用jacoco的api做。
    那唯一的差别,就是对项目层级的判定,比如多模块、比如可能项目的目录并不规范(有的maven项目并没有把所有的代码放到src/main/java下),这些需要自己对公司项目进行适配。
    我司就是因为项目结构差别太大,所以适配的过程花了一番功夫。

5 导出报告。
提供下载,或者给出服务器存放的链接,都行,这个看个人实现就行了。

十一、一些坑

  1. ant构建
    build.xml中,有特定的compile阶段,这个自己去找。
    请务必保证,有

    debug="true"

    这个配置,不然,jacoco是无法注入的,有的时候ant项目生成的数据为0,就可以去排查下这里。
    如我司配置了两个,一个compileDebug,一个compile,在compileDebug阶段打开了debug的开关:

  2. 关于负载均衡
    有时候可能一个服务会有负载均衡出现,那么可以配置不同端口,如果在不同服务器上,那么IP和端口都可以不同。
    这时候,在dump数据的时候,只需要循环几个ip:port(至于你想怎么传,那就是代码层面事情了)去dump,保存到同一个文件中就行了。

  3. 做平台时-项目代码无法独立编译
    这个看怎么解决了,如果非要自己编译,那就让开发适配到可以独立编译。
    我这里是提供了sftp下载的方式,你告诉我你的代码在哪个服务器的那个路径,提供给我用户名密码,我用java的方式去sftp下载到平台部署的机器上。
    这样可以解决现编译的不匹配问题,也可以解决无法独立编译的问题。
    但是有几个遗留问题,你如何判定是不是要重新下载,你也会担心sftp下载下来的class和java代码跟测试机上的是否不一样。这个要看个人取舍,理论上tcp进行下载还是安全的。

  4. 如果注入jacoco的配置之后,端口确实没有起来或者dump的时候,tcpserver连接不上
    可能原因有几种。

    • Tcp端口确实没起来,这个在部署测试服务器的文档里有说明,部署后需要查看下是否真的起来。
    • Tcp端口确实起来了,netstat查看的时候也是显示正确。 这种还有两种可能。
      1. 确保javaagent参数中的address写的是真实ip地址,而不是127.0.0.1或者localhost。
      2. 防火墙。防火墙开启的时候,阻碍了外部ip连接的进入,请关闭防火墙,或者配置防火墙策略。
  5. 覆盖率数据会丢失或者不准确
    举个栗子。
    8:30的时候,执行了测试,生成了一次报告。此时8.30之前的数据,肯定是存在的。
    9:00的时候,重新部署了,之前没有再次捞取执行信息,那重启之后,8.30-9.00之间的执行记录可能很大概率丢失。
    所以,务必小心。

  6. 怎么确保报告准确,且尽量减少丢失?
    及时保存,及时收集,可以采用定时任务的方式。

  7. 应用的突然重启和服务器的断电状况怎么处理?
    天灾,没招。如果真的确实需要,可以在程序中加入定时收集,但是频率不一定好控制,而且当不再执行的时候,平白重复保存完全一模一样的执行信息,个人觉得意义不大,会对服务器磁盘造成巨大压力。具体解决方案还要看个人取舍。

  8. 造成覆盖率报告数据不准确的原因有哪些?
    最最最最底层的原因。 部署时的class文件和生成报告的时候,用的class文件不一致。有以下几种情况:

    • 测试服务器(就是你的应用所在的那个环境)中的class文件和我管理平台上编译环境不一致,导致产生的class文件跟部署时的class文件有差异。这个可以通过不手动编译,而是从 测试服务器部署位置的目录来拷贝传输,来解决,但现阶段,没做。
    • 测试服务器版本变更了,但是管理平台上的代码没变更(或者说新代码拉取下来了,但是没有重新编译。),导致class文件不一致
    • 管理平台上的新版本代码的版本号没有填写,默认每次拉取最新代码,这会导致生成报告的时候,源码变了,class文件没变,覆盖率插桩收集的时候,用的还是老代码 所以,要想准确。需要保证,测试服务器部署时的代码版本和管理平台上写的版本号完全一致。

不知不觉,又写了这么多,哎,表达能力不行,又啰嗦了。不早了,先写到这里,稍后想到,再作补充把~~~~~~

十二、补充一些API相关的代码

PS: 2019年9月24日 09:20追加一些用到的源码,昨天写完22:20了,着急赶末班车,就没来得及,今天补上一些。

覆盖率数据的获取

import org.jacoco.core.tools.ExecDumpClient;
import org.jacoco.core.tools.ExecFileLoader;
...

public void dumpExecDataToFile(String filePath) {
logger.debug("开始dump覆盖率信息:{},到:{}文件中", this.jacocoAgentTcpServer,
filePath);
ExecDumpClient dumpClient = new ExecDumpClient();
dumpClient.setDump(true);
ExecFileLoader execFileLoader = null;
try {
execFileLoader = dumpClient.dump(
this.jacocoAgentTcpServer.getJacocoAgentIp(),
this.jacocoAgentTcpServer.getJacocoAgentPort());
// 这个后面的true,代表如果这个文件已经存在,且以前已经保存过数据,那么是可以追加的,也相当于覆盖率数据文件的合并
//如果设置为false,则会重置该文件,这在多节点负载均衡的时候尤其有用,可以把多个节点的数据组合合并之后再进行统计
execFileLoader.save(new File(filePath), true);
} catch (IOException e2) {
logger.error("获取dump信息失败:{}", e2.getMessage());
throw new BusinessValidationException("tcp服务连接失败,请查看tcp配置");
}
}

另外可以根据自己的需要,看下是否把以前的覆盖率数据做备份(我们现在是做了备份、且做了定时dump,防止覆盖率数据突然丢失),需要的时候从备份数据里拿,再从tcpserver中dump,然后做合并,这个过程可能统计全量的时候尤其需要。

CodeCoverageDTO.java

该文件主要封装覆盖率数据生成报告的时候需要的一些属性,如数据文件、src源码、class文件、报告存放文件等等。

import java.io.File;

/**
* @author : Administrator
* @since : 2019年3月6日 下午7:53:02
* @see :
*/

public class CodeCoverageFilesAndFoldersDTO {
private File projectDir;

/**
* 覆盖率的exec文件地址
*/

private File executionDataFile;

/**
* 目录下必须包含源码编译过的class文件,用来统计覆盖率。所以这里用server打出的jar包地址即可
*/

private File classesDirectory;

/**
* 源码的/src/main/java,只有写了源码地址覆盖率报告才能打开到代码层。使用jar只有数据结果
*/

private File sourceDirectory;
private File reportDirectory;
private File incrementReportDirectory;

public File getProjectDir() {
return projectDir;
}

//省略了getter和setter
}

ReportGenerator.java

这里生成报告的时候,其实默认应该已经有源码、exec文件、class文件了,至于class文件什么时候编译出来的或者怎么出来的,那应该在生成报告的前置步骤已经做好了。

private static void createReportWithMultiProjects(File reportDir,
List<CodeCoverageFilesAndFoldersDTO> codeCoverageFilesAndFoldersDTOs)
throws IOException {
logger.debug("开始在:{}下生成覆盖率报告", reportDir);
File coverageFolderFile = reportDir;
if (coverageFolderFile.exists()) {
FileUtil.forceDeleteDirectory(coverageFolderFile);
}

HTMLFormatter htmlFormatter = new HTMLFormatter();
IReportVisitor iReportVisitor = null;

boolean everCreatedReport = false;

for (CodeCoverageFilesAndFoldersDTO codeCoverageFilesAndFoldersDTO : codeCoverageFilesAndFoldersDTOs) {
// class文件为空或者不存在
boolean classDirNotExists = (null == codeCoverageFilesAndFoldersDTO
.getClassesDirectory())
|| (!(codeCoverageFilesAndFoldersDTO.getClassesDirectory()
.exists()));

// class文件目录不存在
boolean needNotToCreateReport = classDirNotExists;
if (needNotToCreateReport) {
logger.debug("目录:{}没有class文件,不生成报告",
codeCoverageFilesAndFoldersDTO.getProjectDir()
.getAbsolutePath());
continue;
}

// 修改标志位
everCreatedReport = true;
logger.debug("正在为:{}生成报告", codeCoverageFilesAndFoldersDTO
.getProjectDir().getAbsolutePath());
IBundleCoverage bundleCoverage = analyzeStructureWithOutChangeMethods(
codeCoverageFilesAndFoldersDTO);
ExecFileLoader execFileLoader = getExecFileLoader(
codeCoverageFilesAndFoldersDTO);
iReportVisitor = htmlFormatter
.createVisitor(new FileMultiReportOutput(
new File(coverageFolderFile.getAbsolutePath(),
codeCoverageFilesAndFoldersDTO
.getProjectDir().getName())));

if (null != execFileLoader) {
iReportVisitor.visitInfo(
execFileLoader.getSessionInfoStore().getInfos(),
execFileLoader.getExecutionDataStore().getContents());
}

//这个地方之所以没有用一个固定的文件夹来指定,是因为我们的项目有的不标准,如果你们的项目是标准的,比如都在src/main/java下,那就可以直接用一个固定值
//我们这里为了防止src/java src/java/plugin src/plugin这种层级的源码出现,才做了适配
ISourceFileLocator iSourceFileLocator = getSourceFileLocatorsUnderThis(
codeCoverageFilesAndFoldersDTO.getSourceDirectory());
iReportVisitor.visitBundle(bundleCoverage, iSourceFileLocator);
iReportVisitor.visitEnd();
}

if (!everCreatedReport) {
throw new BusinessValidationException("从未生成报告,检查下工程是否未编译或者是否都是空工程");
}
}

private static ISourceFileLocator getSourceFileLocatorsUnderThis(
File topLevelSourceFileFolder) {
MultiSourceFileLocator iSourceFileLocator = new MultiSourceFileLocator(
4);

//这里是获取当前给出的目录以及其下面的子目录中所包含的所有java文件
//实现方式其实就是递归遍历文件夹,并过滤出来java文件,写法比较简单就不贴了,自行实现即可
List<File> sourceFileFolders = getSourceFileFoldersUnderThis(
topLevelSourceFileFolder);

for (File eachSourceFileFolder : sourceFileFolders) {
iSourceFileLocator
.add(new DirectorySourceFileLocator(eachSourceFileFolder,
GlobalDefination.CHAR_SET_DEFAULT, 4));
}
return iSourceFileLocator;
}

如果确实需要有些实现的源码,可以联系我或者从github上获取。
代码示例

备注:
这里关于Jacoco的一部分代码直接引用了AngryTester的代码,如果涉及到侵权请联系我,不过当前是为了个人使用,并不涉及商业,还望见谅~~~
关于server部分的,则大部分是我自己练习的代码,可以随意拿去用~~~
这个小工具只是为了给测试内部使用,其实并不具备完整项目的实力,所以代码和性能不一定很好,但我尽量按照阿里的规范来编写的代码,使其规范。

AngryTesterJacoco的代码-org.jacoco.core.diff.DiffAST.java

这是代码比对源码,

public static List<MethodInfo> diffDir(final String ntag,
final String otag) {// src1是整个工程中有变更的文件,src2是历史版本全量文件,都是相对路径,例如在当前工作空间下生成tag1和tag2
final String pwd = new File(System.getProperty("user.dir"))
.getAbsolutePath();// 同级目录
final String parent = new File(System.getProperty("user.dir")).getParent();
final String tag1Path = pwd;
final String tag2Path = parent + SEPARATOR + otag;
final List<File> files1 = getFileList(tag1Path);
for (final File f : files1) {
// 非普通类不处理
if (!ASTGeneratror.isTypeDeclaration(f.getAbsolutePath())) {
continue;
}
//实现方法在这里,主要是做了路径的替换
final File f2 = new File(
tag2Path + f.getAbsolutePath().replace(tag1Path, ""));
diffFile(f.toString(), f2.toString());
}
return methodInfos;
}

/**
* @param baseDir 与当前项目空间同级的历史版本代码路径
* @return
*/

public static List<MethodInfo> diffBaseDir(final String baseDir) {
final String pwd = new File(System.getProperty("user.dir"))
.getAbsolutePath();// 同级目录
final String parent = new File(System.getProperty("user.dir")).getParent();
final String tag1Path = pwd;
final String tag2Path = parent + SEPARATOR + baseDir;
final List<File> files1 = getFileList(tag1Path);
for (final File f : files1) {
// 非普通类不处理
if (!ASTGeneratror.isTypeDeclaration(f.getAbsolutePath())) {
continue;
}
final File f2 = new File(
tag2Path + f.getAbsolutePath().replace(tag1Path, ""));
diffFile(f.toString(), f2.toString());
}
return methodInfos;
}

/**
* 对比文件
*
* @param nfile
* @param ofile
* @return
*/

public static List<MethodInfo> diffFile(final String nfile,
final String ofile) {
final MethodDeclaration[] methods1 = ASTGeneratror.getMethods(nfile);
if (!new File(ofile).exists()) {
for (final MethodDeclaration method : methods1) {
final MethodInfo methodInfo = methodToMethodInfo(nfile, method);
methodInfos.add(methodInfo);
}
} else {
final MethodDeclaration[] methods2 = ASTGeneratror
.getMethods(ofile);
final Map<String, MethodDeclaration> methodsMap = new HashMap<String, MethodDeclaration>();
for (int i = 0; i < methods2.length; i++) {
methodsMap.put(
methods2[i].getName().toString()
+ methods2[i].parameters().toString(),
methods2[i]);
}
for (final MethodDeclaration method : methods1) {
// 如果方法名是新增的,则直接将方法加入List
if (!isMethodExist(method, methodsMap)) {
final MethodInfo methodInfo = methodToMethodInfo(nfile,
method);
methodInfos.add(methodInfo);
} else {
// 如果两个版本都有这个方法,则根据MD5判断方法是否一致
if (!isMethodTheSame(method,
methodsMap.get(method.getName().toString()
+ method.parameters().toString()))) {
final MethodInfo methodInfo = methodToMethodInfo(nfile,
method);
methodInfos.add(methodInfo);
}
}
}
}
return methodInfos;
}

public static String MD5Encode(String s) {
String MD5String = "";
try {
MessageDigest md5 = MessageDigest.getInstance("MD5");
BASE64Encoder base64en = new BASE64Encoder();
MD5String = base64en.encode(md5.digest(s.getBytes("utf-8")));
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return MD5String;
}


/**
* 判斷方法是否一致
*
* @param method1
* @param method2
* @return
*/

public static boolean isMethodTheSame(final MethodDeclaration method1,
final MethodDeclaration method2) {
if (MD5Encode(method1.toString())
.equals(MD5Encode(method2.toString()))) {
return true;
}
return false;
}

上面最后一个方法就是拿方法的详细信息来做md5的比对,所以这也就有了评论区的那个方法误判变更的来由。
不过这属于历史遗留问题,并不能算大事,想办法规避即可。

共收到 27 条回复 时间 点赞

楼主肯定把增量覆盖率的私货藏起来了🔜

请来第三发,谢谢😄

增量来一发

--- 统一回复下一楼和三楼:
@link1220 @dancingcat_
其实增量的我觉得目前粗粒度的来说,AngryTester做的已经差不多可以了,精细到方法。
那再有详细一点的可以用gitdiff,做更精细化的检视,精确到某一行,这个看个人就行了。
但是精确到方法,用MD5来做精准化判定是否变更,可能会有一些问题存在,比如:

精细到方法粒度,可能小的变化就认为了变更。
像方法头部的注释,方法内部的局部变量优化,都可能带来方法的变更。

所以可以自行来决定如何判定方法变更,实现更细粒度的判别,不至于误判,可能你加了一个@param的注释,这个方法就进到变更覆盖率统计里了。
这个场景在我们这里遇到过,有人反馈说这个代码一点没有变,为啥统计成变更了,我分析过后就是这个原因。

AngryTester的原库在这里(可能发现我经常提到这个人,因为我很大一部分灵感与这个大兄弟,站在了他的肩膀上,哈哈,很感谢他)
地址: AngryTester/jacoco

顶,说的很详细透彻👏

simple 将本帖设为了精华贴 24 Sep 14:52

加精理由:不加精说不过去

simple 回复

哇哦,首精唉。谢谢大佬😃

大佬。学习了

楼主再多分享点干货

陈老师白闹,肚子空空哪有干货,都是坏水😌

陈老师多分享下你的摄影技术

也得益于今年上半年一直负责整个公司不同类型的项目的覆盖率统计技术的适配,对不同形式的项目均有一定的了解,在此记录一下,也不让千疮百孔的自己浪费掉这半年的精力,如果说可以帮到别人一星半点,那这篇文章就算是造福了。

感谢楼主分享~~~超级棒~

咨询楼主一个问题:因为线下测试的时候会出现多次发布,怎么把这多次发布的覆盖度结果进行合并?原生的jacoco合并或追加都是在源码未发生变化的时候才可以,期待楼主解惑!
感谢楼主分享!

tkspeking 回复

就本质上来说,覆盖率报告的生成,只需要两个必须的东西,一个是class文件,一个是针对这些class产生的执行信息(就是exec文件)。
当然了源码起到很重要的作用就是可以让你在报告里面来回跳转查看代码详情,如果没有源码,则没办法点进去方法。
综上来说,那也可以说成一个完善的覆盖率报告,可以有源码、class文件、和对应的exec文件即可。

回到你的问题,针对你的变更,结合到这三个东西那就可以有以下合并的结论:

  1. 首先,最先想到的就是多次发布产生的exec合并,当然这是jacoco原本就支持的,或者说是exec文件本身的特性-支持合并
  2. 第二,基于第一步,当然可以合并多次发布的报告,但也会有一些风险
  3. 第三,风险1,就是exec虽然可以合并,但是每次发布的时候,class文件的变更,可能会导致同一个class在不同阶段中会有不同的形态,那会不会有覆盖率数据不准的问题,这个有待讨论
  4. 第四,风险2,多次发布,因为中间有停顿时间,或者覆盖率数据的收集有没有一定的时间间隔,会不会因为多次发布导致有些执行步骤的覆盖率信息没有收集到,这也在一定程度上可能会导致覆盖率数据的不准,甚至丢失
  5. 第五,风险3,多次发布,有没有可能在写入exec的时候,会导致数据写到一半的时候被停机,导致exec文件本身变得不完整,这有可能导致覆盖率数据不准,甚至有可能导致文件不完整,让jacoco没法读取这些信息、

综上步骤,可以简单得出,你的问题,在一定程度上可以做到,但最终能到什么地步,取决于你对这几个风险的把控,要么你可以忽略,要么你可以深度解析一下源码,看看有没有什么解决方案或者规避方案。

当然,这也是我个人现阶段对它的认知,猜出来的解决方案,也可能不对,但总归是个思路~希望可以帮到您

多谢楼主回答问题!
现在从实际验证来看(可以确定楼主的第四、第五不存在,应用仅我一个人使用):

  1. 第一次源码文件有一个方法,测试使其已覆盖,并拉取exec。
  2. 编辑源码文件,新增一个方法,测试使其覆盖,并拉取exec。
  3. 合并两次结果文件,生成报告,此时仅能看到新增的方法被覆盖

我再想想其他办法,从报告生成方面想想办法,再次感谢

楼主 最近遇到一个问题,就是我们是用duubo分布式服务,其中was服务是部署在tomcat里面,我可以修改tomcat启动参数监控,但das服务是部署在其他机器里面,是一个服务进程(java -cp "./bin:../libs/*:." -Ddubbo.shutdown.hook=true "com.yjhealth.pcloud.dat
)这样启动的服务,好像监控不到。这个要怎么处理,我是Jenkins大包部署的
然后就是启动参数修改 不用jacocoagent安装路径吗

lyz362502 回复

没太明白你的问题出在哪儿,所以不能准确回答你的问题。。
但想说一点,就jacoco本身而言,如果你的服务启动的时候,不经过jacocoagent的代理程序,那么都是检测不到的。
当然了,即使你经过了jacocoagent的代理,也有可能是出不来的,出不来的原因就有很多了。

你可以再详细描述下问题。

lyz362502 回复

嗯嗯,希望可以帮到你了。
等你研究完了可分享出来供大家食用,哈哈

@风雨夜阑听 楼主你好,我们遇到这个问题“do no match with execution data. For report generation the same class files must be used as at runtime”,“Execution data for class com/xxxx does not match”。我也同步了发布的编译的JDk,但是还是没有解决。

Gavin 回复

这个看起来就是你的构建jdk和你运行时的jdk不一致,最好是要保持一致。

在哪儿构建,就在哪儿运行,如果不能做到这样,那就干脆生成报告的时候,class文件、源代码、和exec文件直接从运行时的机器上复制。

你可以看下我上面的回复,生成报告,要想准确,就得源码、class文件、exec最好是同一批构建的产物,当然不排除有时候分开也可以,但最好是要保证同一批~~

希望可以帮到你😌

你可以了解下class文件的构造,开头有一串东西(好像叫魔数啥的),是用来区分jdk相关的东西的,差别过大,可能就会被认为不匹配了。

@风雨夜阑听 多谢回复,构建jdk和运行时的jdk我都检查了,没有问题。现在编译打包是在jenkins的服务器,JDK版本也是一致的。方便加一下微信吗?18520825002

楼主你好,首先非常感谢你的分享~
想问下,如果只想要知道代码变更的影响范围,来辅助测试的测试范围,可以通过插桩来实现吗?求指点

Janet 回复

谢阅。
影响范围这个话题是比较大的,也很难一句两句说清楚。不过按照我当前能力的理解,对测试来说,最粗粒度的可以做到检测出变更的方法或者类,出现在哪些请求的调用链上,也就是说被哪些类的哪些方法调用了,这才是测试应该关注的。如果其他的请求链路,没有出现变更方法的调用,那从一定程度上一定范围内是安全的。所以你可以按照这个思路来想想可以做点什么,至于用到的技术和一些,我有些建议,但我没有完全试验过,所以可以看看能否参考:

  • 1.你可以通过解析新版本的字节码,把整个应用的调用链路给解析出来,当然了这个调用链路多数情况下会有多颗树组成,对整个应用来说,最终会是一个由多个调用树组成的森林。这个用ASM字节码解析,应该可以做到,但数据量可能比较大,怎么存储怎么展示,可能需要细想,在java层可以想办法以hashmap来存。
  • 2. 上面的森林拿到之后,可以根据新代码的源码和基线代码的源码,来解析变更类和方法,看你怎么存储,差不多可以拿到一个变更方法的列表(当然也会有该方法的类信息存在)。
  • 3. 结合上面的调用关系森林和变更方法的列表,可以初步判断在调用森林里,哪些树中的哪些路径是包含了变更方法的,针对这些请求链路,可以看看怎么补充测试。整个调用关系的存储,之前请教过思寒,可以尝试用neo4j或者kibana来处理,或者你有其他的思路也可以尝试下

当然了,以上只是我的设想,我之前也做过尝试,只简单做到了1和2,至于3嘛,存储和展示都需要一定的技术和资源,我并没有深入研究下去,当然可能会有一定难度,但我之前想到的也就是这个思路。我这想的都比较浅,不知道会不会对你有所帮助。

感谢楼主这么认真的回复,我也有试想过尝试用变更代码的调用关系去判断影响范围,但觉得这样是远远不够的,可能会有更多更深的影响范围,有点累觉不爱了😂

问题描述:项目有4个应用包,一个放在181服务器tomcat里面,前端代码也是这个tomcat,其他三个应用是放在182服务器,启动是用java -jar方式启动, 项目是用dubbo框架。 现在有两个问题:1. 181我修改了tomcat配置,加了JavaAgent监控,可以拿到覆盖率,但182上面三个应用没有tomcat 怎么拿到覆盖率? 我参试了在 java -jar 启动参数里面增加JavaAgent,开启了三个监控端口,不知道是否可行? 2. 这4个应用的覆盖率报告如何合并然后显示在Jenkins里面?

lyz362502 回复

感谢提问,其实我对jacoco的理解也是有限的,很多原理性的东西或者更深入的东西我也不会😂
看完描述,我首先想到的是贤弟(妹)可能没有读完这个文章的内容,这个怪我文笔不好写的太啰嗦了,道个歉哈。
本身没有搞清楚你这四个工程是个怎样的结构,我姑且以为你这个四个工程属于父子工程(隶属一个git url,而不是项目间交互),以此为基准,下面就聊聊你提到的几个点:


问点1: 项目是用dubbo框架

答: 首先你提到dubbo框架让我心里一虚,因为我对dubbo的理解只停留在表面,所以我只能按照我理解的来说(当然可能会错,这个需要你识别一下哈。)
dubbo本身是基于接口进行的调用,所以你对dubbo的调用覆盖,势必要到provider端(因为实现都在provider端),一般来说,dubbo项目会专门提供一个client端的jar包或者GAV(通常是纯接口),供consumer端调用,这至少在我理解的我们公司项目是这样。就比如你的A B C D 四个项目,可能你对A项目发起的请求,它内部通过dubbo调用了D(provider)项目的接口,那么相当于你对A项目进行测试,那么这一部分测试一方面覆盖率你A项目的部分代码,另一部分也覆盖到了D项目的代码实现,虽然你在A项目中有用到一部分D项目的代码,但是可能它提供的都是接口,你(应该)是没办法统计到这些D项目中的具体实现。这个要计划清楚


问点2: 但182上面三个应用没有tomcat 怎么拿到覆盖率

答: jacoco的本质工作机制,并不依赖于哪个web 容器,它需要的是一个动态的或者静态字节码插桩,就是你这个应用或者java代码不管将来怎么运行,这并不重要,重要的是要给它插桩的时机(也就是class的生成和运行)。这个点理清楚了,你下一个问题也就不是问题了。所以你这个问题就追溯到上面提到工作机制的java -jar,如果你最后没有收到覆盖率数据,那我猜可能是启动命令写错了或者端口没有规划好,或者去参考下文章说的那些覆盖率报告为0的场景,有没有命中。


问点3: 我参(尝?)试了在 java -jar 启动参数里面增加JavaAgent,开启了三个监控端口,不知道是否可行

答: 肯定是可行的 ,完全支持这种机制。


问点4: 这4个应用的覆盖率报告如何合并然后显示在Jenkins里面?

答前半句(能否合并):

  • 肯定是OK的,具体去看下这个文章的主题(十二),覆盖率数据是可以合并的。

答后半句(能否在Jenkins上运行):

  • 我猜你想集成到jenkins上。这个我没法肯定回答,因为我暂时没有用过jenkins去统计这个覆盖率,我一直以为jenkins只支持单元测试的覆盖率统计(可能显得很没文化,孤陋寡闻了哈)。 这个建议先去了解下你希望用jenkins能做到什么地步,如果它支持自定义覆盖率数据(就是exec或者ec文件),那么结合源码和class文件目录,那你这个目标应该是没问题的,上面覆盖率数据合并之后丢给jenkins去统计就行了。 如果jenkins只是支持像执行单元测试那样才能统计,那我暂时没有好的建议,你或许可以发帖求助下社区。

想了解一下项目部署完生成的class文件以什么方式去获取的~

需要 Sign In 后方可回复, 如果你还没有账号请点击这里 Sign Up