安卓單元測試:What, Why and How

Dev Club 是一個交流移動開發技術,結交朋友,擴展人脈的社群,成員都是經過審核的移動開發工程師。每周都會舉行嘉賓分享,話題討論等活動。

本期,我們邀請了蘑菇街 Android 開發工程師——小創,為大家分享《安卓單元測試:What, Why and How》。

分享內容簡介:

單元測試一直是軟件開發過程中保證軟件質量、提高代碼設計非常重要的一環,然后國內環境普遍不重視這點,移動開發圈更是如此。這次分享主要介紹什么是單元測試、為什么要做單元測試、以及如何在安卓平臺上做單元測試。

下面是本期分享內容整理

大家晚上好,我是小創,目前工作于 蘑菇街 支付金融部門。今天很高興跟大家分享一下,我在安卓單元測試方面的一些經驗。

這次分享主要介紹什么是單元測試、為什么要做單元測試、以及如何在安卓平臺上做單元測試。

單元測試一直是軟件開發過程中保證軟件質量、提高代碼設計非常重要的一環。然而國內環境普遍不重視這點,移動開發界更是如此。希望這次分享能讓大家了解到單元測試的一些知識,提高大家對單元測試的重視程度。

下面,我們從為什么開始。

1. 為什么要寫單元測試?

說到為什么要寫單元測試的話,我相信大部分人都能承認、也能理解單元測試在保證代碼質量,防止bug或盡早發現bug這方面的作用,這可能是大家覺得單元測試最大的作用。

然而我覺得,除了這方面的作用,單元測試還能在非常大的程度上改善代碼的設計,同時還能節約時間,讓人工作起來更有信心、更開心,以及其他的一些好處。這些都是我的切身感受,我相信也是多數真正實踐過單元測試的人的切身感受,而不是為了宣傳這個東西而說的好聽的大話。

說到節約時間,大家可能就會好奇了,寫單元測試需要時間,維護單元測試代碼也需要時間,應該更費時間才對啊?

這就是在開始分享之前,我想重點澄清的一點,那就是 單元測試本身其實不會占用多少時間,相反,還會節約時間。 只是:

  1. 學習如何做單元測試需要時間;

  2. 在一個沒有單元測試的項目中加入單元測試,需要一定的結構調整的時間,因為一個有單元測試跟沒有單元測試的項目,結構上還是有較大不同的。

打個比方,開車這件事情,需要很多時間嗎?我相信很少人會說開車這件事情需要很多時間,而是:

  1. 學習開車,需要一定的時間;

  2. 如果路面不平的話,那么修路需要一定的時間。

單元測試也是類似的道理。

那為什么說單元測試可以節約時間呢?簡單說幾點:

  1. 如果沒有單元測試的話,我們每次寫的新代碼,都只能把app運行起來,測試相應的功能,才能知道代碼是否是正確的,這比運行一次單元測試要慢多了。運行一次app需要多少時間,我相信大家都是有深刻體會的,gradle有多慢,相信大家也是有深刻體會的。

  2. 單元測試可以減少bug,盡早發現bug,從而減少了debug和fix bug的時間。有句話說我們寫代碼90%的時間在改bug,另外10%的時間在寫新的bug。這句話雖然有點夸張,但是也能說明改bug確實占用了非常多的時候。既然單元測試能減少bug,自然也能節約時間。

  3. 重構的時候,大大提高重構的正確性,減少手工測試的時間。

所以,我希望大家能去掉”沒時間寫單元測試”這個印象,如果工作上安排太緊。沒有時間學習如何做單元測試的話,可以自己私底下學,然后在慢慢應用到項目中。

2. 如何在安卓平臺做單元測試?

2.1 單元測試與其它測試的區別

接下來介紹一下安卓單元測試是怎么做的。

首先澄清一下概念,在安卓上面寫“測試”,有很多技術方案。有JUnit、Instrumentation test、Espresso、UiAutomator等等,還有第三方的Appium、Robotium、Calabash、Robolectric等等。

我們現在講的是使用JUnit和Robolectric等其他的一些框架,寫可以在我們開發環境的JVM上面直接運行的單元測試。其他的幾種其實都不屬于單元測試,而是集成測試或者叫Functional test等。

這兩者明顯的不同是:

  • 前者可以直接在開發用的電腦的JVM上,或者是CI上面的JVM上運行,而且可以只運行那么一小部分代碼,速度非常快。

  • 后者必須要有模擬器或真機,把整個project打包成一個app,然后上傳到模擬器或真機上,再運行相關的代碼,速度相對來說慢很多。

2.2 單元測試的定義

單元測試的定義相信大家都知道,就是為我們寫的某一個代碼單元(比如說一個方法)寫的測試代碼。

一個單元測試大概可以分為三個部分:

  1. setup:即new 出待測試的類,設置一些前提條件

  2. 執行動作:即調用被測類的被測方法,并獲取返回結果

  3. 驗證結果:驗證獲取的結果跟預期的結果是一樣的

2.3 void方法如何測試 & 常見測試誤區

然而一個類的方法分兩種,一種是有返回值的方法,一種是沒有返回值的方法,即void方法。

對于有返回值的方法,測試起來固然是很容易的。但是對于沒有返回值的方法,該怎么測試呢?這里的關鍵是,怎么樣獲取這個方法的“返回結果”?

這里舉一個例子來說明一下,順便澄清一個十分常見的誤解。

比如說有一個Activity,管他叫 DataActivity ,它有一個 public void loadData() 方法, 會去調用底層的 DataModel#loadDataFromNetwork() 方法,異步的執行一些網絡請求。當網絡請求返回以后,更新用戶界面。

這里的 loadData() 方法是void的,它該怎么測試呢?

一個最直接的反應可能是,調用 loadData() 方法(當然,實際可能是通過其他事件觸發),然后一段時間后,驗證界面得到了更新。

然而這種方法是錯的,這種測試叫集成測試,而不是單元測試。因為它涉及到很多個方面,它涉及到 DataModel 的實現、網絡服務器,以及網絡返回正確時, DataActivity 內部的處理,等等。

集成測試固然有它的必要性,但這不是我們應該最關注的地方,也不是最有價值的地方。我們應該最關注的是單元測試。

關于這一點,有一個 Test Pyramid 的理論:

Test Pyramid理論基本大意是,單元測試是基礎,是我們應該花絕大多數時間去寫的部分,而集成測試等應該是冰山上面能看見的那一小部分。

那么對于這個case,正確的單元測試方法,應該是去驗證 loadData() 方法調用了 DataModel 的loadDataFromNetwork()方法,同時傳遞的參數是正確的。“調用了DataModel的loadDataFromNetwork()方法,同時參數是xxx” 這個才是 loadData() 這個方法的“返回結果”。

2.4 Mock的概念以及Mockito框架

要驗證某個對象的某個方法得到調用了,就涉及到mock的使用。這里對mock的概念做個簡單介紹,以免很多同學不熟悉,mock就是創建一個虛假的、模擬的對象。在測試環境下,用來替換掉真實的對象。

這樣就能達到兩個目的:

  1. 可以隨時指定mock對象的某個方法返回什么樣的值,或執行什么樣的動作。

  2. 可以驗證mock對象的某個方法有沒有得到調用,或者是調用了多少次,參數是什么等等。

要使用mock,一般需要使用mock框架,目前安卓最常用的有兩個,Mockito和JMockit。

兩者的區別是,前者不能mock static method和final class、final method,后者可以。

我個人使用和推薦的是 Mockito ,因為它比較成熟穩定,兼容性也比較好。Mockito在github上面有2000多個mark,而 JMockit 只有100多個,跟Robolectric的兼容性也有問題。

但是使用Mockito,就有一個問題,那就是static method和final class、final method沒有辦法mock,對于這點如何解決,我們稍后會介紹到。

關于Mock和Mockito的使用,可以參考 這篇文章 。

http://chriszou.com/2016/04/29/android-unit-testing-mockito.html

2.5 在測試環境中使用Mock:依賴注入

接下來的一個問題就是, 如何在測試環境下,把 DataModel 換成mock的對象,而正式代碼中, DataModel 又是正常的對象呢?

這個問題也有兩種解決方案:

  • 一是使用專門的testing product flavor;

  • 二是使用依賴注入。

2.5.1 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.油Tube.com/watch?v=vdasFFfXKOY&list=PLWz5rJ2EKKc_Tt7q77qwyKRgytF1RzRx8&index=16

2.5.2 依賴注入

因此,我們用的是第二種,依賴注入。

先簡單介紹一下 依賴注入 (Dependency Injection)的概念。

假如某一個類,比如說 DataActivity ,內部用到另外一個類,比如說 DataModel 。那么DataModel叫做DataActivity的依賴(Dependency),DataActivity叫做DataModel的Client。

依賴注入的基本理念是,Dependency(DataModel)的創建過程不在Client(DataActivity)內部去new,而是由外部去創建好Depencendy(DataModel)的實例,然后通過某種方式set給Client(DataActivity)。

這種模式應用是非常廣泛的,拋開單元測試不說,它本身就是一種非常好的代碼設計。只不過單元測試讓依賴注入這種模式變得非做不可而已。

關于依賴注入更詳細的說明和做法,大家可以看 這篇文章 。

http://chriszou.com/2016/05/06/android-unit-testing-di.html

為了更方便的做依賴注入,如今有很多框架專門做這件事情,比如RoboGuice, Dagger、Dagger2等等。

我們用的是Dagger2。理由很簡單,這是目前最好用的DI框架。

關于Dagger2的文章,目前網上很多,相信大家也看過不少,但是好像我并沒有看到講述沒有關于如何在測試環境下使用Dagger2的文章,這個還是略感遺憾的。雖然說本身就是一個非常優秀的設計,而不僅僅是為了單元測試,但離開單元測試,使用依賴注入就少了很有說服力的一個理由。

那么這里我就介紹一下,怎么樣把Dagger2應用到單元測試中。

熟悉dagger2的童靴可能知道,Dagger2里面最關鍵的有兩個概念,Module 和Component。Module是負責生成諸如 DataModel 這樣的Dependency的地方。而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增加任何多余的代碼,同時還能得到依賴注入的其他好處。

關于Dagger2的介紹和使用,以及在單元測試中的運用,大家可以參考 這篇文章 。

http://chriszou.com/2016/05/10/android-unit-testing-di-dagger.html

2.6 Robolectric:解決Android單元測試最大的痛點

接下來講講Android單元測試最大的痛點,那就是JVM上面運行純JUnit單元測試時,是不能使用Android相關的類(比如Activity、View等等)的,因為我們開發用到的安卓環境是沒有具體實現的,里面只定義了一些接口,所有方法的實現都是 throw new RuntimeException("stub"); 。如果我們單元測試代碼里面用到了安卓相關的代碼的話,那么運行時就會遇到類似 Class xxx is not mocked 這樣的問題。

要解決這個問題,一般來說有三種方案:

  1. 使用Android提供的Instrumentation系統,將單元測試代碼運行在模擬器或者是真機上。

  2. 用一定的架構,比如MVP等等,將安卓相關的代碼隔離開了,中間的Presenter或Model是純java實現的,可以在JVM上面測試。View和其他android相關的代碼則不測。

  3. 使用 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。基本來說,并沒有什么黑科技,都是業界標準。

3. 案例實踐

接下來,我通過一個具體的案例,跟大家介紹一下,一個真實的app,具體是怎么單測的。

這里是蘑菇街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 和 mBus ,都是通過Dagger2注入進去的。在做單元測試的時候,這些都是mock。

對于這個流程,我們做了如下的單元測試:

  1. CheckoutActivity 啟動單元測試:通過Robolectric提供的方法,啟動一個 Activity 。驗證里面的 mCheckoutModel 的 loadCheckoutData() 方法得到了調用,同時參數(訂單ID等)是對的。

  2. CheckoutModel 的 loadCheckoutData 單元測試1:調用 CheckoutModel 的 loadCheckoutData() 方法,驗證里面的 mApi 對應的get方法得到了調用,同時參數是對的。

  3. CheckoutModel 的 loadCheckoutData 單元測試2:mock Api類,指定當它的get方法在收到某些調用的時候,直接調用傳入的callback的onSuccess方法,然后調用 CheckoutModel 的 loadCheckoutData() 方法,驗證Otto bus的post方法得到了調用,并且參數是對的。

  4. CheckoutModel 的 loadCheckoutData 單元測試3:mock api類,指定當它的get方法在收到某些調用的時候,直接調用傳入的callback的onFailure方法,然后調用 CheckoutModel 的 loadCheckoutData() 方法,驗證Otto bus的post方法得到了調用,并且參數是對的。

  5. CheckoutActivity 的 onCheckoutDataLoaded 單元測試1:啟動一個 CheckoutActivity ,調用他的 onCheckoutDataLoaded() ,傳入含有正確數據的Event,驗證相應的數據view顯示出來了

  6. CheckoutActivity 的 onCheckoutDataLoaded() 方法單元測試2:啟動一個 CheckoutActivity ,調用他的 onCheckoutDataLoaded() ,傳入含有錯誤信息的Event,驗證相應的錯誤提示view顯示出來了。

這里需要說明的一點是,上面的每一個測試,都是獨立進行的,不是說下面的單元測試依賴于上面的。或者說必須先做上面的,再做下面的。

4. 其他問題

以上就是我們這邊做單元測試用到的技術,以及一個基本流程,下面聊聊其他的幾個問題。

4.1 哪些東西需要測試呢?

  1. 所有的Model、Presenter/ViewModel、Api、Utils等類的public方法

  2. Data類除了getter、setter、toString、hashCode等一般可以自動生成的方法之外的邏輯部分

  3. 自定義View的功能:比如set data以后,text有沒有顯示出來等等,簡單的交互,比如click事件,負責的交互一般不測,比如touch、滑動事件等等。

  4. Activity的主要功能:比如view是不是存在、顯示數據、錯誤信息、簡單的點擊事件等。比較復雜的用戶交互比如onTouch,以及view的樣式、位置等等可以不測。因為不好測。

4.2 CI和code coverage

要把單元測試正式化,CI是非常重要的一步,我們有一個運行Jenkins的CI server,每次開發者push代碼到master branch的時候,會運行一次單元測試的gradle task,同時使用Jacoco來做code coverage

4.3 private方法怎么測

把private方法改成package或者protected,然后把對應的測試類的包名變成跟待測類一下,這樣,這個方法就可以測試了。 這個看起來有點別扭,但其實,安卓源代碼有些地方就是這樣做的。

5. 遇到的坑,以及好的practice建議

5.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的主要方式。

5.2 盡量寫出易于測試的代碼

static method、直接new object、singleton、Global state等等這些都是一些不利于測試的代碼方式,應該盡量避免,用依賴注入來代替這些方式。

5.3 創建公共的單元測試library

如果你們公司也是組件化開發的話,抽出一個公共的單元測試類庫來做單元測試,里面可以放一些公共的helper、utils、Junit rules等等,這個可以極大的提高寫單元測試的速度。

5.4 把安卓里面的“純java”代碼copy一份到自己的項目里面

安卓里面有些類其實跟安卓沒太大關系的,比如說TextUtils、Color等等,這些類完全可以把代碼copy出來,放到自己的項目里面,然后其他地方就用這個類,這樣也能部分擺脫android的依賴,使用JUnit而不是Robolectric,提高運行test的速度。

5.5 充分發揮JUnit Rule的作用

JUnit Rule 是個很強大的工具,然而知道的人卻不多。它的基本作用是,讓你在執行某個測試方法前后,可以做一些事情。

如果你的好幾個測試類里面有很多的共同的setup、teardown工作,你可能會傾向于使用繼承,結合@Before、@After來減少duplication,這里更建議大家使用JUnit Rule來實現這個目的,而不是用繼承,這樣可以有更大的靈活性。

此外,JUnit Rule還能實現@Before、@After這些annotation無法實現的一些功能。

關于JunitRule的具體使用,可以參考 這篇文章

http://chriszou.com/2016/07/09/junit-rule.html

5.6 善于利用AndroidStudio來加快你寫測試的速度

AndroidStudio有很多feature可以幫助我們更快的寫代碼,比如code generation和Live Template等等。

這點對于寫正式代碼也適用,不過對于寫測試代碼來說,效果更為突出。因為大部分測試代碼的結構、風格都是類似的,在這里live template能起非常大的作用。

此外,如果你先寫測試,可以直接寫一些還不存在的Class或method,然后alt+enter讓AndroidStudio自動幫你生成。

5.7 不要最求完美

剛開始的時候,不用追求測試代碼的質量,也不用追求完美,如果有些地方不好寫測試,可以先放放,以后再來補,有部分測試總比沒有測試好。

Martin Fowler 說過:

Imperfect tests, run frequently, are much better than perfect tests that are never written at all.

然而等你熟悉寫測試的方法以后,強烈建議先寫測試!因為如果你先寫了正式代碼,那你對這寫代碼是如何work的已經有一個印象了,因此你往往會寫出能順利通過的測試,而忽略一些會讓測試不通過的情況。如果先寫測試,則能考慮得更全面。

上面分享中提到的每一個比較重要的點(單元測試的定義、JUnit使用、Mock和Mockito、依賴注入、Robolectric等),都在里面有相應的單獨文章介紹。

互動問答

Q1:感謝分享,想問下關于測試部分有沒有簡單的完整代碼例子可以參考?

有的,分享中的部分代碼在這個Repo: https://github.com/ChrisZou/android-unit-testing-tutorial 。這里面有上面提到的每個關鍵的點的示例代碼

Q2:Groovy和Kotlin學習是不是對將來android開發的必要性 看過很多文章都講到這個技術

Groovy目前看來不覺得。它對android支持的那個lib有點太大,此外,動態語言在性能上也是個大問題。kotlin看起來很有希望,就看google對它的態度了。

Q3:你們在實際項目中,是開發來寫這些test case嗎?會寫多少?

是的,全部的單元測試都是開發自己寫的。目前我們部門的模塊,單元測試覆蓋率都在50%以上

Q4:在團隊開發中,怎么推廣單元測試?

推廣的確是個大問題,因為單元測試的好處只有實踐過,才能真實的體會到。所以最好是有上面領導的支持。

Q5:單元測試在效率和健壯之間怎么平衡?

這個是隨著自身做單元測試的技術而定的,剛開始的時候,可以能比較底層,比較好測的代碼入手,慢慢的再擴大范圍

Q6:單元測試的粒度,不能保證業務功能是正常的,你們有更大粒度的自動測試嗎?如有,能否介紹一下

之前有做過探索,但是因為業務流程和環境的一些問題,效果不是很好。目前這個問題解決了,接下來估計會重新投入一定的人力。主要是用Espresso和UiAutomator

Q7:你們除了單元測試,還會做哪些事情提升代碼質量?

其它的主要就是Code Review了,我們這邊Code Review執行得還是比較好的

 

 

來自:http://mp.weixin.qq.com/s?__biz=MzA3NTYzODYzMg==&mid=2653577840&idx=2&sn=c3b52a72c34194c5e76007254613c644&scene=4#wechat_redirect

 

 本文由用戶 ltxd3517 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!