單元測試一直是軟體開發過程中保證軟體品質、提高代碼設計非常重要的一環,然後國内環境普遍不重視這點,移動開發圈更是如此。這次分享主要介紹什麼是單元測試、為什麼要做單元測試、以及如何在安卓平台上做單元測試。
本文來自于騰訊bugly開發者社群,非經作者同意,請勿轉載,原文位址:http://dev.qq.com/topic/57d28349101cd07a5404c415
Dev Club 是一個交流移動開發技術,結交朋友,擴充人脈的社群,成員都是經過稽核的移動開發工程師。每周都會舉行嘉賓分享,話題讨論等活動。
本期,我們邀請了蘑菇街 Android 開發工程師——小創,為大家分享《安卓單元測試:What, Why and How》。
分享内容簡介:
下面是本期分享内容整理
大家晚上好,我是小創,目前工作于 蘑菇街 支付金融部門。今天很高興跟大家分享一下,我在安卓單元測試方面的一些經驗。
這次分享主要介紹什麼是單元測試、為什麼要做單元測試、以及如何在安卓平台上做單元測試。
單元測試一直是軟體開發過程中保證軟體品質、提高代碼設計非常重要的一環。然而國内環境普遍不重視這點,移動開發界更是如此。希望這次分享能讓大家了解到單元測試的一些知識,提高大家對單元測試的重視程度。
下面,我們從為什麼開始。
1. 為什麼要寫單元測試?
說到為什麼要寫單元測試的話,我相信大部分人都能承認、也能了解單元測試在保證代碼品質,防止bug或盡早發現bug這方面的作用,這可能是大家覺得單元測試最大的作用。
然而我覺得,除了這方面的作用,單元測試還能在非常大的程度上改善代碼的設計,同時還能節約時間,讓人工作起來更有信心、更開心,以及其他的一些好處。這些都是我的切身感受,我相信也是多數真正實踐過單元測試的人的切身感受,而不是為了宣傳這個東西而說的好聽的大話。
說到節約時間,大家可能就會好奇了,寫單元測試需要時間,維護單元測試代碼也需要時間,應該更費時間才對啊?
這就是在開始分享之前,我想重點澄清的一點,那就是單元測試本身其實不會占用多少時間,相反,還會節約時間。隻是:
- 學習如何做單元測試需要時間;
- 在一個沒有單元測試的項目中加入單元測試,需要一定的結構調整的時間,因為一個有單元測試跟沒有單元測試的項目,結構上還是有較大不同的。
打個比方,開車這件事情,需要很多時間嗎?我相信很少人會說開車這件事情需要很多時間,而是:
- 學習開車,需要一定的時間;
-
如果路面不平的話,那麼修路需要一定的時間。
單元測試也是類似的道理。
那為什麼說單元測試可以節約時間呢?簡單說幾點:
- 如果沒有單元測試的話,我們每次寫的新代碼,都隻能把app運作起來,測試相應的功能,才能知道代碼是否是正确的,這比運作一次單元測試要慢多了。運作一次app需要多少時間,我相信大家都是有深刻體會的,gradle有多慢,相信大家也是有深刻體會的。
- 單元測試可以減少bug,盡早發現bug,進而減少了debug和fix bug的時間。有句話說我們寫代碼90%的時間在改bug,另外10%的時間在寫新的bug。這句話雖然有點誇張,但是也能說明改bug确實占用了非常多的時候。既然單元測試能減少bug,自然也能節約時間。
- 重構的時候,大大提高重構的正确性,減少手工測試的時間。
是以,我希望大家能去掉”沒時間寫單元測試”這個印象,如果工作上安排太緊。沒有時間學習如何做單元測試的話,可以自己私底下學,然後在慢慢應用到項目中。
2. 如何在安卓平台做單元測試?
2.1 單元測試與其它測試的差別
接下來介紹一下安卓單元測試是怎麼做的。
首先澄清一下概念,在安卓上面寫“測試”,有很多技術方案。有JUnit、Instrumentation test、Espresso、UiAutomator等等,還有第三方的Appium、Robotium、Calabash、Robolectric等等。
我們現在講的是使用JUnit和Robolectric等其他的一些架構,寫可以在我們開發環境的JVM上面直接運作的單元測試。其他的幾種其實都不屬于單元測試,而是內建測試或者叫Functional test等。
這兩者明顯的不同是:
- 前者可以直接在開發用的電腦的JVM上,或者是CI上面的JVM上運作,而且可以隻運作那麼一小部分代碼,速度非常快。
- 後者必須要有模拟器或真機,把整個project打包成一個app,然後上傳到模拟器或真機上,再運作相關的代碼,速度相對來說慢很多。
2.2 單元測試的定義
單元測試的定義相信大家都知道,就是為我們寫的某一個代碼單元(比如說一個方法)寫的測試代碼。
一個單元測試大概可以分為三個部分:
- setup:即new 出待測試的類,設定一些前提條件
- 執行動作:即調用被測類的被測方法,并擷取傳回結果
- 驗證結果:驗證擷取的結果跟預期的結果是一樣的
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就是建立一個虛假的、模拟的對象。在測試環境下,用來替換掉真實的對象。
這樣就能達到兩個目的:
- 可以随時指定mock對象的某個方法傳回什麼樣的值,或執行什麼樣的動作。
- 可以驗證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的使用,可以參考這篇文章。
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任務都會變得很慢。
關于這種方案,可以參考這個視訊。
2.5.2 依賴注入
是以,我們用的是第二種,依賴注入。
先簡單介紹一下依賴注入(Dependency Injection)的概念。
假如某一個類,比如說,内部用到另外一個類,比如說
DataActivity
DataModel
。那麼DataModel叫做DataActivity的依賴(Dependency),DataActivity叫做DataModel的Client。
依賴注入的基本理念是,Dependency(DataModel)的建立過程不在Client(DataActivity)内部去new,而是由外部去建立好Depencendy(DataModel)的執行個體,然後通過某種方式set給Client(DataActivity)。
這種模式應用是非常廣泛的,抛開單元測試不說,它本身就是一種非常好的代碼設計。隻不過單元測試讓依賴注入這種模式變得非做不可而已。
關于依賴注入更詳細的說明和做法,大家可以看這篇文章。
為了更友善的做依賴注入,如今有很多架構專門做這件事情,比如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的介紹和使用,以及在單元測試中的運用,大家可以參考這篇文章。
2.6 Robolectric:解決Android單元測試最大的痛點
接下來講講Android單元測試最大的痛點,那就是JVM上面運作純JUnit單元測試時,是不能使用Android相關的類(比如Activity、View等等)的,因為我們開發用到的安卓環境是沒有具體實作的,裡面隻定義了一些接口,所有方法的實作都是
throw new RuntimeException("stub");
。如果我們單元測試代碼裡面用到了安卓相關的代碼的話,那麼運作時就會遇到類似
Class xxx is not mocked
這樣的問題。
要解決這個問題,一般來說有三種方案:
- 使用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。基本來說,并沒有什麼黑科技,都是業界标準。
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。
對于這個流程,我們做了如下的單元測試:
-
啟動單元測試:通過Robolectric提供的方法,啟動一個CheckoutActivity
。驗證裡面的Activity
mCheckoutModel
方法得到了調用,同時參數(訂單ID等)是對的。loadCheckoutData()
-
CheckoutModel
單元測試1:調用loadCheckoutData
CheckoutModel
方法,驗證裡面的loadCheckoutData()
對應的get方法得到了調用,同時參數是對的。mApi
-
CheckoutModel
單元測試2:mock Api類,指定當它的get方法在收到某些調用的時候,直接調用傳入的callback的onSuccess方法,然後調用loadCheckoutData
CheckoutModel
方法,驗證Otto bus的post方法得到了調用,并且參數是對的。loadCheckoutData()
-
CheckoutModel
單元測試3:mock api類,指定當它的get方法在收到某些調用的時候,直接調用傳入的callback的onFailure方法,然後調用loadCheckoutData
CheckoutModel
loadCheckoutData()
-
CheckoutActivity
單元測試1:啟動一個onCheckoutDataLoaded
,調用他的CheckoutActivity
,傳入含有正确資料的Event,驗證相應的資料view顯示出來了onCheckoutDataLoaded()
-
CheckoutActivity
方法單元測試2:啟動一個onCheckoutDataLoaded()
CheckoutActivity
,傳入含有錯誤資訊的Event,驗證相應的錯誤提示view顯示出來了。onCheckoutDataLoaded()
這裡需要說明的一點是,上面的每一個測試,都是獨立進行的,不是說下面的單元測試依賴于上面的。或者說必須先做上面的,再做下面的。
4. 其他問題
以上就是我們這邊做單元測試用到的技術,以及一個基本流程,下面聊聊其他的幾個問題。
4.1 哪些東西需要測試呢?
- 所有的Model、Presenter/ViewModel、Api、Utils等類的public方法
- Data類除了getter、setter、toString、hashCode等一般可以自動生成的方法之外的邏輯部分
- 自定義View的功能:比如set data以後,text有沒有顯示出來等等,簡單的互動,比如click事件,負責的互動一般不測,比如touch、滑動事件等等。
- 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的具體使用,可以參考這篇文章
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的已經有一個印象了,是以你往往會寫出能順利通過的測試,而忽略一些會讓測試不通過的情況。如果先寫測試,則能考慮得更全面。
5.8 未來的打算
使用Groovy和RoboSpock或者是Kotlin和Spek,實作BDD,這是很可能的事情,隻是目前我這邊還沒太多那方面的實踐,是以就不說太多了。以後有一定實踐了,到時候可以再跟大家交流。
這些基本就是這次分享的主要内容,大家可以通路我的網站http://chriszou.com/ ,或關注我的公衆号:
上面分享中提到的每一個比較重要的點(單元測試的定義、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執行得還是比較好的
更多精彩内容歡迎關注bugly的微信公衆賬号:
騰訊 Bugly是一款專為移動開發者打造的品質監控工具,幫助開發者快速,便捷的定位線上應用崩潰的情況以及解決方案。智能合并功能幫助開發同學把每天上報的數千條 Crash 根據根因合并分類,每日日報會列出影響使用者數最多的崩潰,精準定位功能幫助開發同學定位到出問題的代碼行,實時上報可以在釋出後快速的了解應用的品質情況,适配最新的 iOS, Android 官方作業系統,鵝廠的工程師都在使用,快來加入我們吧!