Android單元測試在蘑菇街支付金融部門的實踐
大家好,我是蘑菇街支付金融部門的小創。今天很高興跟大家分享一下安卓的單元測試在蘑菇街支付金融的實踐。下面,我們從為什么開始。
為什么要寫單元測試
首先要介紹為什么蘑菇街支付金融這邊會采用單元測試的實踐。說起來比較巧,剛開始的時候,只是我一個人會寫單元測試。后來老板們知道了,覺得這是件很有價值的事情,于是就叫我負責我們組的單元測試這件事情。就這樣慢慢的,單元測試這件事情就成了我們這邊的正常實踐了。再后來,在公司層面也開始有一定的推廣。要說為什么要寫單元測試的話,我相信大部分人都能承認、也能理解單元測試在保證代碼質量,防止bug或盡早發現bug這方面的作用,這可能是大家覺得單元測試最大的作用。然而我覺得,除了這方面的作用,單元測試還能在很大程度上改善代碼的設計,同時還能節約時間,讓人工作起來更自信、更開心,以及其他的一些好處。這些都是我的切身感受,我相信也是多數真正實踐過單元測試的人的切身感受,而不是為了宣傳這個東西而說的好聽的大話。
說到節約時間,大家可能就會好奇了,寫單元測試需要時間,維護單元測試代碼也需要時間,應該更費時間才對啊?這就是在開始分享之前,我想重點澄清的一點,那就是,單元測試本身其實不會占用多少時間,相反,還會節約時間。只是:1. 學習如何做單元測試需要時間;2. 在一個沒有單元測試的項目中加入單元測試,需要一定的結構調整的時間,因為一個有單元測試跟沒有單元測試的項目,結構上還是有較大不同的。
打個比方,開車這件事情,需要很多時間嗎?我相信很少人會說開車這件事情需要很多時間,而是:1. 學習開車,需要一定的時間;2. 如果路面不平的話,那么修路需要一定的時間。單元測試也是類似的情況。
那為什么說單元測試可以節約時間呢?簡單說幾點:1. 如果沒有單元測試的話,就只能把app運行起來測試,這比運行一次單元測試要慢多了。2. 盡早發現bug,減少了debug和fixbug的時間。3. 重構的時候,大大減少手動驗證重構正確性的時間。
所以,我希望大家能去掉"沒時間寫單元測試"這個印象,如果工作上安排太緊,沒有時間學習如何做單元測試的話,可以自己私底下學,然后在慢慢應用到項目中。
單元測試簡單介紹,以及void方法怎么測
接下來介紹我們這邊是怎么做安卓單元測試的。首先澄清一下概念,在安卓上面寫測試,有很多技術方案。有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就是創建一個虛假的、模擬的對象。在測試環境下,用來替換掉真實的對象。這樣就能達到兩個目的:1. 可以隨時指定mock對象的某個方法返回什么樣的值,或執行什么樣的動作。 2. 可以驗證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任務都會變得很慢。關于這種方案,可以參考 這個視頻 。
因此,我們用的是第二種,依賴注入。先簡單介紹一下依賴注入這個模式,他的基本理念是,某一個類(比如說 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,原因有兩個:1. 我們項目當時還沒有比較清楚的架構,android跟純java代碼的隔離沒有做好;2. 很多安卓相關的代碼,還是需要測試的,比如說自定義View等等。然而慢慢的,我們的態度從擁抱Robolectric,到盡量不用它,盡量使用純java代碼去實現。可能大家覺得安卓相關的代碼會很多,而純java的很少,然而慢慢的你會發現,其實不是這樣的,純java的代碼其實真不少,而且往往是核心的邏輯所在。之所以盡量不用Robolectric,是因為Robolectric雖然相對于Instrumentation testing來說快多了。但畢竟他也需要merge一些資源,build出來一個模擬的app,因此相對于純java和JUnit來說,這個速度依然是很慢的。用具體的數字來對比說明:
- 運行Instrumentation testing:幾十秒,取決于app的大小
- Robolectric:10秒左右
- 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的值相應的顯示數據或錯誤信息。
代碼簡寫如下:
public class CheckoutActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { // other code, like setContentView, get data from Intent, etc. mCheckoutModel.loadCheckoutData(paymentId); } @Subscribe public void onCheckoutDataLoaded(DataLoadedEvent event) { if (event.successful()) { //Get data from event and update UI } else { //show error message } } } public class CheckoutModel { public void loadCheckoutData(String paymentId) { //Other code, like composing params mApi.get(someUrl, someParams, new NetworkCallback() { @Override public void onSuccess(Object data) { mBus.post(new DataLoadedEvent(data)); } @Override public void onFailure(int code, String msg) { mBus.post(new DataLoadedEvent(code, msg)); } }); } }
這里, 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 上,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 等),你就會異常懷念那種“命名”方式。如果你沒用過的話,那種“命名”方式大概是這樣的:
describe Hash do # 一下是一個測試方法,it后面的字符串就是這個測試方法的“命名” it "hashes the correct information in a key" do expect(hash[:hello]).to eq('world') end end
這里的關鍵是,當測試方法失敗的時候,這個字符串是要能被加到錯誤信息里面的。我們做了個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,這是很可能的事情,只是目前我們這邊還沒太多那方面的實踐,因此就不說太多了。以后有一定實踐了,到時候可以再更大家交流。
文中部分代碼: github
參考鏈接:
http://dontpanic.42.nl/2013/04/mockito-powermock-vs-jmockit.html
http://endran.nl/blog/mockito-vs-jmockit/
http://martinfowler.com/articles/continuousIntegration.html
來自:http://chriszou.com/2016/04/25/android-unit-testing-wechat-group-share.html