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

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

[霍格沃兹测试学院](javascript:void(0):wink: 4月12日
作者yelanting

本文为霍格沃兹测试学院优秀学员关于 Jacoco 的小结和踩坑记录。测试开发进阶学习,文末加群。

一、概述

测试覆盖率是老生常谈的话题。因为我测试理论基础不是很好,这里就不提需求、覆盖率等内容,直奔主题,本文主要指 Java 后端的测试覆盖率。

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

本来 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 和报告生成,大家如果有兴趣可以研究一下,这里不赘述。

三、项目梳理

由于我所在的公司是个老牌公司,项目杂乱无章,技术五花八门。至今仍然有跑在 JDK6 上的。所以我个人认为,影响 Jacoco 使用过程的,可能存在于以下几点。

  1. JDK 版本。

我司现有 JDK6、7、8,但实际上 jdk6 是个分水岭,其他的都基本可以用 JDK8 来适配。

  1. 构建工具。

我司现有 Maven 构建、ANT 构建,想必有的公司还有用 Gradle 的。

  1. 部署方式。

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

5.1 这句话的解释

  1. -javaagent

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

  1. /home/admin/Jacoco/Jacocoagent.jar

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

  1. includes=*

这个代表了,启动时需要进行字节码插桩的包过滤, * 代表所有的 class 文件加载都需要进行插桩。

假如你们公司内部代码都有相同的包前缀 :com.mycompany <你可以写成:

includes=com.mycompany.*
  1. output=TCPserver

这个地方不用改动,代表以 TCPserver 方式启动应用并进行插桩。

  1. port=2014

这是 Jacoco 开启的 TCPserver 的端口,请注意这个端口不能被占用。

  1. 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。

  1. 总结:

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

-javaagent:(/home/admin/Jacoco/Jacocoagent.jar)=includes=(*),output=TCPserver,port=(2014),address=(192.168.110.1)

比如我可以改成其他的:

-javaagent:/home/admin/Jacoco_new/Jacocoagent.jar=includes=com.company.*,output=TCPserver,port=2019,address=192.168.110.111

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

5.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 服务中。

5.3 Maven 命令的插件启动方式

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

mvn clean installmvn tomcat7:run -Dport=xxx

或者还有

mvn clean installmvn 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 本是一家子吗,我猜底层可能差异不是很大),我不曾做尝试,有兴趣的可以尝试下。

5.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 的代理,最终可以按照上面的方式判断端口是否启动。

5.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 方法的。

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

5.6 启动之后

启动之后,就进行测试就可以了,跟平常不注入 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 文件不一致,这会导致覆盖率数据不准确)。
  3. Dump 覆盖率执行数据。
  4. 根据 dump 出来的执行数据 exec 文件,以及刚才对最新代码的编译出来的字节码 class 文件和 src 中的源代码进行报告生成。
  5. 导出覆盖率数据报告(一般是在 Linux 中执行,查看时需要到自己的 Windows 或者 Mac 上查看)。

以上五个步骤,对获取覆盖率数据缺一不可,不然无法出增量覆盖率数据。

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

  1. 部署

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

  1. 基线代码、和最新代码

可以用 jgit 和 svnkit 这两个工具进行代码下载和克隆。

  1. dump.

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

  1. report

用 Jacoco 的 API 做。

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

  1. 导出报告

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

八、一些坑

  1. Ant 构建

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

debug="true"

这个配置,不然 Jacoco 是无法注入的,有的时候 ant 项目生成的数据为 0,就可以去排查下这里。

比如我司配置了两个,一个 compileDebug, 一个 compile,在 compileDebug 阶段打开了 debug 的开关:

  1. 关于负载均衡

有时候可能一个服务会有负载均衡出现,那么可以配置不同端口,如果在不同服务器上,那么 IP 和端口都可以不同。

这时候,在 dump 数据的时候,只需要循环几个 ip:port(至于你想怎么传,那就是代码层面事情了)去 dump,保存到同一个文件中就行了。

  1. 做平台时-项目代码无法独立编译

这个看怎么解决了,如果非要自己编译,那就让开发适配到可以独立编译。

我这里是提供了 sftp 下载的方式,你告诉我你的代码在哪个服务器的那个路径,提供给我用户名密码,我用 Java 的方式去 sftp 下载到平台部署的机器上。这样可以解决现编译的不匹配问题,也可以解决无法独立编译的问题。

但是有几个遗留问题,你如何判定是不是要重新下载,你也会担心 sftp 下载下来的 class 和 java 代码跟测试机上的是否不一样。这个要看个人取舍,理论上 TCP 进行下载还是安全的。

  1. 如果注入 Jacoco 的配置之后,端口确实没有起来或者 dump 的时候,TCPserver 连接不上

可能原因有几种。

  • TCP 端口确实没起来,这个在部署测试服务器的文档里有说明,部署后需要查看下是否真的起来。
  • TCP 端口确实起来了,netstat 查看的时候也是显示正确。

这里还有两种可能。

  • 确保 javaagent 参数中的 address 写的是真实 ip 地址,而不是 127.0.0.1 或者 localhost。
  • 防火墙。防火墙开启的时候,阻碍了外部 ip 连接的进入,请关闭防火墙,或者配置防火墙策略。
  1. 覆盖率数据会丢失或者不准确

举个栗子。

8:30 的时候,执行了测试,生成了一次报告。此时 8.30 之前的数据,肯定是存在的。

9:00 的时候,重新部署了,之前没有再次捞取执行信息,那重启之后,8.30-9.00 之间的执行记录可能很大概率丢失。所以,务必小心。

  1. 怎么确保报告准确,且尽量减少丢失?

及时保存,及时收集,可以采用定时任务的方式。

  1. 应用的突然重启和服务器的断电状况怎么处理?

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

  1. 造成覆盖率报告数据不准确的原因有哪些?

最最最最底层的原因 —— 部署时的 class 文件和生成报告的时候,用的 class 文件不一致。有以下几种情况:

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

九、补充一些 API 相关的代码

覆盖率数据的获取

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 上获取。

代码示例 GitHub 地址:

https://github.com/yelanting/ManagerPlatformAdministrator.git

备注:
这里关于 Jacoco 的一部分代码直接引用了 AngryTester 项目的代码,
AngryTester (AngryTester) · TesterHome
如果涉及到侵权请联系我,目前并未作商用;关于 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 的比对,所以这也就有了评论区的那个方法误判变更的来由。
不过这属于历史遗留问题,并不能算大事,想办法规避即可。

十、总结

以上,本文是对上一篇文章 Java 端覆盖率探索 的一个细化,文中总结的内容,得益于站在巨人的肩膀上,参考了以下资料和课程。这里推荐大家学习,也期待一起探讨。

References

[1] 有赞测试|浅谈代码覆盖率: 浅谈代码覆盖率 · TesterHome
[2] 腾讯TMQ|JAVA 代码覆盖率工具 Jacoco-踩坑篇: [腾讯 TMQ] JAVA 代码覆盖率工具 JaCoCo-踩坑篇 · TesterHome
[3] 腾讯TMQ|JAVA 代码覆盖率工具 Jacoco-实践篇: [腾讯 TMQ] JAVA 代码覆盖率工具 JaCoCo-实践篇 · TesterHome
[4] 针对手工测试的代码变更覆盖率实现之路: 代码变更覆盖率平台 - 针对手工测试的代码变更覆盖率实现之路 · TesterHome