![]() 本文為『移動前線』群在4月23日的分享總結(jié)整理而成,轉(zhuǎn)載請注明來自『移動開發(fā)前線』公眾號。
大家好,我是蘑菇街支付金融部門的鄒勇,花名叫小創(chuàng)。今天很高興跟大家分享一下安卓的單元測試在蘑菇街支付金融的實踐。下面,我們從為什么開始。 為什么要寫單元測試首先要介紹為什么蘑菇街支付金融這邊會采用單元測試的實踐。說起來比較巧,剛開始的時候,只是我一個人會寫單元測試。后來老板們知道了,覺得這是件很有價值的事情,于是就叫我負(fù)責(zé)我們組的單元測試這件事情。就這樣慢慢的,單元測試這件事情就成了我們這邊的正常實踐了。再后來,在公司層面也開始有一定的推廣。 要說為什么要寫單元測試的話,我相信大部分人都能承認(rèn)、也能理解單元測試在保證代碼質(zhì)量,防止bug或盡早發(fā)現(xiàn)bug這方面的作用,這可能是大家覺得單元測試最大的作用。然而我覺得,除了這方面的作用,單元測試還能在很大程度上改善代碼的設(shè)計,同時還能節(jié)約時間,讓人工作起來更自信、更開心,以及其他的一些好處。這些都是我的切身感受,我相信也是多數(shù)真正實踐過單元測試的人的切身感受,而不是為了宣傳這個東西而說的好聽的大話。 說到節(jié)約時間,大家可能就會好奇了,寫單元測試需要時間,維護(hù)單元測試代碼也需要時間,應(yīng)該更費(fèi)時間才對??? 這就是在開始分享之前,我想重點(diǎn)澄清的一點(diǎn),那就是,單元測試本身其實不會占用多少時間,相反,還會節(jié)約時間。只是:
打個比方,開車這件事情,需要很多時間嗎?我相信很少人會說開車這件事情需要很多時間,而是:
那為什么說單元測試可以節(jié)約時間呢?簡單說幾點(diǎn):
所以,我希望大家能去掉"沒時間寫單元測試"這個印象,如果工作上安排太緊,沒有時間學(xué)習(xí)如何做單元測試的話,可以自己私底下學(xué),然后在慢慢應(yīng)用到項目中。 單元測試簡單介紹接下來介紹我們這邊是怎么做安卓單元測試的。首先澄清一下概念,在安卓上面寫測試,有很多技術(shù)方案。有JUnit、Instrumentation test、Espresso、UiAutomator等等,還有第三方的Appium、Robotium、Calabash等等。我們現(xiàn)在講的是使用JUnit和其他的一些框架,寫可以在我們開發(fā)環(huán)境的JVM上面直接運(yùn)行的單元測試,其他的幾種其實都不屬于單元測試,而是集成測試或者叫Functional test等等。這兩者明顯的不同是,前者可以直接在開發(fā)用的電腦,或者是CI上面的JVM上運(yùn)行,而且可以只運(yùn)行那么一小部分代碼,速度非常快。而后者必須要有模擬器或真機(jī),把整個project打包成一個app,然后上傳到模擬器或真機(jī)上,再運(yùn)行相關(guān)的代碼,速度相對來說慢很多。 單元測試的定義相信大家都知道,就是為我們寫的某一個代碼單元(比如一個方法)寫的測試代碼。一個單元測試大概可以分為三個部分:
然而一個類的方法分兩種,一種是有返回值的方法。一種是沒有返回值的方法,即void方法。對于有返回值的方法,固然測試起來是很容易的,但是對于沒有返回值的方法,該怎么測試呢?這里的關(guān)鍵是,怎么樣獲取這個方法的“返回結(jié)果”? 這里舉一個例子來說明一下,順便澄清一個十分常見的誤解。比如說有一個Activity,管他叫DataActivity,它有一個public void loadData()方法, 會去調(diào)用底層的DataModel類,異步的執(zhí)行一些網(wǎng)絡(luò)請求。當(dāng)網(wǎng)絡(luò)請求返回以后,更新用戶界面。 這里的loadData()方法是void的,它該怎么測試呢?一個最直接的反應(yīng)可能是,調(diào)用loadData()方法(當(dāng)然,實際可能是通過其他事件觸發(fā)),然后一段時間后,驗證界面得到了更新。然而這種方法是錯的,這種測試叫集成測試,而不是單元測試。因為它涉及到很多個方面,它涉及到DataModel、網(wǎng)絡(luò)服務(wù)器,以及網(wǎng)絡(luò)返回正確時,DataActivity內(nèi)部的處理,等等。集成測試固然有它的必要性,但不是我們應(yīng)該最關(guān)注的地方,也不是最有價值的地方。我們應(yīng)該最關(guān)注的是單元測試。關(guān)于這一點(diǎn),有一個Test Pyramid的理論: Test Pyramid理論基本大意是,單元測試是基礎(chǔ),是我們應(yīng)該花絕大多數(shù)時間去寫的部分,而集成測試等應(yīng)該是冰山上面能看見的那一小部分。 那么對于這個case,正確的單元測試方法,應(yīng)該是去驗證loadData()方法調(diào)用了DataModel的某個請求數(shù)據(jù)的方法,同時傳遞的參數(shù)是正確的?!罢{(diào)用了DataModel的方法,同時參數(shù)是。。。” 這個才是loadData()這個方法的“返回結(jié)果”。 Mock的概念以及Mockito框架要驗證某個對象的某個方法得到調(diào)用了,就涉及到mock的使用。這里對mock的概念做個簡單介紹,以免很多同學(xué)不熟悉,mock就是創(chuàng)建一個虛假的、模擬的對象。在測試環(huán)境下,用來替換掉真實的對象。這樣就能達(dá)到兩個目的:
要使用mock,一般需要使用mock框架,目前安卓最常用的有兩個,Mockito和JMockit。兩者的區(qū)別是,前者不能mock static method和final class、final method,后者可以。我們依然采用的是Mockito,原因說起來慚愧,是因為剛開始并不知道JMockit這個東西,后來查了一些資料,看過很多對比Mockito和JMockit的文章,貌似大部分還是很看好JMockit的,只是有一個問題,那就是跟robolectric的結(jié)合也有一些bug,同時使用姿勢跟Mockito有較大的不同,因此一直沒有抽時間去實踐過。這個希望以后能夠做進(jìn)一步的調(diào)查,到時候在給大家分享一下使用感受。 但是使用Mockito,就有一個問題,那就是static method和final class、final method沒有辦法mock,對于這點(diǎn)如何解決,我們稍后會介紹到。 在測試環(huán)境中使用mock:依賴注入接下來的一個問題就是,如何在測試環(huán)境下,把DataModel換成mock的對象,而正式代碼中,DataModel又是正常的對象呢? 這個問題也有兩種解決方案,一是使用專門的testing product flavor;二是使用依賴注入。第一種方案就是用一個專門的product flavor來做testing,在這個testing flavor里面,里面把需要mock的類寫一份mock的implementation,然后通過factory提供給client,這個factory的接口在testing flavor和正式的flavor里面是一樣的,在跑testing的時候,專門使用這個testing flavor,這樣通過factory得到的就是mock的類。這種情況看起來很簡單,但其實很不靈活,因為只有一種mock實現(xiàn);此外,代碼會變得很丑陋,因為你需要為每一個dependency提供一個factory,會覺得很刻意;再者,多了一個flavor,很多gradle任務(wù)都會變得很慢。關(guān)于這種方案,可以參考這個視頻(https://www./watch?v=vdasFFfXKOY)。 因此,我們用的是第二種,依賴注入。先簡單介紹一下依賴注入這個模式,他的基本理念是,某一個類(比如說DataActivity),用到的內(nèi)部對象(比如說DataModel)的創(chuàng)建過程不在DataActivity內(nèi)部去new,而是由外部去創(chuàng)建好DataModel的實例,然后通過某種方式set給DataActivity。這種模式應(yīng)用是非常廣泛的,尤其是在測試的時候。為了更方便的做依賴注入,如今有很多框架專門做這件事情,比如RoboGuice、Dagger、Dagger2等等。我們用的是Dagger2,理由很簡單,這是目前最好用的DI框架。 關(guān)于Dagger2的文章,之前我們?nèi)豪镆卜窒砹瞬簧?,但是好像我并沒有看到講述沒有關(guān)于如何在測試環(huán)境下使用Dagger2的文章,這個還是略感遺憾的。離開單元測試,使用依賴注入就少了很有說服力的一個理由。 那么這里我就介紹一下,怎么樣把Dagger2應(yīng)用到單元測試中。熟悉dagger2的童靴可能知道,Dagger2里面最關(guān)鍵的有兩個概念,Module和Component。Module是負(fù)責(zé)生成諸如DataModel這樣被別人(比如DataActivity)使用的類的地方。用術(shù)語的話,被別人使用的類DataModel叫Dependency,使用到了別的類的類DataActivity叫Client。而Component則是供Client使用Dependency的統(tǒng)一接口。也就是說,DataActivity通過Component,來得到一份DataModel的實例。 現(xiàn)在,關(guān)鍵的地方來了,Component本身是不生產(chǎn)dependency的,它只是搬運(yùn)工而已,真正生產(chǎn)dependency的地方在Module。所以,創(chuàng)建Component需要用到Module,不同的Module生產(chǎn)出不同的dependency。在正式代碼里面,我們使用正常的Module,生產(chǎn)正常的DataModel。而在測試環(huán)境中,我們寫一個TestingModule,讓它繼承正常的Module,然后override掉生產(chǎn)DataModel的方法,讓它生產(chǎn)mock的DataModel。在跑單元測試的時候,使用這個TestingModule來創(chuàng)建Component,這樣的話,DataActivity通過Component得到的DataModel對象就是mock出來的DataModel對象。 使用這種方式,所有production code都不用專門為testing增加任何多余的代碼,同時還能得到依賴注入的其他好處。 Robolectric:解決Android單元測試最大的痛點(diǎn)接下來講講Android單元測試最大的痛點(diǎn),那就是JVM上面運(yùn)行純JUnit單元測試時是不能使用Android相關(guān)的類的,因為我們開發(fā)用到的安卓環(huán)境是沒有實現(xiàn)的,里面只定義了一些接口,所有方法的實現(xiàn)都是throw new RuntimeException("stub");,如果我們單元測試代碼里面用到了安卓相關(guān)的代碼的話,那么運(yùn)行時就會遇到RuntimeException("Stub")。 要解決這個問題,一般來說有三種方案:
第一種方案能work,但是速度非常慢,因為每次運(yùn)行一次單元測試,都需要將整個項目打包成apk,上傳到模擬器或真機(jī)上,就跟運(yùn)行了一次app似得,這個顯然不是單元測試該有的速度,更無法做TDD。這種方案首先被否決。 剛開始,我們采用的是Robolectric,原因有兩個:
然而慢慢的,我們的態(tài)度從擁抱Robolectric,到盡量不用它,盡量使用純java代碼去實現(xiàn)。可能大家覺得安卓相關(guān)的代碼會很多,而純java的很少,然而慢慢的你會發(fā)現(xiàn),其實不是這樣的,純java的代碼其實真不少,而且往往是核心的邏輯所在。之所以盡量不用Robolectric,是因為Robolectric雖然相對于Instrumentation testing來說快多了。但畢竟他也需要merge一些資源,build出來一個模擬的app,因此相對于純java和JUnit來說,這個速度依然是很慢的。 用具體的數(shù)字來對比說明:
當(dāng)然,雖然運(yùn)行一次Robolectric在10秒左右,但是對比運(yùn)行一次app,還是要快太多。因此,剛開始的時候,從Robolectric開始完全是OK的。 以上就是現(xiàn)在我們這邊單元測試用到的幾個基本技術(shù):JUnit4 + Mockito + Dagger2 + Robolectric?;緛碚f,并沒有什么黑科技,都是業(yè)界標(biāo)準(zhǔn)。 一個具體的案例接下來,我通過一個具體的案例,跟大家介紹一下,我們這邊的一個app,具體是怎么單測的。 這里是我們收銀臺界面的樣子: 假設(shè)Activity名字為CheckoutActivity,當(dāng)它啟動的時候,CheckoutActivity會去調(diào)一個CheckoutModel的loadCheckoutData()方法,這個方法又會去調(diào)更底層的一個封裝了用戶認(rèn)證等信息的網(wǎng)絡(luò)請求Api類(mApi)的get方法,同時傳給這個Api類一個callback。這個callback的做的事情是將結(jié)果通過Otto Bus(mBus) post出去。CheckoutActivity里面Subscribe了這個Event(方法名是onCheckoutDataLoaded()),然后根據(jù)Event的值相應(yīng)的顯示數(shù)據(jù)或錯誤信息。 代碼簡寫如下: 這里,CheckoutActivity里面的mCheckoutModel、CheckoutModel里面的mApi、CheckoutModel里面的mBus,都是通過Dagger2注入進(jìn)去的。在做單元測試的時候,這些都是mock。 對于這個流程,我們做了如下的單元測試:
這里需要說明的一點(diǎn)是,上面的每一個測試,都是獨(dú)立進(jìn)行的,不是說下面的單元測試依賴于上面的?;蛘哒f必須先做上面的,再做下面的。 這部分較為詳細(xì)的代碼放在github(https://github.com/ChrisZou/android-unit-testing-tutorial)上,groupshare這個package里面。 其他的問題以上就是我們這邊做單元測試用到的技術(shù),以及一個基本流程,下面聊聊其他的幾個問題。 哪些東西需要測試呢?
CI和code coverage: Jacoco要把單元測試正式化,CI是非常重要的一步,我們有一個運(yùn)行Jenkins的CI server,每次開發(fā)者push代碼到master branch的時候,會運(yùn)行一次單元測試的gradle task,同時使用Jacoco做code coverage。 這里有個坑要特別注意,那就是項目里面的gradle Jacoco插件和Jenkins的Jacoco插件的兼容性問題。我們用的gradle Jacoco插件是7.1,更高版本的好像有問題。然后對應(yīng)的Jenkins的Jacoco插件需要1.0.19或更低版本的,更高版本的jenkins plugin不支持低版本的gradle Jacoco項目版本。實際上,這點(diǎn)在Jenkins的Jacoco插件首頁就有說明: (點(diǎn)擊放大圖像) 但是我當(dāng)時沒注意,所以覆蓋率數(shù)據(jù)一直出不來,折騰了好一會,最后還是在同事的幫助下找到問題了。 遇到的坑,以及好的practice建議接下來講講我們遇到的一些坑,以及一些好的practice建議。 1. Native libary無論是純JUnit還是Robolectric,都不支持load native library,會報UnsatisfiedLinkError的錯。所以如果你的被測代碼里面用到了native lib,那么可能需要給System.loadLibrary加上try catch。 如果是被測代碼用到的第三方lib,而里面用到了native lib的話,一般有兩種解決辦法,一種是將用到native lib的第三方類外面自己在包一層,然后在測試的情況下mock掉。第二種是用Robolectric,給那個類創(chuàng)建一個shadow class。 第一種方法的好處是可以在測試的時候隨時改變這個類的返回值或行為,缺點(diǎn)是需要另外創(chuàng)建一個wrapper類,會有點(diǎn)繁瑣。第二種方式不能隨時改變這個類的行為,但是寫起來非常簡單。所以,看自己的需要,選擇相應(yīng)的方法。 這兩種方法,也是解決static method, final class/method不能mock的主要方式。 2. 盡量寫出易于測試的代碼static method、直接new object、singleton、Global state等等這些都是一些不利于測試的代碼方式,應(yīng)該盡量避免,用依賴注入來代替這些方式。 3. 不要重復(fù)你的unit test比如說你使用了一個builder模式來創(chuàng)建了一個類,這個builder有一個validator,來validate一些參數(shù)情況。那么這種情況,builder跟validator分開測,用各種正確的錯誤的參數(shù)情況去測試validator,然后測builder的時候,就不用遍歷各種有效的跟無效的參數(shù)去測試了。 因為如果這樣的話,到時候Validator的邏輯改了,那么針對Validator的測試跟針對Builder的測試都要修改,這個其實是重復(fù)的。這里只需要測試這個builder里面有一個Validator就好了。 4. 公共的單元測試library如果你們公司也是組件化開發(fā)的話,抽出一個公共的單元測試類庫來做單元測試,里面可以放一些公共的helper、utils、rules等等,這個可以極大的提高寫單元測試的速度。 5. 把安卓里面的“純java”代碼copy一份到自己的項目里面安卓里面有些類其實跟安卓沒太大關(guān)系的,比如說TextUtils、Color等等,這些類完全可以把代碼copy出來,放到自己的項目里面,然后其他地方就用這個類,這樣也能部分?jǐn)[脫android的依賴,使用JUnit而不是Robolectric,提高運(yùn)行test的速度。 6. 充分發(fā)揮JUnit Rule的作用JUnit Rule是個很強(qiáng)大的工具,然而知道的人卻不多。它的基本作用是,讓你在執(zhí)行某個測試方法前后,可以做一些事情。如果你的好幾個測試類里面有很多的共同的setup、teardown工作,你可能會傾向于使用繼承,結(jié)合@Before、@After來減少duplication,這里更建議大家使用JUnit Rule來實現(xiàn)這個目的,而不是用繼承,這樣可以有更大的靈活性。 比如,為了方便測試Activity的method,我們有一個ActivityRule,在跑一個測試方法之會啟動target Activity,然后跑完以后自動finish這個activity。 其中一個比較有趣的用JUnit Rule實現(xiàn)的功能,是實現(xiàn)類似于BDD測試框架的命名方式。做單元測試的時候,你經(jīng)常需要為同一個方法寫好幾個測試方法,每個測試方法測試不同的點(diǎn)。為了讓命名更具可讀性,我們往往會把名字寫的很長,在這種情況下,如果用駝峰命名的話,需要不斷切換大小寫,寫起來麻煩,可讀性也不高。如果用下劃線的話,寫起來也很麻煩。如果你使用過BDD的一些框架(比如RSpec、Cucumber、Jasmine等),你就會異常懷念那種“命名”方式。如果你沒用過的話,那種“命名”方式大概是這樣的: 這里的關(guān)鍵是,當(dāng)測試方法失敗的時候,這個字符串是要能被加到錯誤信息里面的。我們做了個JUnit Rule來達(dá)到這個效果。做法是結(jié)合一個自定義的annotation,這個annotation接收一個String,來描述這個測試方法的測試目的。在Rule里面將這個annotation讀出來,如果測試沒通過的話,把這個描述性的String加到輸出的error message里面。這樣在批量運(yùn)行的時候,一看就知道沒通過的測試是測什么東西的。而測試方法的命名則可以比較隨意。 如果運(yùn)行失敗,得到如下的結(jié)果 關(guān)于JUnit Rule的使用,大家可以自行g(shù)oogle一下,也不難。 7. 善于利用AndroidStudio來加快你寫測試的速度AndroidStudio有很多feature可以幫助我們更快的寫代碼,比如code generation和live template。這點(diǎn)對于寫正式代碼也適用,不過對于寫測試代碼來說,效果更為突出。因為大部分測試代碼的結(jié)構(gòu)、風(fēng)格都是類似的,在這里live template能起非常大的作用。此外,如果你先寫測試,可以直接寫一些還不存在的Class或method,然后alt+enter讓AndroidStudio自動幫你生成。 8. 不要追求完美剛開始的時候,不用追求測試代碼的質(zhì)量,也不用追求完美,如果有些地方不好寫測試,可以先放放,以后再來補(bǔ),有部分測試總比沒有測試好。Martin Fowler說過
然而等你熟悉寫測試的方法以后,強(qiáng)烈建議先寫測試!因為如果你先寫了正式代碼,那你對這寫代碼是如何work的已經(jīng)有一個印象了,因此你往往會寫出能順利通過的測試,而忽略一些會讓測試不通過的情況。如果先寫測試,則能考慮得更全面。 9. 未來的打算使用Groovy和RoboSpock或者是Kotlin和Spek,真正實現(xiàn)BDD,這是很可能的事情,只是目前我們這邊還沒太多那方面的實踐,因此就不說太多了。以后有一定實踐了,到時候可以再更大家交流。 文中部分代碼:https://github.com/ChrisZou/android-unit-testing-tutorial QA環(huán)節(jié)Q:如何測試界面交互?如點(diǎn)擊拖動等。
Q:我也是后來才接觸代碼測試的,然后開始喜歡上寫代碼測試,但當(dāng)嘗試為以前的代碼寫代碼測試的時候,發(fā)現(xiàn)以前的結(jié)構(gòu)很難寫代碼測試,請問你們也有遇到這種情況么?如何解決。
Q:自繪控件一般怎么去做自動化測試?
Q:業(yè)務(wù)測試數(shù)據(jù),是自己本地寫的邏輯,還是結(jié)合服務(wù)器的真實邏輯?
Q:MVP的情況下view和presenter的回調(diào)函數(shù)需要做測試嗎,如果需要怎么做?
Q:對于依賴環(huán)境的測試,比如有無網(wǎng)絡(luò),不同的網(wǎng)絡(luò)測試類型,不同的網(wǎng)絡(luò)類型,網(wǎng)絡(luò)超時等,這種怎么去做單元測試比較好?在比如測試試寫文件的方法,怎么去構(gòu)造剩余空間不足、空間足夠的環(huán)境?
Q:為啥方法名不是駝峰命名法?
Q:robolectric一般只能模擬點(diǎn)擊到一個子控件,但是自繪的控件可能不滿足,自繪控件一般是為了較少layout的嵌套,而實現(xiàn)自繪的,點(diǎn)擊控件的不同區(qū)域可能會觸發(fā)不同的事件,以前我們的做法非常拿到,需要專門去根據(jù)這個自繪控件去這一大堆的測試代碼,不知道有沒有什么好的方法?
感謝徐川對本文的審校。 |
|