標籤:
為什麼要寫單元測試
首先要介紹為什麼蘑菇街支付金融這邊會採用單元測試的實踐。說起來比較巧,剛開始的時候,只是我一個人會寫單元測試。後來老闆們知道了,覺得這是件很有價值的事情,於是就叫我負責我們組的單元測試這件事情。就這樣慢慢的,單元測試這件事情就成了我們這邊的正常實踐了。再後來,在公司層面也開始有一定的推廣。
要說為什麼要寫單元測試的話,我相信大部分人都能承認、也能理解單元測試在保證代碼品質,防止bug或儘早發現bug這方面的作用,這可能是大家覺得單元測試最大的作用。然而我覺得,除了這方面的作用,單元測試還能在很大程度上改善代碼的設計,同時還能節約時間,讓人工作起來更自信、更開心,以及其他的一些好處。這些都是我的切身感受,我相信也是多數真正實踐過單元測試的人的切身感受,而不是為了宣傳這個東西而說的好聽的大話。
說到節約時間,大家可能就會好奇了,寫單元測試需要時間,維護單元測試代碼也需要時間,應該更費時間才對啊?
這就是在開始分享之前,我想重點澄清的一點,那就是,單元測試本身其實不會佔用多少時間,相反,還會節約時間。只是:
學習如何做單元測試需要時間;
在一個沒有單元測試的項目中加入單元測試,需要一定的結構調整的時間,因為一個有單元測試跟沒有單元測試的項目,結構上還是有較大不同的。
打個比方,開車這件事情,需要很多時間嗎?我相信很少人會說開車這件事情需要很多時間,而是:
學習開車,需要一定的時間;
如果路面不平的話,那麼修路需要一定的時間。單元測試也是類似的情況。
那為什麼說單元測試可以節約時間呢?簡單說幾點:
如果沒有單元測試的話,就只能把app運行起來測試,這比運行一次單元測試要慢多了。
儘早發現bug,減少了debug和fixbug的時間。
重構的時候,大大減少手動驗證重構正確性的時間。
所以,我希望大家能去掉"沒時間寫單元測試"這個印象,如果工作上安排太緊,沒有時間學習如何做單元測試的話,可以自己私底下學,然後在慢慢應用到項目中。
單元測試簡單介紹
接下來介紹我們這邊是怎麼做安卓單元測試的。首先澄清一下概念,在安卓上面寫測試,有很多技術方案。有JUnit、Instrumentation test、Espresso、UiAutomator等等,還有第三方的Appium、Robotium、Calabash等等。我們現在講的是使用JUnit和其他的一些架構,寫可以在我們開發環境的JVM上面直接啟動並執行單元測試,其他的幾種其實都不屬於單元測試,而是整合測試或者叫Functional test等等。這兩者明顯的不同是,前者可以直接在開發用的電腦,或者是CI上面的JVM上運行,而且可以只運行那麼一小部分代碼,速度非常快。而後者必須要有模擬器或真機,把整個project打包成一個app,然後上傳到模擬器或真機上,再運行相關的代碼,速度相對來說慢很多。
單元測試的定義相信大家都知道,就是為我們寫的某一個代碼單元(比如一個方法)寫的測試代碼。一個單元測試大概可以分為三個部分:
setup:即new 出待測試的類,設定一些前提條件
執行動作:即調用被測類的被測方法,並擷取返回結果
驗證結果:驗證擷取的結果跟預期的結果是一樣的
然而一個類的方法分兩種,一種是有傳回值的方法。一種是沒有傳回值的方法,即void方法。對於有傳回值的方法,固然測試起來是很容易的,但是對於沒有傳回值的方法,該怎麼測試呢?這裡的關鍵是,怎麼樣擷取這個方法的“返回結果”?
這裡舉一個例子來說明一下,順便澄清一個十分常見的誤解。比如說有一個Activity,管他叫DataActivity,它有一個public void loadData()方法, 會去調用底層的DataModel類,非同步執行一些網路請求。當網路請求返回以後,更新使用者介面。
這裡的loadData()方法是void的,它該怎麼測試呢?一個最直接的反應可能是,調用loadData()方法(當然,實際可能是通過其他事件觸發),然後一段時間後,驗證介面得到了更新。然而這種方法是錯的,這種測試叫整合測試,而不是單元測試。因為它涉及到很多個方面,它涉及到DataModel、網路伺服器,以及網路返回正確時,DataActivity內部的處理,等等。整合測試固然有它的必要性,但不是我們應該最關注的地方,也不是最有價值的地方。我們應該最關注的是單元測試。關於這一點,有一個Test Pyramid的理論:
Test Pyramid理論基本大意是,單元測試是基礎,是我們應該花絕大多數時間去寫的部分,而整合測試等應該是冰山上面能看見的那一小部分。
那麼對於這個case,正確的單元測試方法,應該是去驗證loadData()方法調用了DataModel的某個請求資料的方法,同時傳遞的參數是正確的。“調用了DataModel的方法,同時參數是。。。” 這個才是loadData()這個方法的“返回結果”。
Mock的概念以及Mockito架構
要驗證某個對象的某個方法得到調用了,就涉及到mock的使用。這裡對mock的概念做個簡單介紹,以免很多同學不熟悉,mock就是建立一個虛假的、類比的對象。在測試環境下,用來替換掉真實的對象。這樣就能達到兩個目的:
可以隨時指定mock對象的某個方法返回什麼樣的值,或執行什麼樣的動作。
可以驗證mock對象的某個方法有沒有得到調用,或者是調用了多少次,參數是什麼等等。
要使用mock,一般需要使用mock架構,目前安卓最常用的有兩個,Mockito和JMockit。兩者的區別是,前者不能mock static method和final class、final method,後者可以。我們依然採用的是Mockito,原因說起來慚愧,是因為剛開始並不知道JMockit這個東西,後來查了一些資料,看過很多對比Mockito和JMockit的文章,貌似大部分還是很看好JMockit的,只是有一個問題,那就是跟robolectric的結合也有一些bug,同時使用姿勢跟Mockito有較大的不同,因此一直沒有抽時間去實踐過。這個希望以後能夠做進一步的調查,到時候在給大家分享一下使用感受。
但是使用Mockito,就有一個問題,那就是static method和final class、final method沒有辦法mock,對於這點如何解決,我們稍後會介紹到。
在測試環境中使用mock:依賴注入
接下來的一個問題就是,如何在測試環境下,把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實現;此外,代碼會變得很醜陋,因為你需要為每一個dependency提供一個factory,會覺得很刻意;再者,多了一個flavor,很多gradle任務都會變得很慢。關於這種方案,可以參考這個視頻(https://www.youtube.com/watch?v=vdasFFfXKOY)。
因此,我們用的是第二種,依賴注入。先簡單介紹一下依賴注入這個模式,他的基本理念是,某一個類(比如說DataActivity),用到的內部對象(比如說DataModel)的建立過程不在DataActivity內部去new,而是由外部去建立好DataModel的執行個體,然後通過某種方式set給DataActivity。這種模式應用是非常廣泛的,尤其是在測試的時候。為了更方便的做依賴注入,如今有很多架構專門做這件事情,比如RoboGuice、Dagger、Dagger2等等。我們用的是Dagger2,理由很簡單,這是目前最好用的DI架構。
關於Dagger2的文章,之前我們群裡也分享了不少,但是好像我並沒有看到講述沒有關於如何在測試環境下使用Dagger2的文章,這個還是略感遺憾的。離開單元測試,使用依賴注入就少了很令人信服的一個理由。
那麼這裡我就介紹一下,怎麼樣把Dagger2應用到單元測試中。熟悉dagger2的童靴可能知道,Dagger2裡面最關鍵的有兩個概念,Module和Component。Module是負責產生諸如DataModel這樣被別人(比如DataActivity)使用的類的地方。用術語的話,被別人使用的類DataModel叫Dependency,使用到了別的類的類DataActivity叫Client。而Component則是供Client使用Dependency的統一介面。也就是說,DataActivity通過Component,來得到一份DataModel的執行個體。
現在,關鍵的地方來了,Component本身是不生產dependency的,它只是搬運工而已,真正生產dependency的地方在Module。所以,建立Component需要用到Module,不同的Module生產出不同的dependency。在正式代碼裡面,我們使用正常的Module,生產正常的DataModel。而在測試環境中,我們寫一個TestingModule,讓它繼承正常的Module,然後override掉生產DataModel的方法,讓它生產mock的DataModel。在跑單元測試的時候,使用這個TestingModule來建立Component,這樣的話,DataActivity通過Component得到的DataModel對象就是mock出來的DataModel對象。
使用這種方式,所有production code都不用專門為testing增加任何多餘的代碼,同時還能得到依賴注入的其他好處。
Robolectric:解決Android單元測試最大的痛點
接下來講講Android單元測試最大的痛點,那就是JVM上面運行純JUnit單元測試時是不能使用Android相關的類的,因為我們開發用到的安卓環境是沒有實現的,裡面只定義了一些介面,所有方法的實現都是throw new RuntimeException("stub");,如果我們單元測試代碼裡面用到了安卓相關的代碼的話,那麼運行時就會遇到RuntimeException("Stub")。
要解決這個問題,一般來說有三種方案:
使用Android提供的Instrumentation系統,將單元測試代碼運行在模擬器或者是真機上。
用一定的架構,比如MVP等等,將安卓相關的代碼隔離開了,中間的Presenter或Model是存java實現的,可以在JVM上面測試。View或其他android相關的代碼則不測。
使用Robolectric架構,這個架構基本可以理解為在JVM上面實現了一套安卓的類比環境,同時給安卓相關的類增加了其他一些增強功能,以方便做單元測試,使用這個架構,我們就可以在JVM上面跑單元測試的時候,就可以使用安卓相關的類了。
第一種方案能work,但是速度非常慢,因為每次運行一次單元測試,都需要將整個項目打包成apk,上傳到模擬器或真機上,就跟運行了一次app似得,這個顯然不是單元測試該有的速度,更無法做TDD。這種方案首先被否決。
剛開始,我們採用的是Robolectric,原因有兩個:
我們項目當時還沒有比較清楚的架構,android跟純java代碼的隔離沒有做好;
很多安卓相關的代碼,還是需要測試的,比如說自訂View等等。
然而慢慢的,我們的態度從擁抱Robolectric,到盡量不用它,盡量使用純java代碼去實現。可能大家覺得安卓相關的代碼會很多,而純java的很少,然而慢慢的你會發現,其實不是這樣的,純java的代碼其實真不少,而且往往是核心的邏輯所在。之所以盡量不用Robolectric,是因為Robolectric雖然相對於Instrumentation testing來說快多了。但畢竟他也需要merge一些資源,build出來一個類比的app,因此相對於純java和JUnit來說,這個速度依然是很慢的。
用具體的數字來對比說明:
當然,雖然運行一次Robolectric在10秒左右,但是對比運行一次app,還是要快太多。因此,剛開始的時候,從Robolectric開始完全是OK的。
以上就是現在我們這邊單元測試用到的幾個基本技術:JUnit4 + Mockito + Dagger2 + Robolectric。基本來說,並沒有什麼黑科技,都是業界標準。
一個具體的案例
接下來,我通過一個具體的案例,跟大家介紹一下,我們這邊的一個app,具體是怎麼單測的。
這裡是我們收銀台介面的樣子:
假設Activity名字為CheckoutActivity,當它啟動的時候,CheckoutActivity會去調一個CheckoutModel的loadCheckoutData()方法,這個方法又會去調更底層的一個封裝了使用者認證等資訊的網路請求Api類(mApi)的get方法,同時傳給這個Api類一個callback。這個callback的做的事情是將結果通過Otto Bus(mBus) post出去。CheckoutActivity裡面Subscribe了這個Event(方法名是onCheckoutDataLoaded()),然後根據Event的值相應的顯示資料或錯誤資訊。
代碼簡寫如下:
這裡,CheckoutActivity裡面的mCheckoutModel、CheckoutModel裡面的mApi、CheckoutModel裡面的mBus,都是通過Dagger2注入進去的。在做單元測試的時候,這些都是mock。
對於這個流程,我們做了如下的單元測試:
CheckoutActivity啟動單元測試:通過Robolectric提供的方法,啟動一個Activity。驗證裡面的mCheckoutModel的loadCheckoutData()方法得到了調用,同時參數(訂單ID等)是對的。
CheckoutModel的loadCheckoutData單元測試1:調用CheckoutModel的loadCheckoutData()方法,驗證裡面的mApi對應的get方法得到了調用,同時參數是對的。
CheckoutModel的loadCheckoutData單元測試2:mock Api類,指定當它的get方法在收到某些調用的時候,直接調用傳入的callback的onSuccess方法,然後調用CheckoutModel的loadCheckoutData()方法,驗證Otto bus的post方法得到了調用,並且參數是對的。
CheckoutModel的loadCheckoutData單元測試3:mock api類,指定當它的get方法在收到某些調用的時候,直接調用傳入的callback的onFailure方法,然後調用CheckoutModel的loadCheckoutData()方法,驗證Otto bus的post方法得到了調用,並且參數是對的。
CheckoutActivity的onCheckoutDataLoaded單元測試1:啟動一個CheckoutActivity,調用他的onCheckoutDataLoaded(),傳入含有正確資料的Event,驗證相應的資料view顯示出來了
CheckoutActivity的onCheckoutDataLoaded單元測試2:啟動一個CheckoutActivity,調用他的onCheckoutDataLoaded(),傳入含有錯誤資訊的Event,驗證相應的錯誤提示view顯示出來了。
這裡需要說明的一點是,上面的每一個測試,都是獨立進行的,不是說下面的單元測試依賴於上面的。或者說必須先做上面的,再做下面的。
這部分較為詳細的代碼放在github(https://github.com/ChrisZou/android-unit-testing-tutorial)上,groupshare這個package裡面。
其他的問題
以上就是我們這邊做單元測試用到的技術,以及一個基本流程,下面聊聊其他的幾個問題。
哪些東西需要測試呢?
所有的Model、Presenter/ViewModel、Api、Utils等類的public方法
Data類除了getter、setter、toString、hashCode等一般自動產生的方法之外的邏輯部分
自訂View的功能:比如set data以後,text有沒有顯示出來等等,簡單的互動,比如click事件,負責的互動一般不測,比如touch、滑動事件等等。
Activity的主要功能:比如view是不是存在、顯示資料、錯誤資訊、簡單的點擊事件等。比較複雜的使用者互動比如onTouch,以及view的樣式、位置等等可以不測。因為不好測。
CI和code coverage: Jacoco
要把單元測試正式化,CI是非常重要的一步,我們有一個運行Jenkins的CI server,每次開發人員push代碼到master branch的時候,會運行一次單元測試的gradle task,同時使用Jacoco做code coverage。
這裡有個坑要特別注意,那就是項目裡面的gradle Jacoco外掛程式和Jenkins的Jacoco外掛程式的相容性問題。我們用的gradle Jacoco外掛程式是7.1,更高版本的好像有問題。然後對應的Jenkins的Jacoco外掛程式需要1.0.19或更低版本的,更高版本的jenkins plugin不支援低版本的gradle Jacoco項目版本。實際上,這點在Jenkins的Jacoco外掛程式首頁就有說明:
(點擊放大映像)
但是我當時沒注意,所以覆蓋率資料一直出不來,折騰了好一會,最後還是在同事的協助下找到問題了。
遇到的坑,以及好的practice建議
接下來講講我們遇到的一些坑,以及一些好的practice建議。
1. Native libary
無論是純JUnit還是Robolectric,都不支援load native library,會報UnsatisfiedLinkError的錯。所以如果你的被測代碼裡面用到了native lib,那麼可能需要給System.loadLibrary加上try catch。
如果是被測代碼用到的第三方lib,而裡面用到了native lib的話,一般有兩種解決辦法,一種是將用到native lib的第三方類外面自己在包一層,然後在測試的情況下mock掉。第二種是用Robolectric,給那個類建立一個shadow class。
第一種方法的好處是可以在測試的時候隨時改變這個類的傳回值或行為,缺點是需要另外建立一個wrapper類,會有點繁瑣。第二種方式不能隨時改變這個類的行為,但是寫起來非常簡單。所以,看自己的需要,選擇相應的方法。
這兩種方法,也是解決static method, final class/method不能mock的主要方式。
2. 盡量寫出易於測試的代碼
static method、直接new object、singleton、Global state等等這些都是一些不利於測試的代碼方式,應該盡量避免,用依賴注入來代替這些方式。
3. 不要重複你的unit test
比如說你使用了一個builder模式來建立了一個類,這個builder有一個validator,來validate一些參數情況。那麼這種情況,builder跟validator分開測,用各種正確的錯誤的參數情況去測試validator,然後測builder的時候,就不用遍曆各種有效跟無效的參數去測試了。
因為如果這樣的話,到時候Validator的邏輯改了,那麼針對Validator的測試跟針對Builder的測試都要修改,這個其實是重複的。這裡只需要測試這個builder裡面有一個Validator就好了。
4. 公用的單元測試library
如果你們公司也是組件化開發的話,抽出一個公用的單元測試類庫來做單元測試,裡面可以放一些公用的helper、utils、rules等等,這個可以極大的提高寫單元測試的速度。
5. 把安卓裡面的“純java”代碼copy一份到自己的項目裡面
安卓裡面有些類其實跟安卓沒太大關係的,比如說TextUtils、Color等等,這些類完全可以把代碼copy出來,放到自己的項目裡面,然後其他地方就用這個類,這樣也能部分擺脫android的依賴,使用JUnit而不是Robolectric,提高運行test的速度。
6. 充分發揮JUnit Rule的作用
JUnit Rule是個很強大的工具,然而知道的人卻不多。它的基本作用是,讓你在執行某個測試方法前後,可以做一些事情。如果你的好幾個測試類別裡面有很多的共同的setup、teardown工作,你可能會傾向於使用繼承,結合@Before、@After來減少duplication,這裡更建議大家使用JUnit Rule來實現這個目的,而不是用繼承,這樣可以有更大的靈活性。
比如,為了方便測試Activity的method,我們有一個ActivityRule,在跑一個測試方法之會啟動target Activity,然後跑完以後自動finish這個activity。
其中一個比較有趣的用JUnit Rule實現的功能,是實作類別似於BDD測試架構的命名方式。做單元測試的時候,你經常需要為同一個方法寫好幾個測試方法,每個測試方法測試不同的點。為了讓命名更具可讀性,我們往往會把名字寫的很長,在這種情況下,如果用駝峰命名的話,需要不斷切換大小寫,寫起來麻煩,可讀性也不高。如果用底線的話,寫起來也很麻煩。如果你使用過BDD的一些架構(比如RSpec、Cucumber、Jasmine等),你就會異常懷念那種“命名”方式。如果你沒用過的話,那種“命名”方式大概是這樣的:
這裡的關鍵是,當測試方法失敗的時候,這個字串是要能被加到錯誤資訊裡面的。我們做了個JUnit Rule來達到這個效果。做法是結合一個自訂的annotation,這個annotation接收一個String,來描述這個測試方法的測試目的。在Rule裡面將這個annotation讀出來,如果測試沒通過的話,把這個描述性的String加到輸出的error message裡面。這樣在批量啟動並執行時候,一看就知道沒通過的測試是測什麼東西的。而測試方法的命名則可以比較隨意。
達到的效果如下:
如果運行失敗,得到如下的結果
關於JUnit Rule的使用,大家可以自行google一下,也不難。
7. 善於利用AndroidStudio來加快你寫測試的速度
AndroidStudio有很多feature可以協助我們更快的寫代碼,比如code generation和live template。這點對於寫正式代碼也適用,不過對於寫測試代碼來說,效果更為突出。因為大部分測試代碼的結構、風格都是類似的,在這裡live template能起非常大的作用。此外,如果你先寫測試,可以直接寫一些還不存在的Class或method,然後alt+enter讓AndroidStudio自動幫你產生。
8. 不要追求完美
剛開始的時候,不用追求測試代碼的品質,也不用追求完美,如果有些地方不好寫測試,可以先放放,以後再來補,有部分測試總比沒有測試好。Martin Fowler說過
Imperfect tests, run frequently, are much better than perfect tests that are never written at all.
然而等你熟悉寫測試的方法以後,強烈建議先寫測試!因為如果你先寫了正式代碼,那你對這寫代碼是如何work的已經有一個印象了,因此你往往會寫出能順利通過的測試,而忽略一些會讓測試不通過的情況。如果先寫測試,則能考慮得更全面。
9. 未來的打算
使用Groovy和RoboSpock或者是Kotlin和Spek,真正實現BDD,這是很可能的事情,只是目前我們這邊還沒太多那方面的實踐,因此就不說太多了。以後有一定實踐了,到時候可以再更大家交流。
文中部分代碼:https://github.com/ChrisZou/android-unit-testing-tutorial
原文出處:http://www.infoq.com/cn/articles/mogujie-android-unit-testing
Android單元測試實踐