tags: Java
前陣子使用 Jacoco 進(jìn)行代碼覆蓋率測試,由于項(xiàng)目特殊遇到了不少坑,網(wǎng)上搜到的教程感覺也不夠全面,特此記錄。
所用到的工具軟件的版本信息如下
- Jacoco 版本:0.8.0
- Eclemma 版本:3.0.0
- Eclipse 版本:4.3
- JDK 版本:1.8
- ANT 版本:1.9
1. 工具介紹
JaCoCo,即 Java Code Coverage,是一款開源的 Java 代碼覆蓋率統(tǒng)計(jì)工具。支持 Ant 、Maven、Gradle 等構(gòu)建工具,支持 Jenkins、Sonar 等持續(xù)集成工具,支持 Java Agent 技術(shù)遠(yuǎn)程監(jiān)控 Java 程序運(yùn)行情況,支持Eclipse、IDEA等IDE,提供HTML,CSV 等格式的報(bào)表導(dǎo)出,輕量級實(shí)現(xiàn),對外部庫和系統(tǒng)資源的依賴性小,性能開銷小。
JaCoCo 支持從 JDK1.0 版本到 JDK1.8 版本 的 Java 類文件。但是,JaCoCo 工具所需的JRE 版本最小為 1.5。另外,1.6及以上版本的測試中的類文件必須包含有效的堆棧映射幀。

2. 入門使用
本文將以 tcpserver 模式遠(yuǎn)程獲取應(yīng)用覆蓋率,通過 Ant 腳本執(zhí)行相關(guān)命令,在 Eclipse 上查看源碼覆蓋率情況。
2.1 配置部署
先從官網(wǎng)獲取 Jacoco 的壓縮包, 將其上傳到你要進(jìn)行覆蓋率檢測的應(yīng)用所在的服務(wù)器上。在解壓后的 lib 目錄下找到 jacocoagent ,將其路徑添加到 JAVA_OPTS 環(huán)境變量中(如果項(xiàng)目中用到了 Tomcat,也可以直接將其添加到 CATALINA_OPTS 的環(huán)境變量中,JAVA_OPTS 只是更通用而已)。
如果是 Windows 系統(tǒng),將以下內(nèi)容追加到 JAVA_OPTS 環(huán)境變量。
-javaagent:D:\jacoco-0.7.9\lib\jacocoagent.jar=includes=*,address=10.1.231.168,port=6300,output=tcpserver,append=true;%JAVA_OPTS%
如果是 Linux 系統(tǒng),可以直接編輯 .bash_profile
export JACOCO="-javaagent:/$your_path/jacocoagent.jar=includes=com.grgbanking.*,output=tcpserver,address=11.111.1.11,port=6300,append=true"
export JAVA_OPTS="$JACOCO":"$JAVA_OPTS"
其中常用選項(xiàng)的含義如下
- javaagent: 指定 jacocoagent 的路徑
- includes: 表示只對指定包下的類進(jìn)行覆蓋率注入分析,默認(rèn)為 *,示例中只分析 com.test 包的類。
- output: 表示覆蓋率的輸出方式。在 tcpserver 模式下,Jacoco 會在客戶端執(zhí)行 dump 操作時將目前收集獲取到的覆蓋率數(shù)據(jù)統(tǒng)一寫到指定的ip和端口。在 file 模式下,Jacoco 只會在JVM 終止的時候才將收集到的覆蓋率數(shù)據(jù)寫入到指定的 exec 文件里去。注意,不管是任何模式,應(yīng)用運(yùn)行過程中的臨時覆蓋率數(shù)據(jù)都是保存在服務(wù)端的內(nèi)存中的,因此對于 tcpserver 模式來說,如果 JVM 不小心終止了,那么在這個覆蓋率統(tǒng)計(jì)周期內(nèi)的覆蓋率數(shù)據(jù)都會丟失。還有一個 tcpclient 模式則是以客戶端的形式啟動,由于目前沒有這個使用場景,這里不過多討論。
- address: 只限 tcpserver 與 tcpclient 使用,表示監(jiān)聽的應(yīng)用服務(wù)器IP地址或主機(jī)名。可根據(jù)實(shí)際情況自由選擇。
- port: 只限 tcpserver 與 tcpclient 使用,表示監(jiān)聽的應(yīng)用服務(wù)器的端口號,一般用默認(rèn)6300即可。
- append: 表示覆蓋率數(shù)據(jù)的追加方式,默認(rèn)為true??蛻舳嗽趫?zhí)行 dump 操作時,如果該 exec 覆蓋率文件已存在,那么該輪的覆蓋率數(shù)據(jù)會直接在文本末尾進(jìn)行追加,因此會導(dǎo)致覆蓋率數(shù)據(jù)文件越來越大。如果改為false,則客戶端執(zhí)行 dump 操作時會直接清空原覆蓋率文件的內(nèi)容,保證該覆蓋率文件只有該輪的覆蓋率數(shù)據(jù)。
修改好以后啟動 Java 應(yīng)用,讀取 JAVA_OPTS 環(huán)境變量的信息,Jacoco 被加載進(jìn)。檢查下6300端口如果已監(jiān)聽,說明服務(wù)端 Jacoco 啟動成功。
2.2 數(shù)據(jù)獲取
在正常運(yùn)行過程中,服務(wù)器端的 Jacoco 只是將獲取的覆蓋率數(shù)據(jù)保存到內(nèi)存中,我們還需要在客戶端上進(jìn)行操作才能將覆蓋率數(shù)據(jù) dump 到客戶端。
Jacoco 為我們提供了 Ant、Maven、CLI 等多種方式進(jìn)行操作,其中 CLI 方式唯一的用途就是可以用來執(zhí)行 execinfo 命令,這個命令是 Ant 與 Maven 所沒有的,它可以將 exec 簡單轉(zhuǎn)成文本格式方便你查看每個類的覆蓋率百分比。Maven 與 Ant 大同小異,由于項(xiàng)目中使用 Ant 進(jìn)行構(gòu)建,下文中將以 Ant 為例講解。
在使用 Ant 腳本獲取覆蓋率之前,我們需要先去官網(wǎng)下載好 Ant,注意安裝過程中要手動勾選 “添加到環(huán)境變量” 的相關(guān)選項(xiàng),省得以后要自己添加。
安裝好以后打開 cmd 輸入ant -version,如果能顯示相關(guān)的版本信息例如 “ Apache Ant(TM) version 1.9.11 compiled on March 23 2018 ”,則說明 Ant 安裝成功。
雖然官方也提供了 Ant腳本,但較為簡單,部分內(nèi)容沒有說明,因此文末會附上我在項(xiàng)目中使用的完整腳本。
2.3 統(tǒng)計(jì)分析
對于不熟悉 Java 或者對項(xiàng)目目錄結(jié)構(gòu)不了解的朋友,往往會由于源碼和字節(jié)碼不匹配或者路徑錯誤導(dǎo)致在結(jié)合源碼查看覆蓋率時反復(fù)折騰,跑半天不知道生成的 exec 到底有沒有統(tǒng)計(jì)到。這時候我們可以使用 CLI 中的 execinfo 命令,簡單查看下 exec 文件中的覆蓋率是否為0。
java -jar D:\jacococli.jar execinfo E:\jacoco\igaps1008.exec
這種方式只能查看 exec 文件的概況,要想結(jié)合源碼查看詳細(xì)的覆蓋率使用情況,我們還是需要花點(diǎn)時間,配置好源碼和字節(jié)碼,這樣才能在 IDE 中查看源碼覆蓋率。
首先需要在 Eclipse 中安裝 Eclemma 插件,你可以使用 Eclipse 的 MarketPlace 在線安裝,

也可以下載離線安裝包 eclemma-3.0.0.zip,分別將里面的 features 和 plugins 文件夾里的 jar 包拷貝到 Eclipse 對應(yīng)的文件夾中,重啟 Eclipse 后如果有顯示覆蓋率圖標(biāo)或視圖就說明安裝成功了。

接著下載項(xiàng)目源碼并將項(xiàng)目導(dǎo)入到 Eclipse 中

注意導(dǎo)入前取消 Eclipse 中的自動編譯(即 Project - build automatically ), 然后拷貝服務(wù)器上的字節(jié)碼文件到這個項(xiàng)目的編譯輸出文件夾中。例如這個項(xiàng)目的編譯輸出文件夾為根目錄下bin目錄,那么就把字節(jié)碼文件都拷貝到這個目錄下,到這里我們的項(xiàng)目就準(zhǔn)備好了。
在 Eclipse 控制臺 Coverage 視圖窗口的空白位置,右鍵--Import Session,在 Coverage Session 窗口,選擇第三個代理模式,Agent address 填寫需要監(jiān)控覆蓋率的遠(yuǎn)程服務(wù)器地址。點(diǎn)擊下一步后,選擇需要查看覆蓋率的源碼,一般不需要勾選include binary libraries,再點(diǎn)擊Finish即可查看覆蓋率。


3. 注意事項(xiàng)
官方文檔才是王道
強(qiáng)烈建議在使用 Jacoco 之前閱讀官方文檔,雖然是英文,但是內(nèi)容也很簡單,花1個小時大概瀏覽下能對 Jacoco 有個系統(tǒng)性的了解。這里對 Jacoco 的官方部分 FAQ 進(jìn)行了簡單翻譯,同時加入了部分自己在使用過程中遇到的坑。源代碼沒有覆蓋率高亮問題
必須確保使用調(diào)試信息編譯類文件以包含行號,如果使用 Ant 編譯腳本,則需要檢查腳本中 javac 相關(guān)部分是否沒有設(shè)置 debug=true。
源文件必須在報(bào)表生成時正確提供。即指定的源文件夾必須是定義Java包的文件夾的直接父級。覆蓋率統(tǒng)計(jì)偏差
既然 Jacoco 是依據(jù) class 文件進(jìn)行覆蓋率的統(tǒng)計(jì),那么在用 EclEmma 合并會話數(shù)據(jù)時,應(yīng)該保證多個會話的所測試 class 文件字節(jié)碼內(nèi)容是相同的,即多次測試過程中被測試 Java 類的源文件沒有被修改并且重新編譯過。所以在 Eclipse 中,測試用例開始執(zhí)行執(zhí)行后,應(yīng)該保證 Testee 源文件不被改動。如果修改了被測試源文件并保存( Eclipse 會自動重新編譯),請將之前的所有測試用例重新以 Coverage As 模式執(zhí)行一般,否則合并后的覆蓋率測試數(shù)據(jù)會有誤差。
另外,由于 JaCoCo 分析統(tǒng)計(jì)的是編譯后的 class 文件中字節(jié)碼指令的執(zhí)行情況。例如某源文件中有一個靜態(tài)的方法 someMethod,但是在編譯時 Javac 會自動為我們的類生成一個構(gòu)造方法(本例中沒有提供非空的構(gòu)造方法),所以這個類同時有 someMethod 和一個構(gòu)造方法。由于在執(zhí)行靜態(tài)方法過程中沒有調(diào)用到構(gòu)造函數(shù),所以會顯示覆蓋率不是100%Android應(yīng)用使用覆蓋率
由于Android不能通過JVM停止后自動dump覆蓋率數(shù)據(jù),因此當(dāng)Android應(yīng)用進(jìn)程不存在或停止的時候,覆蓋率數(shù)據(jù)不會生成。要想獲得Android應(yīng)用的覆蓋率,,必須使用離線插樁模式進(jìn)行覆蓋率分析多源文件目錄
Ant 腳本執(zhí)行起來很方便,但如果要執(zhí)行 report 命令則需要注意,如果該應(yīng)用編譯后的class 或 jar 分別在幾個不同的目錄下,那么就需要分別在 Ant 腳本中指定 group,同時每個 group 也都要指定源文件 sourcefiles 以及 編譯后的類文件 classfiles。同樣的,如果
項(xiàng)目的源碼存放目錄也沒有統(tǒng)一的入口,那也需要在一個 sourcefiles 中指定多個 fileset,就如本腳本中分別指定了<fileset dir="${JacocoClassPath}/lib/core"/>和<fileset dir="${JacocoClassPath}/project"/>這2個 classfiles 一樣。Eclipse中導(dǎo)入覆蓋率數(shù)據(jù)時出錯
如果在Eclipse的Eclemma插件中導(dǎo)入exec文件時彈窗,提示 “Error while loading coverage session (code 5001).”
一般是因?yàn)閑clipse 中導(dǎo)入的項(xiàng)目編譯輸出文件夾目錄結(jié)構(gòu)不合法導(dǎo)致,同時 class 文件必須是從服務(wù)器中獲取的,不能使用 eclipse 自己的編譯器編譯的 class。由于 Eclipse 默認(rèn)會開啟自動編譯,所以哪怕你沒有手動編譯,在你導(dǎo)入項(xiàng)目的時候 Eclipse 已經(jīng)幫你編譯了一次了。這里必須刪掉編譯后的 class 然后重新拷貝一份服務(wù)器上的 class 文件

4. 技術(shù)原理
運(yùn)行時分析 (Runtime Profilling) 技術(shù) 在 PureCoverage 中有使用,他就是通過 JVMTI 來監(jiān)聽 JVM 的相關(guān)事件進(jìn)行覆蓋率數(shù)據(jù)收集,而 Jacoco 則是使用字節(jié)碼注入(Byte Code Instrumentation)的方式,使用 ASM 庫在字節(jié)碼中插入 Probe 探針,通過統(tǒng)計(jì)運(yùn)行時探針的覆蓋情況來統(tǒng)計(jì)覆蓋率信息。

On-the-fly 模式:
JVM 中通過 javaagent 參數(shù)指定特定的 jar 文件啟動 Instrumentation 的代理程序,代理程序在通過 Class Loader 裝載一個 class 前判斷是否轉(zhuǎn)換修改 class文件,將統(tǒng)計(jì)代碼插入 class,測試覆蓋率分析可以在 JVM 執(zhí)行測試代碼的過程中完成。
Offline 模式:
在測試前先對文件進(jìn)行插樁,然后生成插過樁的 class 或 jar 包,測試插過樁的 class 和 jar 包后,會生成動態(tài)覆蓋信息到文件,最后統(tǒng)一對覆蓋信息進(jìn)行處理,并生成報(bào)告。
存在如下情況不適合 on-the-fly,需要采用 offline 提前對字節(jié)碼插樁:
- 運(yùn)行環(huán)境不支持 java agent。
- 部署環(huán)境不允許設(shè)置 JVM 參數(shù)。
- 字節(jié)碼需要被轉(zhuǎn)換成其他的虛擬機(jī)如 Android Dalvik VM。
- 動態(tài)修改字節(jié)碼過程中和其他 agent 沖突。
- 無法自定義用戶加載類。
5. Ant 腳本
<?xml version="1.0" encoding="UTF-8" ?>
<project default="report" basedir="." xmlns:jacoco="antlib:org.jacoco.ant">
<!-- 定義 Jacoco 相關(guān)變量和庫路徑 -->
<property name="JacocoIP" value="192.168.22.33"/>
<property name="JacocoPort" value="6300" />
<property name="JacocoExec" value="./jacoco/merge-0608.exec" />
<property name="JacocoReport" value="./jacoco/igaps-report.zip" />
<property name="JacocoSrcPath" value="."/>
<property name="JacocoClassPath" value="./igaps/apps"/>
<property name="Encoding" value="UTF-8"/>
<taskdef uri="antlib:org.jacoco.ant" resource="org/jacoco/ant/antlib.xml">
<classpath path="E:/jacoco/lib/jacocoant.jar"/>
</taskdef>
<!-- 1 獲取覆蓋率exec文件 -->
<target name="dump">
<jacoco:dump address="${JacocoIP}" port="${JacocoPort}" reset="false" append="true" destfile="${JacocoExec}" />
</target>
<!-- 2 合并exec文件 -->
<!-- 獲取指定目錄下的所有 exec 文件并將數(shù)據(jù)合并為一個exec -->
<target name="merge">
<jacoco:merge destfile="./jacoco/merge.exec">
<fileset dir="./jacoco/all" includes="*.exec" />
</jacoco:merge>
</target>
<!-- 3 生成覆蓋率報(bào)告 -->
<target name="report">
<jacoco:report>
<executiondata>
<file file="${JacocoExec}" />
</executiondata>
<structure name="JaCoCo Report">
<group name="Core">
<classfiles>
<fileset dir="${JacocoClassPath}/lib/core" />
</classfiles>
<sourcefiles encoding="${Encoding}">
<fileset dir="${JacocoSrcPath}/src/monitor/timeout"/>
<fileset dir="${JacocoSrcPath}/src/tools/utils"/>
<fileset dir="${JacocoSrcPath}/src/redis/link"/>
<fileset dir="${JacocoSrcPath}/src/redis/util"/>
<fileset dir="${JacocoSrcPath}/src/server/init"/>
<fileset dir="${JacocoSrcPath}/src/grgbpm/core"/>
<fileset dir="${JacocoSrcPath}/src/grgbpm/handler"/>
<fileset dir="${JacocoSrcPath}/src/server/core"/>
<fileset dir="${JacocoSrcPath}/src/server/backend"/>
<fileset dir="${JacocoSrcPath}/src/server/exception"/>
<fileset dir="${JacocoSrcPath}/src/server/audit"/>
<fileset dir="${JacocoSrcPath}/src/server/dao"/>
<fileset dir="${JacocoSrcPath}/src/server/log"/>
<fileset dir="${JacocoSrcPath}/src/server/reload"/>
<fileset dir="${JacocoSrcPath}/src/server/business"/>
<fileset dir="${JacocoSrcPath}/src/component/service/http"/>
<fileset dir="${JacocoSrcPath}/src/component/service/https"/>
<fileset dir="${JacocoSrcPath}/src/component/service/webservice"/>
<fileset dir="${JacocoSrcPath}/src/component/unpack/separativesign"/>
<fileset dir="${JacocoSrcPath}/src/component/pack/separativesign"/>
<fileset dir="${JacocoSrcPath}/src/component/unpack/struct"/>
<fileset dir="${JacocoSrcPath}/src/component/pack/iso8583"/>
<fileset dir="${JacocoSrcPath}/src/component/pack/struct"/>
<fileset dir="${JacocoSrcPath}/src/component/unpack/xml"/>
<fileset dir="${JacocoSrcPath}/src/component/pack/xml"/>
<fileset dir="${JacocoSrcPath}/src/component/unpack/iso8583"/>
<fileset dir="${JacocoSrcPath}/src/component/service/tcp"/>
<fileset dir="${JacocoSrcPath}/src/component/communicate/ftp"/>
<fileset dir="${JacocoSrcPath}/src/component/communicate/http"/>
<fileset dir="${JacocoSrcPath}/src/component/communicate/https"/>
<fileset dir="${JacocoSrcPath}/src/component/communicate/webservice"/>
<fileset dir="${JacocoSrcPath}/src/component/communicate/tcp"/>
<fileset dir="${JacocoSrcPath}/src/component/timeout"/>
<fileset dir="${JacocoSrcPath}/src/component/endflow"/>
<fileset dir="${JacocoSrcPath}/src/component/logic"/>
<fileset dir="${JacocoSrcPath}/src/component/encryptor"/>
<fileset dir="${JacocoSrcPath}/src/component/judge"/>
<fileset dir="${JacocoSrcPath}/src/component/option"/>
<fileset dir="${JacocoSrcPath}/src/component/startflow"/>
<fileset dir="${JacocoSrcPath}/src/component/format"/>
</sourcefiles>
</group>
<group name="Project">
<classfiles>
<fileset dir="${JacocoClassPath}/project"/>
</classfiles>
<sourcefiles encoding="${Encoding}">
<fileset dir="${JacocoSrcPath}/src/project">
<exclude name="config/**" />
</fileset>
</sourcefiles>
</group>
</structure>
<html destfile="${JacocoReport}" encoding="${Encoding}" footer="${ReportFooter}"/>
</jacoco:report>
</target>
</project>