史上最轻量!阿里新型单元测试Mock工具开源了
最簡單舒適的Mock測試應(yīng)該是怎樣的?
指著源文件調(diào)用了外部依賴的那行代碼說:
“你,在測試的時候,換成這個假的調(diào)用!”
結(jié)束。
甭管他是私有方法、靜態(tài)方法,還是別的類的方法,直接換掉,不要有任何多余動作。
一 Mock測試八股文
Java的Mock工具伴隨著單元測試技術(shù)不斷迭代發(fā)展,可謂前仆后繼、歷久彌新,雖然原理各不相同,但核心的使用模式卻幾乎沒發(fā)生過多少變化。不論是當(dāng)下流行的Mockito和PowerMock,或是曾經(jīng)著名的JMockit、EasyMock、MockRunner等等,基本使用套路都是:先初始化、然后定義Mock對象,最后通過某種機制把定義好的Mock對象送回被測類,替換原本的被調(diào)用對象。
來個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);// 第六步:執(zhí)行測試內(nèi)容 boolean saved = recordService.save("demo");// 第七步:驗證測試結(jié)果 assertEquals(true, saved); // 第八步:驗證Mock方法被執(zhí)行 verify(databaseMock, times(1)).write(); } }根據(jù)不同的實現(xiàn)原理,將Mock對象送回被測方法的手段有許多種。
基于動態(tài)代理實現(xiàn)的Mockito比較符合直覺,但除了能用@InjectMocks支持 @Autowired注入的Spring Bean以外,幾乎沒提供太多黑魔法,因此要求用戶代碼要寫得“可測試”。若要換的對象沒用依賴注入機制,Mockito就幫不上忙了。
基于自定義類加載器的PowerMock能用@PrepareForTest繞進被測類里去替換Mock對象,但副作用是會讓Jacoco默認(rèn)的on-the-fly模式測試覆蓋率會全部跌零。PowerMock的使用流程和Mockito十分 相似,只是功能更多了,開發(fā)者的學(xué)習(xí)曲線也變得更加陡峭。
基于動態(tài)字節(jié)碼修改實現(xiàn)的JMockit要技高一籌,它在不影響測試覆蓋率的情況下,僅通過“局部手術(shù)”就能讓被測方法里的Mock目標(biāo)“貍貓換太子”。不過,JMockit不僅要求每個用例的開頭和結(jié)尾采用固定結(jié)構(gòu),而且發(fā)明了一種并不太符合Java習(xí)慣的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; }};// 第六步:執(zhí)行測試內(nèi)容 boolean res = performer.perform("test"); // 第七步:驗證測試結(jié)果 assertEquals(true, res);// 第八步:驗證Mock方法被執(zhí)行 new Verifications() {{ collaborator.receive(true); }}; } }其余幾款Mock工具使用流程基本雷同,不再列舉。這個神奇的規(guī)律表明,在任何完整的Mock測試過程里,我們都在習(xí)以為常的遵循一種固定的八段式結(jié)構(gòu)。而且這八個步驟里,有五個都與Mock相關(guān)。
本來只是讓Mock工具客串一下外部依賴,怎么它就喧賓奪主的掌控起整個測試結(jié)構(gòu)了呢?
二 極簡的TestableMock
為了探索更輕量易用的Mock測試手段,我們嘗試給工具減負,讓Mock的定義和置換干凈利落,最終設(shè)計了一款極簡風(fēng)格的測試輔助工具TestableMock,開源地址:
https://github.com/alibaba/testable-mock。
在TestableMock的世界里,Mock就是指定目標(biāo)方法,定義替代實現(xiàn),然后看著它在測試運行的時候被自動換掉,從頭至尾只需一個注解:@MockMethod。若將前面的第一個例子改成用TestableMock來實現(xiàn),大概長這個樣子。
public class RecordServiceTest { // 定義Mock目標(biāo)和替代方法 // 約定Mock方法比原方法多一個參數(shù),傳入調(diào)用者本身 // 因此是替換DatabaseDAO類的int write()方法調(diào)用 @MockMethod int write(DatabaseDAO origin) { return 4; }// 定義測試用例 @Test public void saveTest() { // 執(zhí)行測試內(nèi)容 RecordService rs = new RecordService(); boolean saved = rs.save("demo");// 驗證測試結(jié)果 assertEquals(true, saved); // 驗證Mock方法被執(zhí)行 TestableTool.verify("write").times(1); } }一共五個步驟,與Mock相關(guān)的只有兩處。無需初始化框架,且Mock定義無需侵入測試用例,更無需開發(fā)者操心Mock方法如何注入。一切被@MockMethod注解安排的明明白白:在被測類中凡是調(diào)用DatabaseDAO對象write()方法的地方,統(tǒng)統(tǒng)變成空調(diào)用并且返回數(shù)值“4”。
與以往Mock工具總是要替換整個對象的思路不同,TestableMock直接替換目標(biāo)方法,腦回路無比簡單,這種簡化設(shè)計主要基于兩條基本假設(shè):
- 假設(shè)一:同一個測試類里,一個測試用例里需要Mock掉的方法,在其他測試用例里通常也都需要Mock。因為這些被Mock的方法往往訪問了不便于測試的外部依賴。
- 假設(shè)二:需要Mock的調(diào)用都來自被測類的代碼。此假設(shè)是符合單元測試初衷的,即單元測試只應(yīng)該關(guān)注當(dāng)前單元的內(nèi)部行為,單元外的邏輯應(yīng)該被替換為Mock。
對于假設(shè)一,TestableMock允許有少量特例。比如上述Mock方法里,如果僅對從save方法里的write()調(diào)用進行Mock,可以使用TestableTool工具類進行輔助判斷。
@MockMethod int write(DatabaseDAO origin) { switch(TestableTool.SOURCE_METHOD) { case "save": return 10; default: return origin.write(); } }假設(shè)二通常不應(yīng)該有特例,否則意味著是單元測試本身寫法有問題。
除此以外,TestableMock的“輕量”還體現(xiàn)在它不挑合作伙伴,代碼里沒有為任何運行框架或測試框架定制邏輯。不論項目使用Spring、JFinal還是Quarkus,不論測試使用JUnit4、JUnit5還是TestNG,不論覆蓋率統(tǒng)計使用Jacoco還是其他工具,都能輕松上崗。同時,除了Mock被測類中任意對象的方法調(diào)用,TestableMock還能Mock被測類自身的私有成員方法、靜態(tài)方法、以及new操作符。值得一提的是,new操作符的Mock方法返回的既可以是一個真實對象,也可以是一個經(jīng)過動態(tài)代理包裝的Mock對象。但TestableMock并不負責(zé)生成此類Mock對象,因為在這方面,Mockito等傳統(tǒng)Mock工具已經(jīng)做得足夠好了,可以直接拿來配合使用、取長補短。
同樣是Mock工具,TestableMock卻能將Mock所需的各種準(zhǔn)備工作極大簡化,那么它相比傳統(tǒng)Mock工具是否有什么缺點呢?TestableMock并未引入重大的底層新技術(shù),在軟件設(shè)計領(lǐng)域有一條不成名的定律:任何非顛覆式的改進都是一種trade-off,有得必有失。在TestableMock極簡的體驗背后,舍棄的其實就是不符合上述兩點假設(shè)的非典型使用場景。由于將Mock方法和測試用例分開定義,倘若Mock方法里有太多需要區(qū)分調(diào)用來源的if和switch,就會使得代碼邏輯被打散、不便于閱讀。所幸,作為一位資深踩坑員,我可以告訴大家,這類特例并不常見。反而更常見的情況是有許多測試用例需要使用相同的Mock方法,此時將Mock定義獨立出來更加有助于減少重復(fù)代碼,因此結(jié)果通常都是利大于弊的。
三 TestableMock的原理
簡單來說,TestableMock利用了運行時字節(jié)碼修改技術(shù),在單元測試啟動時掃描測試類和被測類的字節(jié)碼,完成Mock方法替換。
這一看似理所當(dāng)然的技術(shù)選型背后,濃縮了TestableMock對功能齊備和極致輕量的雙重追求。
現(xiàn)實中的Java單元測試Mock工具原理主要有三類,其典型代表列舉如下:
- 動態(tài)代理:Mockito、EasyMock、MockRunner
- 自定義類加載器:PowerMock
- 運行時字節(jié)碼修改:JMockit、TestableMock
在三種機制里,動態(tài)代理只在被測類的外周做手腳,不改動被測類本身,因此最安全,但功能也最弱。這類Mock工具對被Mock的方法比較挑剔,final類型、靜態(tài)方法、私有方法全都無法覆蓋。
自定義類加載器和動態(tài)字節(jié)碼修改都會修改被測類的字節(jié)碼,前者完全接管測試類的加載過程,后者則是在類加載完成后再對字節(jié)碼做“二次改造”。從功能而言,兩者沒有太大差異,都可以實現(xiàn)對幾乎任何類型和方法的Mock。兩者的主要差異在于機制的啟用方式,為了讓自定義類加載器生效,需要針對不同的測試框架進行有區(qū)分的特殊處理,譬如在JUnit中使用@RunWith注解。這一點體現(xiàn)在PowerMock上就表現(xiàn)為,與不同測試框架配合使用時,它的注解搭配是有明確區(qū)別的。
為了與測試框架完全解耦,TestableMock通過直接掃描測試類中是否存在@MockMethod(或者@MockConstructor)修飾的方法,來自動判斷是否要進行相應(yīng)的初始化準(zhǔn)備工作,實現(xiàn)了只需一個注解就能完成Mock初始化、定義和置換的極致體驗。加之以可復(fù)用的方法(而非整個類型)作為粒度執(zhí)行Mock替換,整個過程對測試的代碼編寫毫無侵入。
除了以上的三種方法,是否還有別的Mock實現(xiàn)手段呢?其實TestableMock的早期版本還嘗試過一種做法:利用JSR-269規(guī)范的插件化注解處理器(Pluggable Annotation Processing)在代碼編譯期對被編譯的源碼進行修改。這種機制也能實現(xiàn)將源碼中的方法調(diào)用換成Mock調(diào)用的目的,但它帶來了兩個棘手的問題。一是修改過的源碼會被打包進最終生成的jar,導(dǎo)致生產(chǎn)包內(nèi)容被篡改,此問題其實可通過在打包前增加一個class文件還原的步驟解決,但比較低效且并不優(yōu)雅。另一個問題則是由于修改的是源碼,因此對每種JVM語言都要單獨實現(xiàn),通用性不佳。TestableMock在迭代中逐步舍棄了基于JSR-269的Mock方案,轉(zhuǎn)而利用這種機制實現(xiàn)了另一項功能:被測類私有成員訪問。
四 超越Mock工具
TestableMock來自阿里云云效團隊,秉持云效讓研發(fā)工作更簡單的理念,它所承載的職責(zé)是 “讓Java沒有難測的方法”,這也是TestableMock項目名字的由來。
除了獨具一格的Mock功能,TestableMock還提供了兩項單元測試增強能力:
讓單測用例可以直接訪問被測類的私有成員
“該不該測試私有方法”這個話題一直在Java單元測試的圈子里頗有爭議。沒錯,僅集中于Java圈子,因為一些較新的編程語言,比如Python、Golang、Rust都從源頭上避免了這個爭論發(fā)生:Python的“私有方法”只是一種命名約定,Golang默認(rèn)同包內(nèi)所有方法皆可訪問,而Rust的單元測試是和被測代碼放在一起的。也就是說這些新式語言早都已經(jīng)默認(rèn),單元測試可以訪問私有方法,怎么舒服怎么來。Java代碼由于要測試private方法就得將方法可見性改為default或者public,破壞了封裝,這根導(dǎo)火索引燃了面向?qū)ο蟊J嘏膳c實用主義激進派的意識形態(tài)之爭。可是程序員何必為難程序員,“通過公有方法間接測試私有方法”在實際操作的時候只會讓編寫測試者非常蛋疼。TestableMock為測試類準(zhǔn)備了一個@EnablePrivateAccess注解來快速實現(xiàn)可訪問性的增強,使所有在測試類中訪問相應(yīng)被測類的私有成員代碼都會在編譯期被自動改為合法的反射調(diào)用,而訪問其他類的私有方法則依然不被允許,該限制的地方限制,該放寬的地方放寬。
輔助測試沒有返回值的void類型方法
“沒返回值的方法怎么測試”這是個業(yè)界并無太大觀點分歧,卻也至今尚未出現(xiàn)簡單實用解決方案的技術(shù)課題。值得指出的是,void類型方法雖然不會直接返回計算結(jié)果,但一定會在其內(nèi)部引起某種全局狀態(tài)改變或引發(fā)某種“函數(shù)副作用”,比如輸出日志、調(diào)用外部系統(tǒng)等等。既不返回數(shù)據(jù)也不產(chǎn)生任何副作用的方法毫無價值。通過TestableMock的私有成員訪問機制和Mock驗證器功能,可以快速驗證被測類的內(nèi)部狀態(tài)變化,或是驗證測方法中產(chǎn)生副作用的調(diào)用語句是否被正確執(zhí)行且傳入了預(yù)期的參數(shù)值。至此,Java項目void類型方法難以測試的歷史或許將被終結(jié) 。
五 總結(jié)
功能比PowerMock毫不遜色,用法比Mockito更加簡潔,不挑框架,指哪換哪,一個@MockMethod注解打天下。
單元測試是保障代碼可重構(gòu)和抗腐化的一種有效手段,但在實踐的過程中,許多開發(fā)者最終被單元測試的條條框框與編寫成本擊退。實用主義單測增強工具TestableMock在提供萬能Mock注入能力的同時,將單元測試編寫的各方面成本均拉到了歷史新低點 。
讓Mock返璞歸真,讓測試告別繁瑣。項目開源地址:
https://github.com/alibaba/testable-mock
原文鏈接:https://developer.aliyun.com/article/780287?
版權(quán)聲明:本文內(nèi)容由阿里云實名注冊用戶自發(fā)貢獻,版權(quán)歸原作者所有,阿里云開發(fā)者社區(qū)不擁有其著作權(quán),亦不承擔(dān)相應(yīng)法律責(zé)任。具體規(guī)則請查看《阿里云開發(fā)者社區(qū)用戶服務(wù)協(xié)議》和《阿里云開發(fā)者社區(qū)知識產(chǎn)權(quán)保護指引》。如果您發(fā)現(xiàn)本社區(qū)中有涉嫌抄襲的內(nèi)容,填寫侵權(quán)投訴表單進行舉報,一經(jīng)查實,本社區(qū)將立刻刪除涉嫌侵權(quán)內(nèi)容。總結(jié)
以上是生活随笔為你收集整理的史上最轻量!阿里新型单元测试Mock工具开源了的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 车主无忧:为什么放弃开源Kafka?
- 下一篇: 聚焦实战,架构升级!