簡介:為了探索更輕量易用的Mock測驗手段,阿里云云效團隊嘗試給工具減負,在主流Mock工具的基礎上讓Mock的定義和置換干凈利落,最終設計了一款極簡風格的測驗輔助工具TestableMock,無需初始化,不挑測驗框架,無論要換的是私有方法、靜態方法、構造方法還是其他任何類的任何方法,也無需關注要換的物件是怎么創建的,只要寫好Mock定義,加個@MockMethod注解,就能統統搞定,本文分享TestableMock的實作原理,

最簡單舒適的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?
著作權宣告:本文內容由阿里云實名注冊用戶自發貢獻,著作權歸原作者所有,阿里云開發者社區不擁有其著作權,亦不承擔相應法律責任,具體規則請查看《阿里云開發者社區用戶服務協議》和《阿里云開發者社區知識產權保護指引》,如果您發現本社區中有涉嫌抄襲的內容,填寫侵權投訴表單進行舉報,一經查實,本社區將立刻洗掉涉嫌侵權內容,
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/239623.html
標籤:其他
上一篇:詳解什么是中臺?
下一篇:SpringBoot使用攔截器
