史上最輕量​!阿里新型單元測試Mock工具開源了

程序員面試通 發佈 2020-12-22T14:25:41+00:00

最簡單舒適的Mock測試應該是怎樣的?指著源文件調用了外部依賴的那行代碼說:「你,在測試的時候,換成這個假的調用!」結束。甭管他是私有方法、靜態方法,還是別的類的方法,直接換掉,不要有任何多餘動作。

最簡單舒適的Mock測試應該是怎樣的?

指著源文件調用了外部依賴的那行代碼說:

「你,在測試的時候,換成這個假的調用!」

結束。

甭管他是私有方法、靜態方法,還是別的類的方法,直接換掉,不要有任何多餘動作。

一 Mock測試八股文

Java的Mock工具伴隨著單元測試技術不斷疊代發展,可謂前仆後繼、歷久彌新,雖然原理各不相同,但核心的使用模式卻幾乎沒發生過多少變化。不論是當下流行的Mockito和PowerMock,或是曾經著名的JMockit、EasyMock、MockRunner等等,基本使用套路都是:先初始化、然後定義Mock對象,最後通過某種機制把定義好的Mock對象送回被測類,替換原本的被調用對象。

來個Mockito測試的實際代碼感受一下。

// 第一步:初始化 
Mockito@RunWith(MockitoJUnitRunner.class)
public class RecordServiceTest {         
    // 第二步:定義Mock對象    
    @Mock    
    DatabaseDAO databaseMock;
    
    // 第三步:定義測試用例    
    @Test    
    public void saveTest()    
    {        
        // 第四步:定義替代方法        
        when(databaseMock.write()).thenReturn(4);        
        // 第五步:注入Mock對象        
        RecordService recordService =            
            new RecordService(databaseMock);
            
        // 第六步:執行測試內容        
        boolean saved = recordService.save("demo");
            
        // 第七步:驗證測試結果        
        assertEquals(true, saved);        
        // 第八步:驗證Mock方法被執行        
        verify(databaseMock, times(1)).write();    
    }
}

根據不同的實現原理,將Mock對象送回被測方法的手段有許多種。

基於動態代理實現的Mockito比較符合直覺,但除了能用@InjectMocks支持 @Autowired注入的Spring Bean以外,幾乎沒提供太多黑魔法,因此要求用戶代碼要寫得「可測試」。若要換的對象沒用依賴注入機制,Mockito就幫不上忙了。

基於自定義類加載器的PowerMock能用@PrepareForTest繞進被測類里去替換Mock對象,但副作用是會讓Jacoco默認的on-the-fly模式測試覆蓋率會全部跌零。PowerMock的使用流程和Mockito十分 相似,只是功能更多了,開發者的學習曲線也變得更加陡峭。

基於動態字節碼修改實現的JMockit要技高一籌,它在不影響測試覆蓋率的情況下,僅通過「局部手術」就能讓被測方法裡的Mock目標「狸貓換太子」。不過,JMockit不僅要求每個用例的開頭和結尾採用固定結構,而且發明了一種並不太符合Java習慣的Mock定義語法,妥妥的將自己做成了一款「測試框架」。同樣看個例子。

// 第一步:初始化JMockit
@RunWith(JMockit.class)
public class PerformerTest {     
    // 第二步:定義Mock對象    
    @Mocked    
    private Collaborator collaborator; 
    
    // 第三步:定義被測對象
    // 隱含注入Mock對象邏輯    
    @Tested    
    private Performer performer;     
    
    // 第四步:定義測試用例    
    @Test    
    public void testThePerformMethod() {        
        // 第五步:定義替代方法        
        new Expectations() {{            
            collaborator.work("bar"); result = 10;        
        }};
        
        // 第六步:執行測試內容        
        boolean res = performer.perform("test");        
        // 第七步:驗證測試結果        
        assertEquals(true, res);
        
        // 第八步:驗證Mock方法被執行        
        new Verifications() {{            
            collaborator.receive(true);        
        }};    
    }
}

其餘幾款Mock工具使用流程基本雷同,不再列舉。這個神奇的規律表明,在任何完整的Mock測試過程里,我們都在習以為常的遵循一種固定的八段式結構。而且這八個步驟里,有五個都與Mock相關。

本來只是讓Mock工具客串一下外部依賴,怎麼它就喧賓奪主的掌控起整個測試結構了呢?

二 極簡的TestableMock

為了探索更輕量易用的Mock測試手段,我們嘗試給工具減負,讓Mock的定義和置換乾淨利落,最終設計了一款極簡風格的測試輔助工具TestableMock,開源地址:
https://github.com/alibaba/testable-mock。

在TestableMock的世界裡,Mock就是指定目標方法,定義替代實現,然後看著它在測試運行的時候被自動換掉,從頭至尾只需一個註解:@MockMethod。若將前面的第一個例子改成用TestableMock來實現,大概長這個樣子。

public class RecordServiceTest 
{         
    // 定義Mock目標和替代方法    
    // 約定Mock方法比原方法多一個參數,傳入調用者本身    
    // 因此是替換DatabaseDAO類的int write()方法調用    
    @MockMethod    
    int write(DatabaseDAO origin) { return 4; }
    
    // 定義測試用例    
    @Test    
    public void saveTest()    {        
        // 執行測試內容        
        RecordService rs = new RecordService();        
        boolean saved = rs.save("demo");
        
        // 驗證測試結果        
        assertEquals(true, saved);        
        // 驗證Mock方法被執行        
        TestableTool.verify("write").times(1);    
    }
}

一共五個步驟,與Mock相關的只有兩處。無需初始化框架,且Mock定義無需侵入測試用例,更無需開發者操心Mock方法如何注入。一切被@MockMethod註解安排的明明白白:在被測類中凡是調用DatabaseDAO對象write()方法的地方,統統變成空調用並且返回數值「4」。

與以往Mock工具總是要替換整個對象的思路不同,TestableMock直接替換目標方法,腦迴路無比簡單,這種簡化設計主要基於兩條基本假設:

  • 假設一:同一個測試類里,一個測試用例里需要Mock掉的方法,在其他測試用例里通常也都需要Mock。因為這些被Mock的方法往往訪問了不便於測試的外部依賴。
  • 假設二:需要Mock的調用都來自被測類的代碼。此假設是符合單元測試初衷的,即單元測試只應該關注當前單元的內部行為,單元外的邏輯應該被替換為Mock。

對於假設一,TestableMock允許有少量特例。比如上述Mock方法裡,如果僅對從save方法裡的write()調用進行Mock,可以使用TestableTool工具類進行輔助判斷。

@MockMethod
int write(DatabaseDAO origin) {    
    switch(TestableTool.SOURCE_METHOD) {        
        case "save": return 10;        
        default: return origin.write();    
    }
}

假設二通常不應該有特例,否則意味著是單元測試本身寫法有問題。

除此以外,TestableMock的「輕量」還體現在它不挑合作夥伴,代碼里沒有為任何運行框架或測試框架定製邏輯。不論項目使用Spring、JFinal還是Quarkus,不論測試使用JUnit4、JUnit5還是TestNG,不論覆蓋率統計使用Jacoco還是其他工具,都能輕鬆上崗。同時,除了Mock被測類中任意對象的方法調用,TestableMock還能Mock被測類自身的私有成員方法、靜態方法、以及new操作符。值得一提的是,new操作符的Mock方法返回的既可以是一個真實對象,也可以是一個經過動態代理包裝的Mock對象。但TestableMock並不負責生成此類Mock對象,因為在這方面,Mockito等傳統Mock工具已經做得足夠好了,可以直接拿來配合使用、取長補短。

同樣是Mock工具,TestableMock卻能將Mock所需的各種準備工作極大簡化,那麼它相比傳統Mock工具是否有什麼缺點呢?TestableMock並未引入重大的底層新技術,在軟體設計領域有一條不成名的定律:任何非顛覆式的改進都是一種trade-off,有得必有失。在TestableMock極簡的體驗背後,捨棄的其實就是不符合上述兩點假設的非典型使用場景。由於將Mock方法和測試用例分開定義,倘若Mock方法裡有太多需要區分調用來源的if和switch,就會使得代碼邏輯被打散、不便於閱讀。所幸,作為一位資深踩坑員,我可以告訴大家,這類特例並不常見。反而更常見的情況是有許多測試用例需要使用相同的Mock方法,此時將Mock定義獨立出來更加有助於減少重複代碼,因此結果通常都是利大於弊的。

三 TestableMock的原理

簡單來說,TestableMock利用了運行時字節碼修改技術,在單元測試啟動時掃描測試類和被測類的字節碼,完成Mock方法替換。

這一看似理所當然的技術選型背後,濃縮了TestableMock對功能齊備和極致輕量的雙重追求。

現實中的Java單元測試Mock工具原理主要有三類,其典型代表列舉如下:

  • 動態代理:Mockito、EasyMock、MockRunner
  • 自定義類加載器:PowerMock
  • 運行時字節碼修改:JMockit、TestableMock

在三種機制里,動態代理只在被測類的外周做手腳,不改動被測類本身,因此最安全,但功能也最弱。這類Mock工具對被Mock的方法比較挑剔,final類型、靜態方法、私有方法全都無法覆蓋。

自定義類加載器和動態字節碼修改都會修改被測類的字節碼,前者完全接管測試類的加載過程,後者則是在類加載完成後再對字節碼做「二次改造」。從功能而言,兩者沒有太大差異,都可以實現對幾乎任何類型和方法的Mock。兩者的主要差異在於機制的啟用方式,為了讓自定義類加載器生效,需要針對不同的測試框架進行有區分的特殊處理,譬如在JUnit中使用@RunWith註解。這一點體現在PowerMock上就表現為,與不同測試框架配合使用時,它的註解搭配是有明確區別的。

為了與測試框架完全解耦,TestableMock通過直接掃描測試類中是否存在@MockMethod(或者@MockConstructor)修飾的方法,來自動判斷是否要進行相應的初始化準備工作,實現了只需一個註解就能完成Mock初始化、定義和置換的極致體驗。加之以可復用的方法(而非整個類型)作為粒度執行Mock替換,整個過程對測試的代碼編寫毫無侵入。

除了以上的三種方法,是否還有別的Mock實現手段呢?其實TestableMock的早期版本還嘗試過一種做法:利用JSR-269規範的插件化註解處理器(Pluggable Annotation Processing)在代碼編譯期對被編譯的源碼進行修改。這種機制也能實現將源碼中的方法調用換成Mock調用的目的,但它帶來了兩個棘手的問題。一是修改過的源碼會被打包進最終生成的jar,導致生產包內容被篡改,此問題其實可通過在打包前增加一個class文件還原的步驟解決,但比較低效且並不優雅。另一個問題則是由於修改的是源碼,因此對每種JVM語言都要單獨實現,通用性不佳。TestableMock在疊代中逐步捨棄了基於JSR-269的Mock方案,轉而利用這種機制實現了另一項功能:被測類私有成員訪問。

四 超越Mock工具

TestableMock來自阿里云云效團隊,秉持雲效讓研發工作更簡單的理念,它所承載的職責是 「讓Java沒有難測的方法」,這也是TestableMock項目名字的由來。

除了獨具一格的Mock功能,TestableMock還提供了兩項單元測試增強能力:

讓單測用例可以直接訪問被測類的私有成員

「該不該測試私有方法」這個話題一直在Java單元測試的圈子裡頗有爭議。沒錯,僅集中於Java圈子,因為一些較新的程式語言,比如Python、Golang、Rust都從源頭上避免了這個爭論發生:Python的「私有方法」只是一種命名約定,Golang默認同包內所有方法皆可訪問,而Rust的單元測試是和被測代碼放在一起的。也就是說這些新式語言早都已經默認,單元測試可以訪問私有方法,怎麼舒服怎麼來。Java代碼由於要測試private方法就得將方法可見性改為default或者public,破壞了封裝,這根導火索引燃了面向對象保守派與實用主義激進派的意識形態之爭。可是程式設計師何必為難程式設計師,「通過公有方法間接測試私有方法」在實際操作的時候只會讓編寫測試者非常蛋疼。TestableMock為測試類準備了一個@EnablePrivateAccess註解來快速實現可訪問性的增強,使所有在測試類中訪問相應被測類的私有成員代碼都會在編譯期被自動改為合法的反射調用,而訪問其他類的私有方法則依然不被允許,該限制的地方限制,該放寬的地方放寬。

輔助測試沒有返回值的void類型方法

「沒返回值的方法怎麼測試」這是個業界並無太大觀點分歧,卻也至今尚未出現簡單實用解決方案的技術課題。值得指出的是,void類型方法雖然不會直接返回計算結果,但一定會在其內部引起某種全局狀態改變或引發某種「函數副作用」,比如輸出日誌、調用外部系統等等。既不返回數據也不產生任何副作用的方法毫無價值。通過TestableMock的私有成員訪問機制和Mock驗證器功能,可以快速驗證被測類的內部狀態變化,或是驗證測方法中產生副作用的調用語句是否被正確執行且傳入了預期的參數值。至此,Java項目void類型方法難以測試的歷史或許將被終結 。

五 總結

功能比PowerMock毫不遜色,用法比Mockito更加簡潔,不挑框架,指哪換哪,一個@MockMethod註解打天下。

單元測試是保障代碼可重構和抗腐化的一種有效手段,但在實踐的過程中,許多開發者最終被單元測試的條條框框與編寫成本擊退。實用主義單測增強工具TestableMock在提供萬能Mock注入能力的同時,將單元測試編寫的各方面成本均拉到了歷史新低點 。

讓Mock返璞歸真,讓測試告別繁瑣。項目開源地址:
https://github.com/alibaba/testable-mock

文章連結:https://developer.aliyun.com/article/780287

關鍵字: