實戰|Java 測試覆蓋率 Jacoco 插樁的不同形式總結和踩坑記錄


本文為霍格沃茲測試學院優秀學員關於 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 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
本是一家子嗎,我猜底層可能差異不是很大),我不曾做嘗試,有興趣的可以嘗試下。

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 連接的進入,請關閉防火牆,或者配置防火牆策略。

    5. 覆蓋率數據會丟失或者不准確

舉個栗子。

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 項目的代碼,
https://testerhome.com/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 的比對,所以這也就有了評論區的那個方法誤判變更的來由。
不過這屬於歷史遺留問題,並不能算大事,想辦法規避即可。

十、總結

以上,本文是對上一篇文章 ** Java
端覆蓋率探索**
的一個細化,文中總結的內容,得益於站在巨人的肩膀上,參考了以下資料和課程。這里推薦大家學習,也期待一起探討。

References

[1] 有贊測試|淺談代碼覆蓋率: https://testerhome.com/articles/16981
[2] 騰訊TMQ|JAVA 代碼覆蓋率工具 Jacoco-踩坑篇: https://testerhome.com/topics/5876
[3] 騰訊TMQ|JAVA 代碼覆蓋率工具 Jacoco-實踐篇: https://testerhome.com/topics/5823
[4] 針對手工測試的代碼變更覆蓋率實現之路: https://testerhome.com/topics/19077

** _
來霍格沃茲測試開發學社,學習更多軟件測試與測試開發的進階技術,知識點涵蓋web自動化測試 app自動化測試、接口自動化測試、測試框架、性能測試、安全測試、持續集成/持續交付/DevOps,測試左移、測試右移、精准測試、測試平台開發、測試管理等內容,課程技術涵蓋bash、pytest、junit、selenium、appium、postman、requests、httprunner、jmeter、jenkins、docker、k8s、elk、sonarqube、jacoco、jvm-sandbox等相關技術,全面提升測試開發工程師的技術實力
QQ交流群:484590337
公眾號 TestingStudio
點擊獲取更多信息


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM