
一 背景介紹
最近部門在推進質量標準化,透過標準化研發、交付、部署、運維等過程,減少缺陷率和返工率,提高整體的工作效率。而單元測試又是軟體研發過程中的重要一環,此文可以幫助理解單元測試外掛的執行過程,瞭解 mock 框架以及平臺覆蓋率統計相關的原理,從而更好更快地編寫單元測試。
二 單元測試與敏捷開發
在常規的測試環節中,可以較為籠統地作以下分類:
單元測試:快速地檢查一個類或極小範圍內的功能
冒煙測試:快速檢查系統核心功能是否有明確缺陷
整合測試:檢查整個應用或系統的功能
迴歸測試:檢查變更程式碼是否破壞舊的功能
黑盒測試:將整個系統看成一個黑盒進行不特定測試
白盒測試:按照編碼或執行細節,設計特定的測試過程
完全沒有實施過單元測試的團隊,推進過程可以按照確定覆蓋率基線、覆蓋率摸底、持續補充用例、持續提升單元測試質量和覆蓋率幾個環節。最終的目的是希望研發人員從被動編寫到主動編寫,不斷提升程式碼可測性,將低階缺陷扼殺在整合測試之前。


三 Maven & JUnit 的關係
1 Maven 的簡介
名詞解釋
1)goals
goals 是屬於具體 maven 外掛的一個任務,可以完成一件具體的事情。例如在 Spring Boot 的官方外掛中,就提供了 run 這個任務,幫助我們直接透過 maven 命令執行我們的 Spring Boot 應用。
具體實踐中,會將 goal 繫結在某個 phase 執行時執行。

2)lifecycle 和 phase
phase 是 Maven 定義的一套通用編譯過程,例如 compile、deploy,phase 本身並沒有具體行為,需要依賴相關外掛繫結任務。

可以透過在 pom 檔案中的外掛宣告,指定某個 goal 執行的時機(phase)
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
三套生命週期
Maven 的整體架構採用了 core + plugin 的方式,core 可以當成一個 Launcher,啟動過程中會有一些切換主類的過程,Tomcat、Spring Boot、Pandora Boot 都有類似的設計。
Maven 中定義了三套生命週期:clean、default、site,每個生命週期會包含一些階段(phase)。三套生命週期相互獨立,執行某個 phase 時,按序執行且順序靠前 phase 先執行,直到指定的 phase 執行結束,之後的 phase 不會再執行。
1)clean 生命週期
目的是做一些構建檔案的清理工作
pre-clean..............執行清理前的工作
clean..................清理上一次構建生成的所有檔案
post-clean.............執行清理後的工作
2)default 生命週期
包含了最常用的 phase,定義了構建專案時的核心過程
validate
initialize
generate-sources
process-sources
generate-resources
process-resources......複製和處理資原始檔到target目錄,準備打包
compile................編譯專案的原始碼
process-classes
generate-test-sources
process-test-sources
generate-test-resources
process-test-resources
test-compile...........編譯測試原始碼
process-test-classes
test...................執行測試程式碼
prepare-package
package................打包成jar或者war或者其他格式的分發包
pre-integration-test
integration-test
post-integration-test
verify
install................將打好的包安裝到本地倉庫,供其他專案使用
deploy.................將打好的包安裝到遠端倉庫,供其他專案使用
3)site 生命週期
pre-site
site...................生成專案的站點文件
post-site
site-deploy............釋出生成的站點文件
mvn test 和 mvn surefire:test
surefire:test 是 maven-surefire-plugin 中定義的一個任務,預設繫結在 test 階段執行
-
當執行 mvn test命令時,先執行 test 階段之前的 compile、test-compile 等 phase 及繫結 goal 任務;
-
而 mvn surefire:test 則是直接執行這個任務,不會執行編譯,因此需要提前手動編譯好原始碼和測試程式碼;
Maven 會自動收集當前專案的所有模組,做依賴樹和外掛合併。當 pom 中未宣告任何外掛或者外掛版本號為空,Maven 會使用預設值進行填充。
maven 的 default 生命週期和外掛版本關係的宣告檔案:maven-core-3.6.3.jar/META-INF/plexus/default-bindings.xml

在收集到所有的外掛資訊後,會按照 phase 順序依次執行

2 單元測試框架 JUnit
JUnit 是 Java 開發測試中最常用的單元測試框架,它是由 Kent Beck (極限程式設計和測試驅動開發的創始人) 和 Erich Gamma 共同編寫,其靈感來自於 Kent Beck 早期在 SUnit (一種針對 Smalltalk 程式語言的測試框架) 上的工作。
JUnit 屬於 xUnit測試框架家族。xUnit 家族中的測試框架通常會定義這幾個執行過程: setup, exercise, verify, teardown

JUnit3 中我們能見到一些約定的類名和方法名,這是因為早期的 JDK 並不支援註解。直到 JDK 1.5 支援註解,才使得 JUnit4 基於註解宣告測試用例變成可能。
JUnit4 採用 @Annotation 標註的方式,比 JUnit3 的透過類繼承和特定方法名帶來更大的靈活性,而且只有一個 jar 包非常易於整合。
JUnit3
public void testXxx();
pulbic void setUp();
public void tearDown();
|
JUnit4
|
JUnit5(2016)
JUnit5 則誕生於一個網際網路新技術大爆發的時期,它的目標是作為一個測試平臺,來連線測試工具、測試引擎和用例。JUnit5 的主要元件可以分為。


JUnit4 是如何被 Maven 喚起的
前文提到,surefire 外掛的會將測試任務繫結在 test 階段,因此當執行 mvn test時會呼叫 surefire 外掛的方法。surefire 透過 SPI 機制掃描類路徑下,發現測試引擎實現類(需要實現 org.apache.maven.surefire.providerapi.SurefireProvider),從而將測試任務轉嫁到具體的執行引擎 。

按測試類名稱過濾
Maven Surefire 外掛無其他測試框架的依賴注入時,預設使用 JUnit3Provider 作為執行引擎,因此要求測試類命名為以下模式:
-
**/Test*.java
-
**/*Test.java
-
**/*Tests.java
-
**/*TestCase.java
過程中會排除所有巢狀類(包括靜態成員類),也可以透過在 pom 檔案中配置include和exclude規則來覆蓋預設行為。
預設引擎下掃描測試方法的規則
-
測試方法必須是 public, 非 static,返回型別為 void,無參的方法;
-
測試方法必須寫成testXxx形式;
-
全域性變數可以在無參的構造方法中初始化;
-
每次執行一個測試用例前,執行一遍setUp(),用於對資料的初始化;
執行完一個測試用例後,再執行tearDown(),用於銷燬還原資料;
因此當我們需要使用 JUnit4 的註解如 @Before,需要新增依賴 surefire-junit4 告訴 SurefirePlugin 優先使用 JUnit4 的 @Runner 來執行。
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.16</version>
<dependencies>
<dependency>
<groupId>org.apache.maven.surefire</groupId>
<artifactId>surefire-junit4</artifactId>
<version>2.16</version>
</dependency>
</dependencies>
</plugin>
這就是為什麼在本地執行 IDEA 沒問題, Aone 或者命令列執行就出錯的原因,因為缺少依賴 Maven 無法識別 JUnit4 註解,導致很多變數沒有被 @Before 註解標註的方法初始化。
四 Mock 程式設計
單元測試中,一個重要原則就是不擴大測試範圍,儘可能將 mock 外部依賴,例如外部的 RPC 服務、資料庫等中介軟體。被 mock 的物件可以稱作。
「測試替身」,它來源於電影中的特技替身的概念。Meszaros 在他的文中[2]定義了五類替身。
測試替身的分類

1 fake、spy、stub、mock 如何區分
為了幫助更好的理解「測試替身」在實際單元測試中的應用,我們看幾個例子:
fake
假設有一個庫存系統,當有訂單時會從倉庫中提貨,如果貨物不足則無法完成訂單。單元測試是不應該依賴外部服務的,例如網路,因為網路是不可靠狀態,所以我們應該用一個 fake warehouse 來偽造傳送郵件的功能,它需要實現 Warehouse 抽象類,是一個可用的庫存服務實現,但它只會將內容維護在記憶體中,而不會持久化到資料庫或外部儲存中。
privatestatic String APPLE = "Apple";
privatestatic String PEACH = "Peach";
private Warehouse warehouse = new WarehouseImpl();
publicvoidsetUp()throws Exception {
warehouse.add(APPLE, 50);
warehouse.add(PEACH, 25);
}
publicvoidtearDown(){
warehouse = new WarehouseImpl();
}
publicvoidtestOrderIsFilledIfEnoughInWarehouse(){
Order order = new Order(APPLE, 50);
warehouse.fill(order);
assertTrue(order.isFilled());
assertEquals(0, warehouse.countGoods(APPLE));
}
publicvoidtestOrderDoesNotRemoveIfNotEnough(){
Order order = new Order(APPLE, 51);
warehouse.fill(order);
assertFalse(order.isFilled());
assertEquals(50, warehouse.countGoods(APPLE));
}
stub
stub 是具有固定響應行為的 mock,目的是讓測試用例跑通,不作為關鍵測試環節。
我們假定一個測試場景,當庫存不能滿足訂單所需要的貨物數量時,我們需要自動傳送一封郵件,因此郵件服務的 Stub 可以簡單實現為:
publicinterfaceMailService{
publicvoidsend(Mail mail);
}
publicclassMailServiceStubimplementsMailService{
private List<Mail> sentMails = new ArrayList<Mail>();
publicvoidsend(Mail mail){
sentMails.add(mail);
}
publicintmailSent(){
return sentMails.size();
}
}
程式碼中由於訂單需要貨物不足,會發送一封郵件,我們需要驗證是否傳送
privatestaticString APPLE = "Apple";
privatestaticString PEACH = "Peach";
private MailService mailService = new MailServiceStub()
private Warehouse warehouse = new WarehouseImpl(mailService);
// 省略 @Before 和 @After
publicvoid testOrderSendsMailIfUnfilled() {
Order order = new Order(APPLE, 51);
warehouse.fill(order);
assertEquals(1, mailService.mailSent());
}
我們在測試中使用了狀態驗證,即驗證當前資料的狀態和預期是否一致。如果是使用 mock 來做,兩者區別就在 stub 使用了狀態驗證,而 mock 則使用行為驗證,即驗證某些方法是否被呼叫驗證分支路徑是否已覆蓋或符合我們的業務設計。
publicvoidtestOrderSendsMailIfUnfilled() {
Order order = new Order(APPLE, 51);
Warehouse warehouse = new WarehouseImpl();
MailService mailService = mock(MailService.class);
warehouse.setMailer(mailService);
warehouse.fill(order);
Mockito.verify(mailService).send(Mockito.any());
}
spy
spy 這個單詞是間諜的意思,顧名思義間諜主要的工作就是收集情報,因此 spy 物件的作用就是去收集每次呼叫的引數、返回值、呼叫this、丟擲的異常。spy 最主要的特性就是它只收集情報,不提供任何預設行為。
mock
mock 物件只有傳入的引數滿足設定時,才會觸發 mock 行為。因此 mock 物件多使用行為驗證,其他三類物件也可以使用行為驗證。
對於第一個例子,我們只是要確定對於某一個訂單,在庫存不足時訂單 fill 會失敗,主要測試物件是訂單 Order,過程中依賴物件是倉庫 Warehouse。
privatestatic String APPLE = "Apple";
publicvoidtestFillingRemovesInventoryIfInStock() {
//setup
Order order = new Order(APPLE, 50);
Warehouse warehouseMock = mock(Warehouse.class);
//exercise
warehouseMock.fill(order);
//verify
Mockito.verify(warehouseMock).remove(Mockito.eq(APPLE), Mockito.eq(50));
assertTrue(order.isFilled());
}
2 Mockito
在上面的例子中,我們大量使用了 Mockito 作為 mock 工具,現在我們來簡單對這個工具做個介紹,幫助大家進一步理解上面 mock 和驗證過程中發生的事。下面的例子是一個 JUnit 結合 Mockito 單元測試,透過 @InjectMocks 宣告被測試的物件,透過 @Mock 宣告被測試類依賴的物件。透過 Mockito.doReturn 等方法即可定義 mock 物件的行為。
@RunWith(MockitoJUnitRunner.class)
public class UserControllerTest {
@InjectMocks
private UserController userController;
@Mock
private UserService userService;
@Test
public void testService() {
doReturn(null).when(userService).listUser();
userController.listUser();
}
}
@Mock 物件 @Spy 物件
在 Mockito 中,mock 物件和 spy 物件都可以進行 mock。區別是 mock 會代理的全部方法,對應方法沒有 stubbing 時返回預設值。而 spy 只是將有樁實現(stubbing)的呼叫進行 mock,其餘方法仍然是實際呼叫原物件的方法。
Mockito 預設使用 bytebuddy 生成類,這裡的實現過程,類似動態代理問題中,常見的基於介面實現和子類實現兩種代理方式。為了更好了解 mock 物件工作方式,我們先 dump 一個介面型別 mock 後的 class 檔案。
publicclassUserService$MockitoMock$450450480 implementsUserService, MockAccess{
privatestaticfinallong serialVersionUID = 42L;
private MockMethodInterceptor mockitoInterceptor;
// 這裡省略了 equals、toString、hashCode、clone 方法的代理
privatestatic Method cachedValue$jWkXotML$7kplrf1;
static {
cachedValue$jWkXotML$7kplrf1 = UserService.class.getMethod("listUser");
}
public List<User> listUser(){
return (List<User>)DispatcherDefaultingToRealMethod.interceptAbstract(this, this.mockitoInterceptor, false, cachedValue$jWkXotML$7kplrf1);
}
}
被 mock 的類,方法被代理到 MockHandler

經過上面的分析,這裡可以推出兩點:
-
對於無介面的類來說,會生成被 mock 類的子類,內部呼叫略有不同,但最終仍然會呼叫到 MockHandler
-
Mockito.spy 本質仍然是做 mock,只是添加了預設呼叫原始方法的策略
// Mockito.mock or @Mock
publicstatic <T> T mock(Class<T> classToMock,
MockSettingsmockSettings) {
return MOCKITO_CORE.mock(classToMock, mockSettings);
}
// Mocktio.spy or @Spy
publicstatic <T> T spy(Class<T> classToSpy) {
return MOCKITO_CORE.mock(classToSpy,
withSettings().useConstructor()
// 預設響應方式優先呼叫原類的方法
.defaultAnswer(CALLS_REAL_METHODS));
}
從 Mockito.mock 和 Mockito.spy 的方法實現可以看出,spy 方法也僅是注入了預設的 answer 行為,即呼叫真實方法。
這裡可以推理一下 spy 一個介面後,預設的返回是什麼?介面一般是沒有預設實現,spy 的介面呼叫時又該呼叫什麼呢?
@Mock 物件方法的呼叫和驗證
MockHandler 最主要的實現類為 MockHandlerImpl,我們來看下這個類的主要流程。MockHandler 預設會攔截 mock 物件所有的方法呼叫(super、equals、toString、hashCode 等方法先不討論)。

mock 物件方法被呼叫時,先查詢是否已經在當前執行緒中植入過呼叫驗證物件 「VerificationMode」(可以透過 Mockito.verify 植入),如果存在則執行方法呼叫驗證,不再呼叫 mock 方法。
這個例子可以幫助我們理解 Mockito 做方法驗證的過程。userDao 是 mock 物件,userService 內部會呼叫 userDao#deleteUser 方法。
UserService userService;
UserDao userDao;
publicvoidtestAddResourcePoliciesWithoutMember(){
// setup
Long userId = 100L;
DeleteUserRequest request = new DeleteUserRequest();
request.setUserId(userId);
// exercise
DeleteUserResult result = userService.deleteUser(request);
// verify
Assert.assertTrue(result.isSuccess());
//|----- 植入驗證 ------|-- 再呼叫觸發驗證 --------------|
Mockito.verify(userDao).deleteUser(request.getUserId());
}
在 verify 呼叫前,userService.deleteUser 內部會呼叫 userDao#deleteUser 記錄一次方法呼叫,Mockito#verify 時注入驗證物件「VerificationMode」,再鏈式呼叫了 deleteUser 再次呼叫方法觸發驗證。
@Mock 物件方法引數的驗證
mock 物件的引數匹配,是基於棧做方法呼叫和引數記錄。核心類是 ArgumentMatcher,當查詢 mock 方法的 stub 物件時,不僅需要匹配方法的 invocation 標識,還需要匹配對應的引數,即 Mockito.eq()、 Mockito.anyList() 等。
匹配的實現原理可以類比 Java 的 equals,如使用 Mockito.anyString(),則入參必須是不為 null 的 String。
3 位元組碼編輯
Mockito 預設實現的 mock 也是一種動態代理技術,在方法級別進行攔截和呼叫我們指定的 stub 物件,與我們經常討論的 JDK Proxy、Cglib 等 AOP 技術非常相似。
從 Java 動態代理實現上來看,可分為兩種策略和手段:操作原始類位元組碼或者生成子類或實現介面的新類。在實際的使用中,代理類的生成仍然可能依賴位元組碼的動態生成方式,並沒有嚴格的界限。

常見的動態代理僅限於例項方法級別,對於方法內部如構造方法、靜態方法和靜態塊、初始塊、new、欄位訪問、catch、instanceof 等位元組碼指令通常無能為力,只能求助於操作原始的位元組碼來達到目的。
生成介面實現或者子類的代理也有一定的侷限性:例如父類的 final 方法是無法被動態子類代理的。
私有方法也無法透過 Mockito 進行打樁,因此在專案的單元測試編寫中,Mockito 的手段有些不夠用,於是就有了基於 Mockito 的 PowerMockito。
4 PowerMockito
PowerMockito 使用 Javaassist 作為位元組碼編輯的框架。PowerMockito 會預設對相關類的位元組碼做以下修改
-
去除 final class 的 final 修飾符
-
將所有構造方法修改為 public
-
為 new 物件、欄位訪問、構造器注入代理
-
去除 static final fields 的 final 宣告
-
為類的修飾符新增 public
-
為方法注入代理物件 MockGateway
-
將 @SuppressStaticInitializationFor 宣告的類的靜態塊替換為 {}
-
將超長方法體(超 65535 位元組)替換為拋異常
來看一個 PowerMock 的使用例子
publicclassUserControllerTest{
private UserController userController;
private UserService userService;
public void testService() {
doReturn(null).when(userService).listUser();
userController.listUser();
}
}
這裡與 Mockito 的區別是 JUnit Runner 指定為 PowerMockRunner,在新註解 @PrepareForTest 中宣告的類,執行測試用例時,會建立一個新的 org.powermock.core.classloader.MockClassLoader 類載入器例項,來載入宣告的類,從而完成對目標類指令級別的修改。
在 surefire 外掛的 Runner 建立時,可以在下面的呼叫棧中看到,由 surefire 外掛的 JUnit4Provider 代理到 JUnit,JUnit 負責 Runner 物件的初始化和呼叫。在 PowerMockRunner 初始化的過程中,基於自定義類載入器做到類的修改。

有時候我們需要對某些類的靜態塊遮蔽,保證測試用例可以正常執行,PowerMock 提供了 @SuppressStaticInitializationFor 註解,只需要在測試類上宣告即可。
需要注意的是,遮蔽靜態塊程式碼後,類的靜態欄位也不會被初始化,因為靜態欄位的初始化是被編譯在靜態塊中,這點需要注意。如果你遮蔽了靜態塊中的一些方法,但仍然依賴一些靜態欄位,可能會產生一些異常情況,如空指標。這時候需要額外的 mock 或者手動初始化靜態欄位。
我們來看一段對 new 運算子修改後的方法,下面表格中提供了一些 Javassist 的變數說明便於理解下面的例子。
$0, $1, $2, …
|
this and actual parameters
|
$args
|
An array of parameters. The type of $args is Object[].
|
$$
|
All actual parameters.
For example, m($$) is equivalent to m($1,$2,…)
|
$cflow(…)
|
cflow variable
|
$r
|
The result type. It is used in a cast expression.
|
$w
|
The wrapper type. It is used in a cast expression.
|
$_
|
The resulting value
|
$sig
|
An array of java.lang.Class objects representing the formal parameter types.
|
$type
|
A java.lang.Class object representing the formal result type.
|
$class
|
A java.lang.Class object representing the class currently edited.
|
$proceed
|
The name of the method originally called in the expression.
|
PowerMock 會將 new 物件的位元組碼替換為代理到 MockGateway#newInstanceCall 的靜態方法呼叫,方法返回的物件如果不是 PROCEED,則呼叫原始方法,否則使用返回的構造方法進行反射呼叫或者直接使用 MockGateway 返回的物件。
Object instance = org.powermock.core.MockGateway.newInstanceCall($type,$args,$sig);
if(instance != org.powermock.core.MockGateway.PROCEED) {
if(instance instanceof java.lang.reflect.Constructor) {
$_ = ($r) sun.reflect.ReflectionFactory.getReflectionFactory()
.newConstructorForSerialization($type,
java.lang.Object.class.getDeclaredConstructor(null))
.newInstance(null);
} else {
$_ = ($r) instance;
}
} else {
$_ = $proceed($$);
}
透過以上替換,PowerMock 就成功將方法內部的 new 操作,代理到了 MockGateway。對於一般的方法(無論是 private 還是 static 甚至 native 方法),都是相似的,對於普通方法,會代理到 MockGateway#methodCall。methodCall 中在過濾完一些特殊方法後,如 toString、equals 等,會按照是否被抑制呼叫、是否有 stub、是否有 mock 等策略執行。下圖中說明了 methodCall 的核心流程。

PowerMock 支援的 private 方法 mock、static 方法 mock 可以理解為在 API 層面提供了一套工具入口,剩下的 mock 物件生成、方法驗證等仍舊利用了 Mockito 提供的能力。
5 mock 靜態方法
有時不可避免會遇到要 mock 靜態方法的地方,Mockito 2.0 版本不支援 Mock 靜態方法,目前的方式是引入 PowerMock,但是引入後,JaCoCo 又會出現覆蓋率統計錯誤的問題,需要將 JaCoCo 的採集模式改為離線方式。
新版的 Mockito 從 3.4.0 開始,已經支援了靜態方法的mock。
需要引入
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
<version>3.4.6</version>
<scope>test</scope>
</dependency>
需要注意 mock 靜態方法是萬不得已才去做的,在 mock 靜態方法前,首先應該考慮的是最佳化業務程式碼,提高程式碼可測試度。
五 覆蓋率統計
程式碼覆蓋率是衡量單元測試有效性的一個指標,覆蓋率又可以分為兩個大類,即 「需求覆蓋率」和「程式碼覆蓋率」。
需求覆蓋
指的是測試人員對需求的瞭解程度,根據需求的可測試性來拆分成各個子需求點,來編寫相應的測試用例,最終建立一個需求和用例的對映關係,以用例的測試結果來驗證需求的實現,可以理解為黑盒覆蓋。
|
程式碼覆蓋
為了更加全面的覆蓋,我們可能還需要理解被測程式的邏輯,需要考慮到每個函式的輸入與輸出,邏輯分支程式碼的執行情況,這個時候我們的測試執行情況就以程式碼覆蓋率來衡量,可以理解為白盒覆蓋。
|
程式碼覆蓋率的度量方式有以下幾類:

1 程式碼覆蓋率的發展歷史
Java 中比較流行的程式碼覆蓋率工具有 EMMA, Cobertura,JaCoCo 等。其中 Emma 由於開發團隊的原因已經停止更新,原團隊目前專注於 JaCoCo 的開發和維護工作。有意思的是,EclEmma 和 JaCoCo 的官方網站是指向不同域名的同一個服務。

在 Java 領域有很多方法來收集程式碼覆蓋度。下圖展示了 JaCoCo 外掛採用的技術(加色展示的部分)。

2 Jacoco 覆蓋率統計過程
Jacoco 是 Java 領域目前比較主流的覆蓋率統計工具,它的統計過程可以分為打樁、測試用例執行、覆蓋率統計和覆蓋率資料解析並生成報告。
1、首先對需要統計覆蓋率的 Java 程式碼進行插樁,植入覆蓋率統計程式碼,有 On-The-Fly 和 Offline 兩種方式。
2、執行測試用例,透過用例執行收集執行軌跡資訊,儲存在記憶體中。
3、JVM 退出前將覆蓋率資料儲存至磁碟(二進位制)或透過網路傳送出去 。
4、解析覆蓋率檔案,將程式碼覆蓋率報告圖形化展示出來,如 html、xml 等檔案格式。

3 JaCoCo 的 offline 與 on-the-fly
根據插入位元組碼的時機不同,可以將覆蓋率工具的執行方式分為offline與on-the-fly兩種
offline 模式
offline 模式會對編譯後的位元組碼檔案進行插樁並覆蓋原始檔,在啟動 JVM 時直接載入插樁後的位元組碼。

offline 模式會對編譯後位元組碼原始檔進行修改,所以對使用者的影響最大,但是對效能的影響最小
-
優點是不需要執行環境支援 java agent,不會與其他 agent 衝突
-
但需要新增 jacoco 編譯位元組碼的 runtime 依賴
on-the-fly 模式
在 JVM 載入類檔案時,回撥 javaagent 對位元組碼進行動態增強,植入覆蓋率統計程式碼。

on-the-fly 模式會在應用啟動時對載入進JVM的位元組碼檔案進行插樁,不會改變使用者的執行流程,只需要在JVM啟動時配置 -javaagent 引數,更加無感
-
優點是直接新增啟動引數即可快速進行覆蓋率統計和分析
-
缺點是使用 javaagent 會降低一些啟動速度以及 agent 衝突問題
4 JaCoCo 與 Maven 整合
JaCoCo 提供了 maven 外掛方便開發在專案中整合,提供了以下基本 goals,常用的包括 prepare-agent、report、instrument 和 restore-instrumented-classes。jacoco-maven-plugin 的 goals 與 Maven 生命週期的繫結關係如下
validate
initialize .................. (prepare-agent 預設所屬週期,注入 javaagent 引數)
generate-sources
process-sources
generate-resources
process-resources
compile
process-classes ............. (instrument 預設所屬週期,offline 模式下對位元組碼插樁)
generate-test-sources
process-test-sources
generate-test-resources
process-test-resources
test-compile
process-test-classes
test ........................ (mvn test 執行的截止週期)
prepare-package ............. (restore-instrumented-classes 預設所屬週期,offline 模式下恢復原始位元組碼)
package
pre-integration-test
integration-test
post-integration-test
verify ...................... (report 和 check 預設所屬週期,report 用於生成覆蓋率報告)
install
deploy
在預設的繫結關係中,當我們執行 mvn test 的時候,restore-instrumented-classes 和 report 預設不會被執行,因此為方便 offline 模式使用,我們需要修改下外掛繫結的執行 phase,保證我們執行 mvn test 時可以正常執行,生成覆蓋率報告。下面是兩種不同模式的配置方案
offline
instrument 和 restore-instrumented-classes 需要配套使用
<dependencies>
<dependency>
<groupId>org.jacoco</groupId>
<artifactId>org.jacoco.agent</artifactId>
<version>0.8.6</version>
<classifier>runtime</classifier>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.16</version>
<dependencies>
<dependency>
<groupId>org.apache.maven.surefire</groupId>
<artifactId>surefire-junit4</artifactId>
<version>2.16</version>
</dependency>
</dependencies>
<configuration>
<systemPropertyVariables>
<jacoco-agent.destfile>${project.build.directory}/coverage.exec</jacoco-agent.destfile>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.6</version>
<executions>
<execution>
<id>default-instrument</id>
<goals>
<goal>instrument</goal>
</goals>
</execution>
<execution>
<id>report</id>
<goals>
<goal>report</goal>
</goals>
<configuration>
<dataFile>${project.build.directory}/coverage.exec</dataFile>
</configuration>
</execution>
<execution>
<phase>test</phase>
<id>default-restore-instrumented-classes</id>
<goals>
<goal>restore-instrumented-classes</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
on-the-fly
aone 的預設測試外掛預設採用這種模式
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.6</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>prepare-package</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
為什麼要改預設的 phase?
當我們使用 offline 模式執行 mvn test,如果按照預設的繫結關係,可能會遇到
Cannot process instrumented class 某個類名. Please supply original non-instrumented classes.
原因是JaCoCo 的 instrument 在 process-classes 階段將編譯好的程式碼打樁用於統計程式碼覆蓋率,然後需要在 restore-instrumented-classes 恢復原始位元組碼。
但是執行 mvn test 時只到 test,而 restore-instrumented-classes 繫結在 prepare-package 階段,因此 mvn test 預設不會觸發 restore-instrumented-classes ,第二次 mvn test 時會重複打樁,引起報錯。
如果不改預設的 phase,則需要將 mvn test 改為 mvn verify 使用。verify 會執行 intergration-test 和 package 階段,這兩個階段針對單元測試來說,不是十分必要。
目前 Aone 程式碼覆蓋率主要基於 JaCoCo 的 on-the-fly 模式進行程式碼覆蓋率採集,透過自定義測試構建過程,利用 CI 外掛自動注入 jacoco-maven-plugin,無需使用者自己新增。配置過程可以參考下文。
Aone 的各類測試任務底層複用了同一套執行引擎,類似 Maven 的 phase 和 goals,在 aone 中稱為階段和外掛,每個階段可以新增多個外掛,例如下圖在單元測試階段,添加了程式碼 checkout 外掛、codeconverage-unittest-pre(自動在 pom 檔案中植入 JaCoCo 的 on-the-fly 配置)、單測和覆蓋率解析外掛。

aone 測試任務任務日誌解析
-
codecoverage-unittest-pre 自動注入 jacoco-maven-plugin
(11:00:12) Execute codecoverage-unittest-pre plugin
(11:00:13) ignore submodules.
(11:00:13) Executeplugin command
(11:00:13) cd /root/cise/space/135295299/plugin/codecoverage-unittest-pre && ./codecoverage-unittest-pre -d "/root/cise/space/135295299/source/pom.xml"
(11:00:13) Plugin unit-test code coverage pre processer begins.
(11:00:14) 2022-03-0111:00:14 INFO PomUtils - pom filefound:/root/cise/space/135295299/source/pom.xml
(11:00:14) 2022-03-0111:00:14 INFO PomUtils - backup pom file success.
這裡的日誌還有兩種情況
1)pom 已經配置 jacoco
當我們出於各種情況,例如對外掛版本、執行階段或者為了解決覆蓋率統計問題,自行配置了 jacoco 外掛後,aone 便不會再自動注入
Execute codecoverage-unittest-pre plugin
(11:00:24) ignore submodules.
(11:00:24) Executeplugin command
(11:00:24) cd /root/cise/space/135294989/plugin/codecoverage-unittest-pre && ./codecoverage-unittest-pre -d "/root/cise/space/135294989/source/buc.acl.shared.parent/pom.xml"
(11:00:25) Plugin unit-test code coverage pre processer begins.
(11:00:25) 2022-03-0111:00:25 INFO PomUtils - pom filefound:/root/cise/space/135294989/source/buc.acl.shared.parent/pom.xml
(11:00:25) 2022-03-0111:00:25 INFO PomUtils - backup pom file success.
(11:00:25) 2022-03-0111:00:25ERROR PomUtils - pom.xml contains jacoco-maven-plugin;no need to modify pom.
(11:00:25) 2022-03-01 11:00:25 INFO PomUtils - contains jacoco plugin,no need to modify pom.
2)pom 中缺少 </build> 標籤提示 jacoco.exec not found

(16:41:13) Execute codecoverage-unittest-pre plugin
(16:41:14) ignore submodules.
(16:41:15) Executeplugin command
(16:41:15) cd /root/cise/space/135189936/plugin/codecoverage-unittest-pre && ./codecoverage-unittest-pre -d "/root/cise/space/135189936/source/pom.xml"
(16:41:15) Plugin unit-test code coverage pre processer begins.
(16:41:15) 2022-02-2816:41:15 INFO PomUtils - pom filefound:/root/cise/space/135189936/source/pom.xml
(16:41:15) 2022-02-2816:41:15 INFO PomUtils - backup pom file success.
(16:41:15) 2022-02-2816:41:15ERROR PomUtils - <build> elementnot found.
2. JaCoCo 外掛在 prepare-agent 階段注入 javaagent 引數
(11:00:18)[INFO]---jacoco-maven-plugin:0.8.7:prepare-agent (jacoco-initialize) @ ---
(11:00:18) [INFO] Downloading ...
(11:00:18) [INFO] Downloaded ...
(11:00:20)[INFO]argLinesetto-javaagent:/root/.m2/repository/org/jacoco/org.jacoco.agent/0.8.7/org.jacoco.agent-0.8.7-runtime.jar=destfile=/root/cise/space/135295299/source/target/jacoco.exec
這裡可能會出現另一種情況,日誌出現,但實際執行程序中,並沒有 javaagent 引數,導致覆蓋率無法統計。
grep 程序排查
執行 mvn test 過程中,日誌中看到 case 已經在running中時,檢視程序。
ps -ef | grep java
找到測試對應的程序,觀察完整的程序命令,如果jacoco生效了會看到下圖,如果沒有生效,則ps -ef | grep java | grep jacoco看不到任何程序。

測試執行時為什麼會沒有 JaCoCo 這個 javaagent ?
我們通常會配置maven-surefire-plugin去跑測試。
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>"some jvm args"</argLine>
</configuration>
</plugin>
</plugins>
當 surefire 配置執行引數後,mvn test 時其他外掛就無法自動新增更多的 JVM 執行引數,比如 jacoco 外掛。argLine的正確寫法如下
<argLine>${argLine}"some jvm args"</argLine>
3. 覆蓋率報告解析
實驗室是透過獲取執行的標準輸出,解析後得到執行結果。因此可以透過在指令碼、外掛執行過程中按照相應的格式輸出內容,達到統計執行結果以及定義頁面展現的目的。
(16:56:13) INTEGRATION_COVERAGE: {"appName":"mozi-im-gateway-private","branchCovered":0,"branchRatio":0.0,"branchTotal":30,"buildId":125846455,"classCovered":0,"classRatio":0.0,"classTotal":17,"coverageRecordId":9579151,"env":"local","lineCovered":0,"lineRatio":0.0,"lineTotal":185,"methodCovered":0,"methodRatio":0.0,"methodTotal":75,"name":"buc.login.spi","nodeType":"JAR","parent":"mozi-im-gateway-private","type":"unnit-test","updated":0}
(16:56:13) INTEGRATION_COVERAGE: {"appName":"mozi-im-gateway-private","branchCovered":0,"branchRatio":0.0,"branchTotal":38,"buildId":125846455,"classCovered":0,"classRatio":0.0,"classTotal":5,"coverageRecordId":9579151,"env":"local","lineCovered":0,"lineRatio":0.0,"lineTotal":124,"methodCovered":0,"methodRatio":0.0,"methodTotal":17,"name":"buc.sso.application","nodeType":"JAR","parent":"mozi-im-gateway-private","type":"unnit-test","updated":0}
(16:56:13) INTEGRATION_COVERAGE: {"appName":"mozi-im-gateway-private","branchCovered":49,"branchRatio":0.0016,"branchTotal":29792,"buildId":125846455,"classCovered":20,"classRatio":0.0103,"classTotal":1934,"coverageRecordId":9579151,"env":"local","lineCovered":374,"lineRatio":0.0048,"lineTotal":77160,"methodCovered":115,"methodRatio":0.0069,"methodTotal":16618,"name":"mozi-im-gateway-private","nodeType":"APP","type":"unnit-test","updated":1,"updatedLineCovered":76,"updatedLineTotal":381,"updatedRatio":0.1995}
(16:56:13) *************************************************************
(16:56:13) CODE_COVERAGE_LINES: 374/77160
(16:56:13) CODE_COVERAGE_NAME_LINES: 行
(16:56:13) CODE_COVERAGE_BRANCHES: 49/29792
(16:56:13) CODE_COVERAGE_NAME_BRANCHES: 分支
(16:56:13) CODE_COVERAGE_METHODS: 115/16618
(16:56:13) CODE_COVERAGE_NAME_METHODS: 方法
(16:56:13) CODE_COVERAGE_CLASSES: 20/1934
(16:56:13) CODE_COVERAGE_NAME_CLASSES: 類
(16:56:13) CODE_COVERAGE_REPORT_LINES:http://test.aone.alibaba-inc.com/coverages/9579151
(16:56:13) CODE_COVERAGE_REPORT_BRANCHES:http://test.aone.alibaba-inc.com/coverages/9579151
(16:56:13) CODE_COVERAGE_REPORT_METHODS:http://test.aone.alibaba-inc.com/coverages/9579151
(16:56:13) CODE_COVERAGE_REPORT_CLASSES:http://test.aone.alibaba-inc.com/coverages/9579151
(16:56:13) CODE_COVERAGE_UPDATELINES: 76/381
(16:56:13) CODE_COVERAGE_NAME_UPDATELINES: 行增量
(16:56:13) CODE_COVERAGE_REPORT_UPDATELINES: http://test.aone.alibaba-inc.com/coverages/9579151
(16:56:13) *************************************************************
(16:56:13) Execute case_result_parser plugin
(16:56:15) ignore submodules.
(16:56:15) Execute plugin command
(16:56:15) cd /root/cise/space/135192024/plugin/case_result_parser && ./parser -p "/root/cise/space/135192024/source" -d "false" -t "common" -u "https://testservice.aone.alibaba-inc.com/ak/testservice/api/pipelineResult/utApiCallBack/build/607b495e-3d6d-4a8d-a18f-3cdcd226f860/stage/單元測試"
(16:56:15) /root/cise/space/135192024/plugin/case_result_parser
(16:56:15) [INFO] Project: /root/cise/space/135192024/source
(16:56:15) [INFO] Image: /root/cise/space/135192024/source/images/-815718685
(16:56:15) [INFO] Start to parse
(16:56:16) [INFO] End parsing
(16:56:16) [INFO] Parsed test case count:53
(16:56:16) [INFO] Paased count: 53
(16:56:16) [INFO] Failed count: 0
(16:56:16) TEST_CASE_AMOUNT: {"blocked":0,"passed":53,"failed":0,"skipped":0}
(16:56:16) [INFO] Callback url: https://testservice.aone.alibaba-inc.com/ak/testservice/api/pipelineResult/utApiCallBack/build/607b495e-3d6d-4a8d-a18f-3cdcd226f860/stage/單元測試
(16:56:16) [INFO] Max data size: 45000000
(16:56:16) [INFO] Current data size: 8159
(16:56:16) log4j:WARN No appenders could be found for logger (org.apache.commons.httpclient.HttpClient).
(16:56:16) log4j:WARN Please initialize the log4j system properly.
(16:56:16) [INFO] Send result to server, time: 01
(16:56:17) [INFO] Response: {"success":true,"messages":[],"result":"https://aone.alibaba-inc.com/pipeline/build/detail?buildDetailId=156703515&pipelineId=2905023","errorCode":null,"other":null,"msgCode":null,"msgInfo":null,"message":""}
(16:56:17) TEST_REPORT: https://aone.alibaba-inc.com/pipeline/build/detail?buildDetailId=156703515&pipelineId=2905023
如果實在定位不到問題,可以看原始碼瞭解,可以看到 jacoco 外掛是如何被植入 pom 檔案的。
5 JaCoCo 插樁和覆蓋率統計邏輯
Jacoco 的插樁原理是透過在方法表的 Code 區插入與程式碼覆蓋率相關的執行程式碼,Jacoco 就可以感知位元組碼的執行過程。簡單來講,Jacoco 會對位元組碼檔案進行四個修改:
-
為類增加 $jacocoData 屬性
-
為類增加 $jacocoInit 方法
-
在每個方法開頭建立一個 boolean 型別的陣列,JaCoCo 利用這個陣列來實現探針(Probe)
-
每行程式碼都會有一個探針對應到此陣列中,執行時修改 boolean 陣列中的項來實現探針
在程式碼執行完對應 statement 後,會將陣列對應探針位置修改為true。最終 Jacoco 透過各個類的探針陣列資料計算程式碼覆蓋率。
Jacoco 插樁後的位元組碼檔案示例如下
JacocoProbeTest.class
/* synthetic */
privatestatictransientboolean[] $jacocoData;
// $jacocoInit 是 jacoco 生成的程式碼,以下為近似邏輯
privatestatic/* synthetic */boolean[] $jacocoInit() {
if ($jacocoData != null) {
return $jacocoData;
}
{
Object[] args = new Object[3];
// class id
args[0] = Long.valueOf(8060044182221863588);
// class name
args[1] = "com/example/JacocoProbeTest"; // probecount
args[2] = Integer.valueOf(4);
// Jacoco 的特殊方法,會修改args[0] 的值
new RuntimeData().equals(args);
$jacocoData = (boolean[])args[0];
}
return $jacocoData;
}
}
// 覆蓋率統計,執行一行程式碼,即將 Probe 陣列對應位置置位 true
publicJacocoProbeTest(){
boolean[] b_arr_1 = JacocoProbeTest.$jacocoInit();
b_arr_1[0] = true;
}
publicstaticvoidtestSingleLineProbe(){
boolean[] b_arr_1 = JacocoProbeTest.$jacocoInit();
System.out.println("testSingleLineProbe");
b_arr_1[1] = true;
}
publicstaticvoidtestThrowExceptionProbe(){
boolean[] b_arr_1 = JacocoProbeTest.$jacocoInit();
System.out.println("testThrowExceptionProbe");
b_arr_1[2] = true;
b_arr_1[3] = true;
thrownew IllegalArgumentException();
}
下面是 intellij-coverage-agent 覆蓋率插樁的程式碼,它的埋點比起 jacoco 埋點使用了行號進行記錄。
原始碼:

插樁後的程式碼:
public void testGetHiddenMenuCodes() {
Object __class__data__ = ProjectData.loadClassData("com.alibaba.buc.acl.data.service.manage.impl.com.alibaba.buc.acl.data.service.manage.impl.ActionManageServiceImplTest");
ProjectData.touchLine(__class__data__, 140);
Set<String> hiddenMenuCodes = this.actionManageService.getHiddenMenuCodes(this.tenantId, this.appName);
ProjectData.touchLine(__class__data__, 142);
Assert.assertTrue(hiddenMenuCodes.containsAll(Arrays.asList(this.hiddenMenuCodes, "permissionManageGroup", "businessStripConfig", "divisionConfig", "stripOrgManager", "cooperationApplication", "cooperationList", "cooperationManagement")));
ProjectData.touchLine(__class__data__, 153);
}
6 JaCoCo 與 PowerMock 的衝突原理分析
下面是我在網路上找到的一段關於 JaCoCo 與 PowerMock 的衝突分析的說明
JaCoCo 以 Javaagent 方式啟動時,會為每一個載入進 JVM 的類插樁埋點。而 PowerMock 使用的 javaassist 接收到的某個類的位元組碼資料(原始位元組碼已經被其他非 javassist 的方式增強之後的位元組碼)和此類對應的 class 檔案的資料不一致時,javassist 就會廢棄掉接收到的已經被改變的位元組碼資料,轉而使用此類最原始的 class 檔案進行增強。這就導致 JaCoCo 埋入類位元組碼中的監控點被覆蓋,導致其無法感知到被重新載入的類的執行過程,使得類的程式碼覆蓋率結果為 0
這段話有沒有問題呢?真的是覆蓋率統計程式碼被覆蓋嗎[1]?
進一步想一想,為什麼在 IDEA 中執行時可以統計到程式碼覆蓋率? 而在 Aone 卻又未覆蓋?
提示 1:PowerMock 呼叫 javaassist 修改類後是不是會再次觸發 javaagent 的修改
提示 2:IDEA 執行覆蓋率統計時,預設採用 intellij-converage-agent

提示 3:jacoco 生成覆蓋率報告的方式,ClassID [2]

jacoco.exec 是記錄覆蓋率的二進位制檔案,有時候會命名為 coverage.exec

先從 jacococli 命令列生成報告的引數做個推理

引數中指定了覆蓋率報告、類的位元組碼以及輸出目錄,可以推測 JaCoCo 會根據原始位元組碼與覆蓋率陣列做匹配。在 jacocoagent 和 PowerMock 同時使用的情況下,jacoco.exec 中記錄的 ClassID 是兩次增強後的位元組碼計算的結果,生成覆蓋率報告時,根據原始位元組碼計算出的 ClassID 無法匹配 jacoco.exec 中的記錄,直接判斷為 noMatch,因此未生成有效的覆蓋率報告。
這段計算邏輯可以在分析覆蓋率報告的這個方法中看到[3],有興趣可以下載原始碼分析。
jacoco 的官網中已經解釋了為什麼要使用位元組碼生成的 ClassID 作為類標識
Why can't JaCoCo simply use the class name to identify classes?To understand why JaCoCo can't rely on class names we need to have a look at the way how JaCoCo measures code coverage.JaCoCo tracks execution with so called probes. Probes are additional byte code instructions inserted in the original class file which will note when they are executed and report this to the JaCoCo runtime. This process is called instrumentation. To keep the runtime overhead minimal, only a few probes are inserted at "strategic" places. These probe positions are determined by analyzing the control flow of all methods of a class. As a result every instrumented class produces a list of n boolean flags indicating whether the probe has been executed or not. A JaCoCo *.exec file simply stores a boolean array per class id.At analysis time, for example for report generation, the *.exec file is used to get information about probe execution status. But as probes are stored in a plain boolean array there is no information like corresponding methods or lines. To retrieve this information we need the original class files and perform the exact same control flow analysis than at instrumentation time. Because this is a deterministic process we get the same probe positions. With this information we can now interfere the execution status of every single instruction and branch of a method. Using the debug information embedded in the class files we can also calculate line coverage.If we would use just slightly different classes at analysis time than at runtime — e.g. different method ordering or additional branches — we would end-up with different probes. For example the probe at index i would be in method a() and not in method b(). Obviously this will create random coverage results.
解決方案
目前比較主流的解決方法為使用 JaCoCo 的 offline 模式先對位元組碼檔案進行插樁。在應用啟動時載入進 JVM 的位元組碼檔案就不需要動態插樁了,可以被 Powermock 正常增強。但是這種方式需要修改位元組碼檔案,對業務的執行與釋出過程有影響,需要測試完成後進行二次部署。
最佳化方向
因為 JaCoCo 生成覆蓋率報告時依賴原始碼和原位元組碼,而 intellij-coverage-agent 則不需要,原因是它的插樁程式碼自帶行數,因此不需要對照原始碼和原位元組碼進行分析,或許可以參考修改 JaCoCo 的插樁程式碼,但這比起修改一個數組的插槽,會帶來一些效能損耗。
[1]https://github.com/powermock/powermock/wiki/Code-coverage-with-JaCoCo
[2]https://www.jacoco.org/jacoco/trunk/doc/classids.html
[3] org.jacoco.core.analysis.Analyzer#createAnalyzingVisitor
《阿里雲智慧客服知識運營白皮書》
阿里雲智慧客服知識運營白皮書的撰寫,是在阿里雲智慧客服團隊的統一安排下,協調包括演算法工程師、開發工程師、產品設計師、AIT人工智慧訓練師人員等多角色,將技術理論基礎和實際實踐經驗進行結合,形成業內首部智慧客服知識運營白皮書。白皮書以阿里雲智慧客服系統為應用標的,面向智慧客服中的知識定義、知識應用、知識梳理方法三大環節進行描述和說明,希望為智慧客服領域的知識應用提供具備指導性意義的方法論。點選閱讀原文檢視詳情!