安卓單元測(cè)試全攻略,讓代碼測(cè)試一勞永逸
前言
安卓單元測(cè)試,只看這一篇就足夠啦。真正的完全解析,真正的從0到1,Junit結(jié)合Mockito與Robolectric實(shí)現(xiàn)從M到V再到P,Jacoco掃描函數(shù)、邏輯、代碼行數(shù)單元測(cè)試覆蓋率100%的全面測(cè)試。你是否還在為了驗(yàn)證聯(lián)網(wǎng)與未聯(lián)網(wǎng)狀態(tài)而頻繁的開(kāi)關(guān)WiFi開(kāi)關(guān)?或者你是否還在為一個(gè)switch判斷而頻繁的使用debug斷點(diǎn)setValue來(lái)觀測(cè)代碼的邏輯判斷情況?又或者你是否還在為了校驗(yàn)?zāi)硞€(gè)UI文案的正確性而反復(fù)的比對(duì)UI稿?可能你會(huì)反問(wèn),難道寫(xiě)完代碼自測(cè)也有錯(cuò)?當(dāng)然不是,自測(cè)是一個(gè)良好的習(xí)慣,不過(guò)作為一名工程師,你要做的不應(yīng)該只是看看點(diǎn)點(diǎn)的黑盒測(cè)試,而是應(yīng)該設(shè)計(jì)出一套能夠讓代碼測(cè)試代碼,一勞永逸的測(cè)試工程。
正文
首先我們從Model層開(kāi)始,通過(guò)具體代碼來(lái)詳盡說(shuō)明一下一個(gè)單元測(cè)試覆蓋率100%的測(cè)試工程是如何建立的。嚴(yán)格意義上講,Model數(shù)據(jù)層負(fù)責(zé)數(shù)據(jù)加載與儲(chǔ)存,是游離于安卓環(huán)境之外的存在,所以它可以不需要借助安卓SDK的支持。使用Junit結(jié)合Mockito即可做到100%條件分支覆蓋率的單元測(cè)試。如果項(xiàng)目的Model層有安卓依賴,可能就表明此處的代碼需要重構(gòu)了,這也是單元測(cè)試其中的一個(gè)意義,讓代碼邏輯更清晰。清除Model層的安卓依賴的另一層面好處是讓測(cè)試case更高效,含有android依賴的測(cè)試case執(zhí)行最快也需要5秒,但對(duì)于一個(gè)沒(méi)有安卓依賴的Model類(lèi),跑完全部case的時(shí)間可以降低至毫秒級(jí)。所以,去除Model層所不需要的安卓依賴還是很有必要的。
代碼
Model層測(cè)試代碼如下:
- @RunWith(MockitoJUnitRunner.class)
- public classWeatherModelTest {
- privateWeatherModelmodel;
- @Mock
- ApiServiceapi;
- @Mock
- WeatherDataConvertconvertData;
- @Mock
- WeatherRequestListenerlistener;
- private static finalStringJSON_ROOT_PATH="/json/";
- privateStringjsonFullPath;
- privateWeatherDatanetData;
- privateMapqueryMap;
- @Before
- public voidsetUp() {
- RxUnitTestTools.openRxTools();
- model=newWeatherModel();
- }
- private voidinitResponse() {
- try{
- jsonFullPath= getClass().getResource(JSON_ROOT_PATH).toURI().getPath();
- }
- catch(URISyntaxException e) {
- e.printStackTrace();
- }
- String json = getResponseString("weather.json");
- Gson gson =newGson();
- netData= gson.fromJson(json,WeatherData.class);
- model.setApiService(api);
- try{
- Field field = WeatherModel.class.getDeclaredField("convert");
- field.setAccessible(true);
- field.set(model,convertData);
- }
- catch(Exception e) {
- //reflect error
- }
- queryMap=newHashMap<>();
- queryMap.put("city","沈陽(yáng)");
- }
- privateStringgetResponseString(String fileName) {
- returnFileUtil.readFile(jsonFullPath+ fileName,"UTF-8").toString();
- }
- private voidsetFinalStatic(Field field,Object newValue)throwsException {
- field.setAccessible(true);
- Field modifiersField = Field.class.getDeclaredField("modifiers");
- modifiersField.setAccessible(true);
- modifiersField.setint(field,field.getModifiers() & ~Modifier.FINAL);
- }
- }
首先通過(guò)@Mock注解對(duì)需要mock的對(duì)象進(jìn)行初始化,然后我們需要對(duì)測(cè)試類(lèi)進(jìn)行測(cè)試case分析,WeatherModelmode類(lèi)是一個(gè)網(wǎng)絡(luò)請(qǐng)求數(shù)據(jù)model,所以這個(gè)model類(lèi)的核心是request函數(shù)。首先對(duì)request函數(shù)進(jìn)行分析。必須涵蓋的測(cè)試點(diǎn)如下:請(qǐng)求參數(shù)校驗(yàn),請(qǐng)求成功且返回碼正確處理邏輯校驗(yàn),請(qǐng)求成功但校驗(yàn)碼錯(cuò)誤處理邏輯校驗(yàn)和請(qǐng)求失敗處理邏輯校驗(yàn)。同時(shí)Model類(lèi)中還有一個(gè)觀察者解綁函數(shù),所以測(cè)試case也需要包含解綁函數(shù)處理邏輯測(cè)試這一項(xiàng)。通過(guò)initResponse,我們可以對(duì)接口返回值進(jìn)行模擬,這里采用讀Json文件的方法將接口返回做成Json數(shù)據(jù)文件,結(jié)合服務(wù)端的Swagger文檔可以很輕易的實(shí)現(xiàn)服務(wù)端接口數(shù)據(jù)模擬。
- @Test@SuppressWarnings("unchecked")public voidtestParams() {
- model.request(listener,"沈陽(yáng)");
- try{
- Field fieldParam = WeatherModel.class.getDeclaredField("queryMap");
- Field fieldKey = WeatherModel.class.getDeclaredField("CITY");
- fieldParam.setAccessible(true);
- setFinalStatic(fieldKey, true);
- Map queryMaps = (Map) fieldParam.get(model);
- String key = (String) fieldKey.get(model);
- assertEquals("驗(yàn)證queryMap的Key",key,"city");
- String city = queryMaps.get("city");
- assertEquals("驗(yàn)證queryMap的value",city,"沈陽(yáng)");
- }
- catch(Exception e) {
- //reflect error}
- }
對(duì)于有參數(shù)的Api,***步就是驗(yàn)證傳參。可能你會(huì)覺(jué)得大材小用,但眾多的血淋淋的慘案告訴我們?cè)绞羌?xì)小的東西越容易產(chǎn)生問(wèn)題,而單元測(cè)試就是幫助我們將細(xì)小的問(wèn)題解決在編碼時(shí)期而不對(duì)外暴露。要驗(yàn)證參數(shù)的正確性,首先我們需要要驗(yàn)證向queryMap中put的時(shí)候是否正確。對(duì)于queryMap,我們需要驗(yàn)證K-V鍵值對(duì)的正確性,還是那句防微杜漸,因?yàn)閝ueryMap是一個(gè)private變量,在正常情況下我們無(wú)法獲取到它的值,而為這個(gè)變量加一個(gè)對(duì)業(yè)務(wù)毫無(wú)用處的get/set方法就顯得太刻意了,我們的目的是為了解決讓代碼更健壯,bug更少,而不是為了測(cè)試而測(cè)試。拿不到queryMap參數(shù)測(cè)試還怎么進(jìn)行?難道單元測(cè)試也要從入門(mén)到放棄?要其實(shí)很多事情都是這樣,當(dāng)你覺(jué)得某個(gè)問(wèn)題完全沒(méi)有辦法解決的時(shí)候,一定是你考慮的不夠周全。queryMap對(duì)象的值我們可以通過(guò)Java反射獲得。反射的原理在這里我就不為大家闡述了,在testParams方法中,我們首先通過(guò)getDeclaredField獲取了queryMap對(duì)象,然后我們需要獲得到put的key。key的獲得使我們陷入了第二個(gè)難題,可能你會(huì)說(shuō),這有什么難的,繼續(xù)反射啊,可這個(gè)key是一個(gè)private static變量,通過(guò)正常的反射是無(wú)法拿到key的,最多會(huì)拿到一個(gè)異常。還是那句,不要放棄尋找解決方案,最終我們發(fā)現(xiàn)只要設(shè)置下虛擬機(jī)不去檢測(cè)私有屬性,即可完成對(duì)private static變量的獲取。不要覺(jué)得只是很小的一個(gè)參數(shù),這么勞師動(dòng)眾不值得,據(jù)不完全統(tǒng)計(jì),每天因?yàn)榻涌趉ey值多寫(xiě)或是寫(xiě)錯(cuò)一個(gè)字母而產(chǎn)生的bug不計(jì)其數(shù)。
- @Test@SuppressWarnings("unchecked")public voidtestRequestSuccess() {
- initResponse();
- Mockito.when(api.getWeather(queryMap)).thenReturn(Observable.just(netData));
- ArgumentCaptor captor = ArgumentCaptor.forClass(WeatherData.class);
- model.request(listener,"沈陽(yáng)");
- Mockito.verify(api).getWeather(queryMap);
- Mockito.verify(listener).showLoading();
- Mockito.verify(listener).hideLoading();
- Mockito.verify(convertData).convertData(captor.capture());
- WeatherData result = captor.getValue();
- intstatus = result.getStatus();
- assertEquals("驗(yàn)證code",status,1000);
- }
保證的參數(shù)傳遞的前提下,我們接下來(lái)需要對(duì)接口返回狀態(tài)進(jìn)行測(cè)試,首先便是成功態(tài)的接口返回。Mockito.when的作用是設(shè)定預(yù)期返回結(jié)果,例如case testRequestSuccess()所要測(cè)試的是請(qǐng)求成功且返回碼正確的情況,所以我們對(duì)response的預(yù)期就是讓它執(zhí)行onNext方法,同時(shí)返回我們初始化好的完全正確的接口數(shù)據(jù)。Mockito.when使得測(cè)試代碼可以完全按照我們所預(yù)期的執(zhí)行。不過(guò)這個(gè)聲明必須在方法執(zhí)行之前,即Mockito.when必須比model.request(listener,"沈陽(yáng)");先執(zhí)行才會(huì)生效。Junit提供了豐富的assert斷言機(jī)制,借助assert我們可以實(shí)現(xiàn)多種情況的測(cè)試,然而對(duì)于沒(méi)有明確返回值的void方法,assert就顯得有些無(wú)能為力,因?yàn)樗鼰o(wú)法找到一個(gè)標(biāo)準(zhǔn)進(jìn)行斷言。這時(shí)候需要使用mockito的verify方法,它的作用是驗(yàn)證mock對(duì)象的某一個(gè)方法是否得到了正確的執(zhí)行Mockito.verify(listener).showLoading();就是驗(yàn)證加載進(jìn)度條是否能夠正常顯示,ArgumentCaptor是一個(gè)參數(shù)捕獲,它可以捕獲onNext返回的數(shù)據(jù),通過(guò)assert斷言,我們可以驗(yàn)證成功情況下數(shù)據(jù)是否正確。數(shù)據(jù)成功情況下,我們有一個(gè)網(wǎng)絡(luò)數(shù)據(jù)向視圖數(shù)據(jù)轉(zhuǎn)換的過(guò)程,這個(gè)轉(zhuǎn)換方法是在convert類(lèi)中執(zhí)行的操作,因?yàn)槲覀冏龅氖菃卧獪y(cè)試而非集成測(cè)試,所以基于WeatherModel這個(gè)測(cè)試類(lèi),我們只需要驗(yàn)證到convertData()這個(gè)函數(shù)是否正確得到了調(diào)用即可,數(shù)據(jù)轉(zhuǎn)換的內(nèi)容由Convert類(lèi)的單元測(cè)試進(jìn)行跟蹤即可。
- @Testpublic voidtestStatusError() {
- initResponse();
- netData.setStatus(1001);
- Mockito.when(api.getWeather(queryMap)).thenReturn(Observable.just(netData));
- ArgumentCaptor captor = ArgumentCaptor.forClass(WeatherData.class);
- model.request(listener,"沈陽(yáng)");
- Mockito.verify(api).getWeather(queryMap);
- Mockito.verify(listener).showLoading();
- Mockito.verify(listener).fail(null,ServerCode.get(netData.getStatus()).getMessage());
- }
在實(shí)際開(kāi)發(fā)過(guò)程中,服務(wù)端通常會(huì)對(duì)同一接口的不同狀態(tài)做成不同的服務(wù)應(yīng)答碼,雖然返回非常態(tài)應(yīng)答碼的時(shí)候網(wǎng)絡(luò)請(qǐng)求也是成功,但它卻是有別于常態(tài)服務(wù)端應(yīng)答的另一種情況。所以,這里需要對(duì)非常態(tài)服務(wù)應(yīng)答碼進(jìn)行一個(gè)條件分支的測(cè)試。testStatusError ()的測(cè)試方法與testRequestSuccess()類(lèi)似,只是我們這次的status模擬值由成功的status換成了一個(gè)異常status,同時(shí),驗(yàn)證的函數(shù)執(zhí)行也變成了listener的失敗方法
- @Testpublic voidtestRequestFail() {
- initResponse();
- Exception exception =newException("exception");
- Mockito.when(api.getWeather(queryMap)).thenReturn(Observable.error(exception));
- model.request(listener,"沈陽(yáng)");
- Mockito.verify(listener).fail(null,"exception");
- }
Request是一個(gè)接口,我們不能夠保證每次請(qǐng)求我們的服務(wù)器都能夠給與準(zhǔn)確應(yīng)答,同時(shí)用戶在發(fā)出請(qǐng)求的時(shí)候我們也不能夠保證用戶所處的網(wǎng)絡(luò)狀態(tài)是否通暢。所以我們?cè)谠O(shè)計(jì)Model類(lèi)的時(shí)候也要將非常態(tài)考慮在內(nèi),對(duì)接口的異常情況進(jìn)行處理,有時(shí)候我們需要自己創(chuàng)造一些異常來(lái)驗(yàn)證我們代碼的健壯程度。同樣的,我們的測(cè)試類(lèi)也需要有一個(gè)專(zhuān)門(mén)的方法來(lái)保證異常態(tài)的測(cè)試。testRequestFail()的測(cè)試方法與成功的方法的不同之處在于我們首先我們需要mock的不是接口數(shù)據(jù),而是一個(gè)異常,Exception exception = new Exception("exception");注意,這個(gè)Exception中的參數(shù)即是異常信息,因?yàn)槲覀兊膄ail方法中有異常信息的顯示,所以這個(gè)參數(shù)是必須要加上的,否則e.getLocalizedMessage()會(huì)拋出NPE。另外,這個(gè)時(shí)候的Mockito.when的期望也有所改變,這次我們期望的是函數(shù)執(zhí)行onError方法。
- @Testpublic voidtestCancelRequest() {
- Subscription subscription =mock(Subscription.class);
- model.setSubscription(subscription);
- model.cancelRequest();
- verify(subscription).unsubscribe();
- }
Model類(lèi)中***一個(gè)case是testCancelRequest()它的作用是,在合適的時(shí)候解綁request,我們的網(wǎng)絡(luò)請(qǐng)求是異步的,也就是說(shuō)當(dāng)我們調(diào)用請(qǐng)求的activity或是fragment destroy的時(shí)候,如果我們沒(méi)有解除綁定,是存在內(nèi)存泄漏風(fēng)險(xiǎn)的。當(dāng)然,我們能想到的問(wèn)題,Rxjava的維護(hù)者們也一定想到了,Subscription就是方便我們?cè)谏芷诮Y(jié)束的時(shí)候?qū)x解綁。驗(yàn)證方法很簡(jiǎn)單,還是通過(guò)verify方法,驗(yàn)證解綁方法是否得到了正確執(zhí)行。
- dependencies {
- classpath'com.vanniktech:gradle-android-junit-jacoco-plugin:0.6.0'
- }
至此我們已經(jīng)完成了對(duì)model的全覆蓋測(cè)試,點(diǎn)擊測(cè)試類(lèi)前面的運(yùn)行按鈕,可以看到所有測(cè)試類(lèi)運(yùn)行的情況,綠色代表成功,紅色代表存在問(wèn)題,可以通過(guò)下方的Log日志查看引起測(cè)試失敗的問(wèn)題點(diǎn)進(jìn)行改正,借助Jacoco統(tǒng)計(jì)工具可以看到單元測(cè)試覆蓋率的情況。之所以選擇使用Jacoco而不是IDE自帶的Coverage是因?yàn)樵跍y(cè)試&條件分支的情況下Coverage存在漏洞,導(dǎo)致沒(méi)有達(dá)到全覆蓋的測(cè)試顯示已覆蓋完全。Jacoco的AndroidStudio集成網(wǎng)絡(luò)資源并不多,集成方法不是存在潛在漏洞就是過(guò)于繁瑣。經(jīng)過(guò)兩天的不斷搜索,終于發(fā)現(xiàn)了一個(gè)史上最簡(jiǎn)單集成方法,只需要在主工程的gradle文件中添加一個(gè)Jacoco插件,gradle就會(huì)生成一個(gè)Jacoco Task,雙擊運(yùn)行即可生成一份Html覆蓋率報(bào)告。運(yùn)行我們的model測(cè)試類(lèi),從jacoco生成的html可以看到,我們的model已經(jīng)達(dá)到了100%的全覆蓋。既然如此,我們是不是就可以認(rèn)為MVP的M層已經(jīng)ok了呢?等等,我們好像遺漏了點(diǎn)什么,沒(méi)錯(cuò),onNext情況下的數(shù)據(jù)轉(zhuǎn)換類(lèi)還沒(méi)有測(cè)試,下面我們來(lái)對(duì)convert類(lèi)進(jìn)行一下測(cè)試。
首先們來(lái)看看convert類(lèi)代碼:
- /**
- * Author : YangHaoyi on 2017/6/28.
- * Email : yanghaoyi@neusoft.com
- * Description :網(wǎng)絡(luò)數(shù)據(jù)與視圖數(shù)據(jù)轉(zhuǎn)換器
- * Change : YangHaoYi on 2017/6/28.
- * Version : V 1.0
- */
- open classWeatherDataConvert {
- open funconvertData(netData: WeatherData):WeatherViewData{
- valviewData= WeatherViewData()
- viewData.temperature= netData.data?.temperature?:0.0viewData.weatherType= netData.data?.weatherType?:1viewData.ultraviolet= netData.data?.ultraviolet?:0viewData.rainfall= netData.data?.rainfall?:"0"viewData.hourTemperature= netData.data?.hourTemperature?:"10"viewData.windPower= netData.data?.windPower?:"2"returnviewData
- }
- }
從代碼可以看出我們的convert類(lèi)看起來(lái)有一些的奇怪,每錯(cuò),因?yàn)樗⒉皇莏ava代碼,它是kotlin。好好的java工程為什么要混入kotlin,單單只是為了炫技么?當(dāng)然不是,數(shù)據(jù)轉(zhuǎn)換類(lèi)的作用是對(duì)網(wǎng)絡(luò)數(shù)據(jù)進(jìn)行判空并包裝成視圖數(shù)據(jù),我們都知道在java中的判空,需要層層嵌套,例如,我們需要判斷Student類(lèi)中的Score類(lèi)中的EnglishScore字段,我們的寫(xiě)法如下:
- if(Student!=null&&Student.getScore()!=null&&Student.getScore().getEnglishScore()!=null){}
這是一個(gè)很多層的判斷,而對(duì)于kotlin我們只需要寫(xiě)Student?.score?.englishScore即可,代碼量巨減有沒(méi)有。對(duì)于kotlin的特性,有興趣的同學(xué)可以移步官網(wǎng)去詳細(xì)了解。
讓我們回歸單元測(cè)試,convert類(lèi)是一個(gè)數(shù)據(jù)判空類(lèi),它的作用是對(duì)數(shù)據(jù)進(jìn)行組裝并賦予默認(rèn)初值,因?yàn)榉?wù)端的數(shù)據(jù)不可控,作為手機(jī)端我們不能把用戶體驗(yàn)完全寄托于后端的兄弟,因?yàn)榉胚^(guò)任何一個(gè)null數(shù)據(jù)對(duì)于App都是一個(gè)Crash。所以我們的測(cè)試點(diǎn)就是,這個(gè)類(lèi)是否達(dá)到了當(dāng)數(shù)據(jù)為空的時(shí)候賦予默認(rèn)值,當(dāng)數(shù)據(jù)不為空的時(shí)候取網(wǎng)絡(luò)數(shù)據(jù)值的作用。這里選取一個(gè)比較有代表性的testTemperature為例,首先設(shè)定模擬WeatherData的值為10D,因?yàn)榫W(wǎng)絡(luò)數(shù)據(jù)有值,所以會(huì)取網(wǎng)絡(luò)數(shù)據(jù)的值即10D,通過(guò)assertEquals可以進(jìn)行斷言比對(duì)驗(yàn)證,不過(guò)有一個(gè)需要注意的是double型的斷言assertEquals(message,double1,double2)是不可用的,直接運(yùn)行的話會(huì)報(bào)測(cè)試失敗。Double的比對(duì)需要加上一個(gè)誤差值,這里給一個(gè)誤差值0.1D,再次運(yùn)行,測(cè)試條變綠。同時(shí)我們需要測(cè)試當(dāng)WeatherData為空的情況下,viewData是否被賦予了默認(rèn)值0.0。以此類(lèi)推,我們需要對(duì)每一條數(shù)據(jù)進(jìn)行校驗(yàn),并包裝成視圖數(shù)據(jù)。
- /**
- * Author : YangHaoyi on 2017/7/7.
- * Email : yanghaoyi@neusoft.com
- * Description :
- * Change : YangHaoYi on 2017/7/7.
- * Version : V 1.0
- */
- public classWeatherDataConvertTest {
- privateWeatherDataConvertconvert;
- private static doubleDETAL=0.1D;
- @Beforepublic voidsetUp(){
- convert=newWeatherDataConvert();
- }
- @Testpublic voidtestTemperature(){
- WeatherData netData =newWeatherData();
- WeatherData.DataBean dataBean =newWeatherData.DataBean();
- dataBean.setTemperature(10D);
- netData.setData(dataBean);
- WeatherViewData viewData =convert.convertData(netData);
- //斷言double不可以用assertEquals(message,double1,double2)//需要改用下面的方法,DETAL為誤差值assertEquals(viewData.getTemperature(),10D,DETAL);
- }
- @Testpublic voidtestTemperatureNull(){
- WeatherData netData =newWeatherData();
- WeatherData.DataBean dataBean =newWeatherData.DataBean();
- netData.setData(dataBean);
- WeatherViewData viewData =convert.convertData(netData);
- //斷言double不可以用assertEquals(message,double1,double2)//需要改用下面的方法,DETAL為誤差值assertEquals(viewData.getTemperature(),0D,DETAL);
- }
- }
Convert類(lèi)的順利執(zhí)行標(biāo)志著Model層的測(cè)試圓滿結(jié)束,下面讓我們來(lái)看一看MVP架構(gòu)下的第二順位View層的測(cè)試,如果我們不借助UI測(cè)試框架直接運(yùn)行UI測(cè)試是無(wú)法得到預(yù)期的驗(yàn)證的,因?yàn)槲覀冎粫?huì)得到一個(gè)運(yùn)行時(shí)異常。可是我們?cè)跇?gòu)建工程之前已經(jīng)下載了對(duì)應(yīng)版本的安卓Sdk,為什么還是會(huì)拋出異常呢?在真機(jī)或是模擬器上面為什么不會(huì)呢?是不是IDE只為我們提供了工程的開(kāi)發(fā)與編譯環(huán)境,并沒(méi)有提供工程的運(yùn)行環(huán)境呢?引用Linus Torvalds的那句經(jīng)典的RTFSC,讓我們通過(guò)源碼來(lái)一點(diǎn)點(diǎn)驗(yàn)證我們的猜想。首先我們找到SDK對(duì)應(yīng)的android.jar文件,然后隨便找個(gè)工程add as library,以我們最常用的Activity為例,源碼如下:
- public WindowManager getWindowManager() {
- throw newRuntimeException("Stub!");
- }
- public Window getWindow() {
- throw newRuntimeException("Stub!");
- }
- public LoaderManager getLoaderManager() {
- throw newRuntimeException("Stub!");
- }
- public View getCurrentFocus() {
- throw newRuntimeException("Stub!");
- }
- protected void onCreate(BundlesavedInstanceState) {
- throw new RuntimeException("Stub!");
- }
- public void onCreate(BundlesavedInstanceState, PersistableBundle persistentState) {
- throw newRuntimeException("Stub!");
- }
我們可以清除的看到所有的方法都不約而同的拋出了RuntimeException("Stub!"),這也就是我們的測(cè)試case無(wú)法進(jìn)行的原因。為了應(yīng)對(duì)UI單元測(cè)試難以推進(jìn)的現(xiàn)狀,谷歌推出了一套名為Espresso的UI單元測(cè)試框架,由于是官方的框架,所以在工程的運(yùn)行以及相關(guān)資料的跟進(jìn)都做的比較完善。然而Espresso的短板也非常明顯,Espresso必須借助于安卓模擬器或是真機(jī)環(huán)境才能夠運(yùn)行,也正是因?yàn)樾枰诎沧吭O(shè)備上運(yùn)行,Espresso的運(yùn)行速度非常緩慢,使之與Jenkins相結(jié)合進(jìn)行自動(dòng)化構(gòu)建更是難上加難。這不禁讓我陷入沉思,如果UI單元測(cè)試需要如此的大費(fèi)周章,那是否還有測(cè)下去的必要?不過(guò)很快迭代的bug統(tǒng)計(jì)就打消了我放棄UI只做邏輯測(cè)試的念頭。我們手機(jī)組在迭代過(guò)程中的UI與邏輯bug比基本可以達(dá)到5比1,也就是說(shuō)有絕大多數(shù)問(wèn)題產(chǎn)生在了視圖層,單元測(cè)試的目的是減少bug產(chǎn)生,而目前UI就是我們***的痛點(diǎn),UI單元測(cè)試勢(shì)在必行。經(jīng)過(guò)不斷的資源搜索,最終我到了一個(gè)可以不借助安卓設(shè)備的UI測(cè)試框架Robolectric,它的設(shè)計(jì)思路是通過(guò)實(shí)現(xiàn)一套JVM能運(yùn)行Android代碼,從而做到脫離Android環(huán)境進(jìn)行測(cè)試。由于robolectric需要從oss.sonatype.org下載一些必要的依賴包,但是oss.sonatype.org是國(guó)外的網(wǎng)站,下載速度比較緩慢。這里需要修改整個(gè)工程的build.gradle文件,修改mavenCentral()為阿里云{"http://maven.aliyun.com/nexus/content/groups/public/"} 的代理。
Robolectric的依賴為:
- testCompile'org.robolectric:robolectric:3.3.2'
運(yùn)行Robolectric需要首先對(duì)測(cè)試類(lèi)進(jìn)行配置,如下:
- @RunWith(MyRobolectricTestRunner.class)@Config(constants= BuildConfig.class,sdk=24)
MyRobolectricTestRunner為自定義的指向阿里云的配置文件,BuildConfig為當(dāng)前model的BuildConfig文件,sdk為使用的sdk版本,之所以指定sdk版本是因?yàn)镽obolectric需要下載對(duì)應(yīng)sdk的鏡像資源,指定版本就會(huì)使用本地已經(jīng)下載好的sdk資源。***次運(yùn)行測(cè)試的時(shí)候會(huì)自動(dòng)到阿里云去下載相關(guān)文件,然后會(huì)在系統(tǒng)的C盤(pán)下生成一個(gè).m2文件夾,如果依舊下載緩慢,可直接拷貝.m2文件夾到自己電腦的相對(duì)目錄下直接使用。Robolectric幾乎可以測(cè)試一切安卓方法,使用也是非常簡(jiǎn)單。例如:
- @Beforepublic voidsetUp() {
- activity= Robolectric.setupActivity(WeatherActivity.class);
- }
實(shí)現(xiàn)的便是創(chuàng)建一個(gè)Activity,一行代碼即可模擬activity的創(chuàng)建與運(yùn)行。一行代碼就解決了一直困擾我們對(duì)于android環(huán)境無(wú)法獲取的苦惱。有了Activity對(duì)象,瞬間覺(jué)得可以解決所有問(wèn)題。例如測(cè)試頁(yè)面的跳轉(zhuǎn):
- @Testpublic voidtestToHelpCenter(){
- view.toHelpCenter();
- //設(shè)置期待IntentIntent expectedIntent =newIntent(activity,WeatherHelpCenterActivity.class);//獲取實(shí)際IntentIntent actualIntent = ShadowApplication.getInstance().getNextStartedActivity();//通過(guò)Assert驗(yàn)證Assert.assertEquals(expectedIntent.getComponent(),actualIntent.getComponent());
- }
設(shè)置好當(dāng)前頁(yè)面與跳轉(zhuǎn)頁(yè)面,Robolectric就能夠幫助我們模擬出我們所期待的Intent,同時(shí)通過(guò)ShadowApplicaiton可以獲取到模擬運(yùn)行后的實(shí)際Intent的值,結(jié)合Junit即可完成對(duì)Intent的驗(yàn)證,進(jìn)而驗(yàn)證頁(yè)面跳轉(zhuǎn)邏輯。
TextView是我們?cè)陂_(kāi)發(fā)過(guò)程中最常用也是最容易出錯(cuò)的一個(gè)UI組件,尤其是團(tuán)隊(duì)的設(shè)計(jì)師是一個(gè)非常把不同地方的文案設(shè)計(jì)得非常想象而又有著細(xì)微差別的時(shí)候,我們非常容易多打或是少打一個(gè)字,又或是錯(cuò)別或是形近字。為了保證產(chǎn)品質(zhì)量,我們不得不一遍又一遍的比對(duì)UI稿件,錙銖必較,逐字觀察,簡(jiǎn)直苦不堪言。所謂程序即生活,難道我們生活中就沒(méi)有這種校驗(yàn)文字的困擾么?生活中我們又都是怎么解決的呢?記得許多年前時(shí)不時(shí)會(huì)看到有人去ATM轉(zhuǎn)賬轉(zhuǎn)錯(cuò)的新聞,今年來(lái)倒是很少有這樣的新聞了,原因就在于銀行對(duì)于銀行卡號(hào)作了二次校驗(yàn)。對(duì)于TextView的測(cè)試也是利用了二次校驗(yàn)的方法,***次文字使用業(yè)務(wù)代碼,第二次代碼使用測(cè)試代碼進(jìn)行校驗(yàn),如果兩次不一致則證明文字存在問(wèn)題。這樣就可以有效的避免了靠肉眼比對(duì)的不確定性,讓程序去驗(yàn)證程序。
- @Testpublic voidtestShowTemperature(){
- //模擬視圖數(shù)據(jù)WeatherViewData viewData =newWeatherViewData();
- viewData.setTemperature(23.1D);
- view.updateCache(viewData);
- //執(zhí)行待測(cè)函數(shù)view.showTemperature();//通過(guò)Id獲得view實(shí)體TextView tvTemperature = (TextView)activity.findViewById(R.id.tvTemperature);
- String text = tvTemperature.getText().toString();
- //驗(yàn)證文字顯示assertEquals("驗(yàn)證溫度",text,"23.1");
- }
首先通過(guò)view.showTemperature();調(diào)用執(zhí)行函數(shù),在通過(guò)Id找到對(duì)應(yīng)的TextView組件,通過(guò)getText獲取TextView的顯示文字,再通過(guò)Junit的aseertEquals進(jìn)行字符串驗(yàn)證即可。如果發(fā)生比對(duì)失敗,通過(guò)下方的Log提示click to see difference即可準(zhǔn)確的看到差異點(diǎn)。
Robolectric對(duì)于提示Tost的測(cè)試也是非常的簡(jiǎn)單,只需要:
- @Testpublic voidtestShowDataError(){
- view.showDataError();
- assertEquals("數(shù)據(jù)轉(zhuǎn)換異常",ShadowToast.getTextOfLatestToast());
- }
測(cè)試Resource中的顏色:
- @Testpublic voidtestInitTitle(){
- TextView tvTitle = (TextView)activity.findViewById(R.id.tvTitle);
- view.initTitle();
- String title = tvTitle.getText().toString();
- assertEquals("驗(yàn)證標(biāo)題初始化",title,"幫助中心");
- Application application = RuntimeEnvironment.application;
- ColorStateList color = ColorStateList.valueOf(application.getResources().getColor(R.color.colorWhite));
- assertEquals("驗(yàn)證顏色",color,tvTitle.getTextColors());
- }
測(cè)試Dialog:
- @Testpublic voidtestShowTelDialog(){
- view.showTelDialog();
- //因?yàn)樘崾究?nbsp;dialog 在 view 中屬于私有變量,不需要對(duì)外暴露方法,如果為了測(cè)試而寫(xiě)一個(gè)get set 方法似乎太過(guò)牽強(qiáng)//所以采用 Java 反射的方法獲取dialog對(duì)象try{// /通過(guò)類(lèi)的字節(jié)碼得到該類(lèi)中聲明的所有屬性,無(wú)論私有或公有Field field = WeatherHelpCenterImpl.class.getDeclaredField("telDialog");// 設(shè)置訪問(wèn)權(quán)限(這點(diǎn)對(duì)于有過(guò)android開(kāi)發(fā)經(jīng)驗(yàn)的可以說(shuō)很熟悉)field.setAccessible(true);// 得到私有的變量值Object dialog = field.get(view);
- TConfirmDialog telDialog = (TConfirmDialog) dialog;
- //獲取到Dialog對(duì)象之后,再通過(guò)反射獲取Dialog中TextView對(duì)象Field fieldDialog = TConfirmDialog.class.getDeclaredField("tvTitle");// 設(shè)置訪問(wèn)權(quán)限fieldDialog.setAccessible(true);//獲取telDialog中的TextView對(duì)象Object title = fieldDialog.get(telDialog);
- TextView tvTitle = (TextView) title;
- //通過(guò)assert方法驗(yàn)證標(biāo)題assertEquals("驗(yàn)證標(biāo)題",tvTitle.getText().toString(),"客服電話");//獲取到Dialog對(duì)象之后,再通過(guò)反射獲取Dialog中TextView對(duì)象fieldDialog = TConfirmDialog.class.getDeclaredField("tvConfirm");//獲取telDialog中的TextView對(duì)象Object confirm = fieldDialog.get(telDialog);
- TextView tvConfirm = (TextView) confirm;
- //通過(guò)assert方法驗(yàn)證標(biāo)題assertEquals("驗(yàn)證確定按鈕",tvConfirm.getText().toString(),"撥打電話");//獲取到Dialog對(duì)象之后,再通過(guò)反射獲取Dialog中TextView對(duì)象fieldDialog = TConfirmDialog.class.getDeclaredField("tvCancel");//獲取telDialog中的TextView對(duì)象Object cancel = fieldDialog.get(telDialog);
- TextView tvCancel = (TextView) cancel;
- //通過(guò)assert方法驗(yàn)證標(biāo)題assertEquals("驗(yàn)證取消按鈕",tvCancel.getText().toString(),"取消");
- }
- catch(Exception e) {
- //error}
- }
Dialog的測(cè)試點(diǎn)需要包括Dialog的顯示與隱藏,Dialog的提示文字與按鈕的文字顯示,因?yàn)楹芏嗍撬接凶兞浚赃@里用到了一些Java反射來(lái)幫助獲取對(duì)象。
目前為止,我們已經(jīng)完成了對(duì)Model層與View層的測(cè)試,MVP三兄弟只剩下P層還沒(méi)有測(cè)試,下面我們就來(lái)看看P層該如何測(cè)試。P層作為M層與V層的紐帶,起到了隔離視圖與數(shù)據(jù)直接交互的作用。因?yàn)镻層持有的只是V的接口,所以P層也可以抽離成簡(jiǎn)單的純Java測(cè)試。讓我們先來(lái)看看P層的測(cè)試代碼:
- /**
- * Created by YangHaoyi on 2017/7/8.
- * Email : yanghaoyi@neusoft.com
- * Description :
- * Version :
- */
- public classWeatherPresenterTest {
- privateWeatherPresenterpresenter;
- privateIWeatherViewview;
- privateWeatherControlcontrol;
- privateWeatherModelweatherModel;
- privateWeatherRequestListenerlistener;
- @Beforepublic voidsetUp(){
- view=mock(IWeatherView.class);
- control=mock(WeatherControl.class);
- weatherModel=mock(WeatherModel.class);
- listener=mock(WeatherRequestListener.class);
- presenter=newWeatherPresenter(view);
- presenter.updateWeatherModel(weatherModel);
- presenter.updateControl(control);
- presenter.updateListener(listener);
- }
- @Testpublic voidtestRequest(){
- presenter.request();
- verify(weatherModel).request(listener,view.getLocationCity());
- }
- @Testpublic voidtestCancelRequest(){
- presenter.cancelRequest();
- verify(weatherModel).cancelRequest();
- }
- @Testpublic voidtestShowHourTemperature(){
- presenter.showHourTemperature();
- verify(control).buttonWasPressed(WeatherControl.TEMPERATURE);
- }
- @Testpublic voidtestShowPrecipitation(){
- presenter.showPrecipitation();
- verify(control).buttonWasPressed(WeatherControl.PRECIPITATION);
- }
- @Testpublic voidtestShowWindPower(){
- presenter.showWindPower();
- verify(control).buttonWasPressed(WeatherControl.WINDPOWER);
- }
- @Testpublic voidtestToHelpCenter(){
- presenter.toHelpCenter();
- verify(view).toHelpCenter();
- }
- }
由于這只是一個(gè)示例Demo,沒(méi)有過(guò)多的業(yè)務(wù)邏輯,結(jié)合了幾個(gè)簡(jiǎn)單的設(shè)計(jì)模式,Presenter的代碼變成了絕大多數(shù)的順序執(zhí)行,通過(guò)Mockito的verify即可完成驗(yàn)證。這里需要說(shuō)明一下的是之所以結(jié)合設(shè)計(jì)模式是因?yàn)閱卧獪y(cè)試的原則是每一個(gè)條件分支都需要有一條測(cè)試Case做保證,對(duì)于多分支甚至是多嵌套分支就會(huì)比較繁瑣,需要寫(xiě)大量的重復(fù)代碼,同時(shí)也增大了漏測(cè)的幾率,適當(dāng)?shù)奶砑釉O(shè)計(jì)模式可以很好的彌補(bǔ)這一點(diǎn),將嵌套條件判斷測(cè)底刪除,極大程度減少甚至刪除條件判斷。經(jīng)過(guò)完善代碼后的單元測(cè)試,測(cè)試的只是一些簡(jiǎn)單的if/else單分支判斷。驗(yàn)證方法與Model層的測(cè)試方法大同小異,借助Junit與Mockito我們可以輕易的實(shí)現(xiàn)Presenter層的測(cè)試。
寫(xiě)在后面,很多朋友對(duì)單元測(cè)試都是抱著一種排斥的態(tài)度,覺(jué)得寫(xiě)單元測(cè)試是在浪費(fèi)時(shí)間。其實(shí)不然,如果你把代碼調(diào)試,bug修復(fù)與回歸測(cè)試的時(shí)間也算人進(jìn)去的話,你就會(huì)發(fā)現(xiàn),單元測(cè)試其實(shí)能夠幫助我們節(jié)約大量的時(shí)間。單元測(cè)試的編寫(xiě)要本著驗(yàn)證問(wèn)題的心態(tài)就編碼,切不可以完成任務(wù)指標(biāo)的心態(tài)去編碼,覺(jué)得只是Leader安排的指標(biāo)。很多時(shí)候一個(gè)有經(jīng)驗(yàn)的前人安排你去做某件事的時(shí)候,并不是想讓你完成什么,只是以一個(gè)過(guò)來(lái)人的角度告訴你終南捷徑,東西就在你眼前,誰(shuí)把話聽(tīng)進(jìn)去了,誰(shuí)就得到了。
Jacoco代碼覆蓋率