Android單元測試研究與實踐

Android單元測試介紹

處于高速迭代開發中的Android項目往往需要除黑盒測試外更加可靠的質量保障,這正是單元測試的用武之地。單元測試周期性對項目進行函數級別的測試,在良好的覆蓋率下,能夠持續維護代碼邏輯,從而支持項目從容應對快速的版本更新。

單元測試是參與項目開發的工程師在項目代碼之外建立的白盒測試工程,用于執行項目中的目標函數并驗證其狀態或者結果,其中,單元指的是測試的最小模塊,通常指函數。如圖1所示的綠色文件夾即是單元測試工程。這些代碼能夠檢測目標代碼的正確性,打包時單元測試的代碼不會被編譯進入APK中。


圖1 單元測試工程位置


與Java單元測試相同,Android單元測試也是維護代碼邏輯的白盒工程,但由于Android運行環境的不同,Android單元測試的環境配置以及實施流程均有所不同。

Java單元測試


在傳統Java單元測試中,我們需要針對每個函數進行設計單元測試用例。如圖2便是一個典型的單元測試的用例。


圖2 單元測試示例



上述示例中,針對函數dosomething(Boolean param)的每個分支,我們都需要構造相應的參數并驗證結果。單元測試的目標函數主要有三種:


有明確的返回值,如上圖的dosomething(Boolean param),做單元測試時,只需調用這個函數,然后驗證函數的返回值是否符合預期結果。

這個函數只改變其對象內部的一些屬性或者狀態,函數本身沒有返回值,就驗證它所改變的屬性和狀態。

一些函數沒有返回值,也沒有直接改變哪個值的狀態,這就需要驗證其行為,比如點擊事件。

既沒有返回值,也沒有改變狀態,又沒有觸發行為的函數是不可測試的,在項目中不應該存在。當存在同時具備上述多種特性時,本文建議采用多個case來真對每一種特性逐一驗證,或者采用一個case,逐一執行目標函數并驗證其影響。

構造用例的原則是測試用例與函數一對一,實現條件覆蓋與路徑覆蓋。Java單元測試中,良好的單元測試是需要保證所有函數執行正確的,即所有邊界條件都驗證過,一個用例只測一個函數,便于維護。在Android單元測試中,并不要求對所有函數都覆蓋到,像Android SDK中的函數回調則不用測試。

Android單元測試

在Android中,單元測試的本質依舊是驗證函數的功能,測試框架也是JUnit。在Java中,編寫代碼面對的只有類、對象、函數,編寫單元測試時可以在測試工程中創建一個對象出來然后執行其函數進行測試,而在Android中,編寫代碼需要面對的是組件、控件、生命周期、異步任務、消息傳遞等,雖然本質是SDK主動執行了一些實例的函數,但創建一個Activity并不能讓它執行到resume的狀態,因此需要JUnit之外的框架支持。

當前主流的單元測試框架AndroidTest和Robolectric,前者需要運行在Android環境上,后者可以直接運行在JVM上,速度也更快,可以直接由Jenkins周期性執行,無需準備Android環境。因此我們的單元測試基于Robolectric。對于一些測試對象依賴度較高而需要解除依賴的場景,我們可以借助Mock框架。

Android單元測試環境配置

Robolectric環境配置

Android單元測試依舊需要JUnit框架的支持,Robolectric只是提供了Android代碼的運行環境。如果使用Robolectric 3.0,依賴配置如下:

testCompile?'junit:junit:4.10'

testCompile?'org.robolectric:robolectric:3.0'


Gradle對Robolectric 2.4的支持并不像3.0這樣好,但Robolectric 2.4所有的測試框架均在一個包里,另外參考資料也比較豐富,作者更習慣使用2.4。如果使用Robolectric 2.4,則需要如下配置:


classpath?'org.robolectric:robolectric-gradle-plugin:0.14.+'//這行配置在buildscript的dependencies中

apply plugin:?'robolectric'

androidTestCompile?'org.robolectric:robolectric:2.4'


上述配置中,本文將testCompile寫成androidTest,并且常見的Android工程的單元測試目錄名稱有test也有androidTest,這兩種寫法并沒有功能上的差別,只是Android單元測試Test Artifact不同而已。Test Artifact如圖3所示:


圖3 Test Artifact



在Gradle插件中,這兩種Artifact執行的Task還是有些區別的,但是并不影響單元測試的寫法與效果。雖然可以主動配置單元測試的項目路徑,本文依舊建議采用與Test Artifact對應的項目路徑和配置寫法。


Mock配置


如果要測試的目標對象依賴關系較多,需要解除依賴關系,以免測試用例過于復雜,用Robolectric的Shadow是個辦法,但是推薦更加簡單的Mock框架,比如Mockito,該框架可以模擬出對象來,而且本身提供了一些驗證函數執行的功能。Mockito配置如下:


repositories?{

jcenter()

}

dependencies?{

testCompile?"org.mockito:mockito-core:1.+"

}


Robolectric使用介紹


Robolectric單元測試編寫結構


單元測試代碼寫在項目的test(也可能是androidTest,該目錄在項目中會呈淺綠色)目錄下。單元測試也是一個標準的Java工程,以類為文件單位編寫,執行的最小單位是函數,測試用例(以下簡稱case)是帶有@Test注解的函數,單元測試里面帶有case的類由Robolectric框架執行,需要為該類添加注解@RunWith(RobolectricTestRunner.class)?;赗obolectric的代碼結構如下:


//省略一堆import

@RunWith(RobolectricTestRunner.class)

public?class?MainActivityTest?{

@Before

public?void?setUp()?{

//執行初始化的操作

}

<a?>@Test</a>

public?void?testCase()?{

//執行各種測試邏輯判斷

}

}


上述結構中,帶有@Before注解的函數在該類實例化后,會立即執行,通常用于執行一些初始化的操作,比如構造網絡請求和構造Activity。帶有@test注解的是單元測試的case,由Robolectric執行,這些case本身也是函數,可以在其他函數中調用,因此,case也是可以復用的。每個case都是獨立的,case不會互相影響,即便是相互調用也不會存在多線程干擾的問題。


常見Robolectric用法


Robolectric支持單元測試范圍從Activity的跳轉、Activity展示View(包括菜單)和Fragment到View的點擊觸摸以及事件響應,同時Robolectric也能測試Toast和Dialog。對于需要網絡請求數據的測試,Robolectric可以模擬網絡請求的response。對于一些Robolectric不能測試的對象,比如ConcurrentTask,可以通過自定義Shadow的方式現實測試。下面將著重介紹Robolectric的常見用法。


Robolectric 2.4模擬網絡請求


由于商業App的多數Activity界面數據都是通過網絡請求獲取,因為網絡請求是大多數App首要處理的模塊,測試依賴網絡數據的Activity時,可以在@Before標記的函數中準備網絡數據,進行網絡請求的模擬。準備網絡請求的代碼如下:


public?void?prepareHttpResponse(String?filePath)?throws?IOException?{

String?netData?=?FileUtils.readFileToString(FileUtils.

toFile(getClass().getResource(filePath)),?HTTP.UTF_8);

Robolectric.setDefaultHttpResponse(200,?netData);

}//代碼適用于Robolectric 2.4,3.0需要注意網絡請求的包的位置


由于Robolectric 2.4并不會發送網絡請求,因此需要本地創建網絡請求所返回的數據,上述函數的filePath便是本地數據的文件的路徑,setDefaultHttpResponse()則創建了該請求的Response。上述函數執行后,單元測試工程便擁有了與本地數據數據對應的網絡請求,在這個函數執行后展示的Activity便是有數據的Activity。

在Robolectric 3.0環境下,單元測試可以發真的請求,并且能夠請求到數據,本文依舊建議采用mock的辦法構造網絡請求,而不要依賴網絡環境。


Activity展示測試與跳轉測試


創建網絡請求后,便可以測試Activity了。測試代碼如下:


@Test

public?void?testSampleActivity(){

SampleActivity sampleActivity=Robolectric.buildActivity(SampleActivity.class).

create().resume().get();

assertNotNull(sampleActivity);

assertEquals("Activity的標題",?sampleActivity.getTitle());

}


Robolectric.buildActivity()用于構造Activity,create()函數執行后,該Activity會運行到onCreate周期,resume()則對應onResume周期。assertNotNull和assertEquals是JUnit中的斷言,Robolectric只提供運行環境,邏輯判斷還是需要依賴JUnit中的斷言。


Activity跳轉是Android開發的重要邏輯,其測試方法如下:


@Test

public?void?testActivityTurn(ActionBarActivity firstActivity,?Class?secondActivity)?{

Intent intent?=?new?Intent(firstActivity.getApplicationContext(),?secondActivity);

assertEquals(intent,?Robolectric.shadowOf(firstActivity).getNextStartedActivity());//3.0的API與2.4不同

}


Fragment展示與切換


Fragment是Activity的一部分,在Robolectric模擬執行Activity過程中,如果觸發了被測試的代碼中的Fragment添加邏輯,Fragment會被添加到Activity中。


需要注意Fragment出現的時機,如果目標Activity中的Fragment的添加是執行在onResume階段,在Activity被Robolectric執行resume()階段前,該Activity中并不會出現該Fragment。采用Robolectric主動添加Fragment的方法如下:

@Test

public?void?addfragment(Activity activity,?int?fragmentContent){

FragmentTestUtil.startFragment(activity.getSupportFragmentManager().findFragmentById(fragmentContent));

Fragment fragment?=?activity.getSupportFragmentManager().findFragmentById(fragmentContent);

assertNotNull(fragment);

}


startFragment()函數的主體便是常用的添加fragment的代碼。切換一個Fragment往往由Activity中的代碼邏輯完成,需要Activity的引用。


控件的點擊以及可視驗證


@Test

public?void?testButtonClick(int?buttonID){

Button submitButton?=?(Button)?activity.findViewById(buttonID);

assertTrue(submitButton.isEnabled());

submitButton.performClick();

//驗證控件的行為

}


對控件的點擊驗證是調用performClick(),然后斷言驗證其行為。對于ListView這類涉及到Adapter的控件的點擊驗證,寫法如下:


//listView被展示之后

listView.performItemClick(listView.getAdapter().getView(position,?null,?null),?0,?0);


與button等控件稍有不同。

Dialog和Toast測試

測試Dialog和Toast的方法如下:


public?void?testDialog(){

Dialog dialog?=?ShadowDialog.getLatestDialog();

assertNotNull(dialog);

}

public?void?testToast(String?toastContent){

ShadowHandler.idleMainLooper();

assertEquals(toastContent,?ShadowToast.getTextOfLatestToast());

}


上述函數均需要在Dialog或Toast產生之后執行,能夠測試Dialog和Toast是否彈出。

Shadow寫法介紹

Robolectric的本質是在Java運行環境下,采用Shadow的方式對Android中的組件進行模擬測試,從而實現Android單元測試。對于一些Robolectirc暫不支持的組件,可以采用自定義Shadow的方式擴展Robolectric的功能。


@Implements(Point.class)

public?class?ShadowPoint?{

@RealObject?private?Point realPoint;

...

public?void?__constructor__(int?x,?int?y)?{

realPoint.x?=?x;

realPoint.y?=?y;

}

}//樣例來源于Robolectric官網


上述實例中,@Implements是聲明Shadow的對象,@RealObject是獲取一個Android 對象,constructor則是該Shadow的構造函數,Shadow還可以修改一些函數的功能,只需要在重載該函數的時候添加@Implementation,這種方式可以有效擴展Robolectric的功能。

Shadow是通過對真實的Android對象進行函數重載、初始化等方式對Android對象進行擴展,Shadow出來的對象的功能接近Android對象,可以看成是對Android對象一種修復。自定義的Shadow需要在config中聲明,聲明寫法是@Config(shadows=ShadowPoint.class)。


Mock寫法介紹


對于一些依賴關系復雜的測試對象,可以采用Mock框架解除依賴,常用的有Mockito。例如Mock一個List類型的對象實例,可以采用如下方式:


List list?=?mock(List.class);?//mock得到一個對象,也可以用@mock注入一個對象


所得到的list對象實例便是List類型的實例,如果不采用mock,List其實只是個接口,我們需要構造或者借助ArrayList才能進行實例化。與Shadow不同,Mock構造的是一個虛擬的對象,用于解耦真實對象所需要的依賴。Mock得到的對象僅僅是具備測試對象的類型,并不是真實的對象,也就是并沒有執行過真實對象的邏輯。


Mock也具備一些補充JUnit的驗證函數,比如設置函數的執行結果,示例如下:


When(sample.dosomething()).thenReturn(someAction);//when(一個函數執行).thenReturn(一個可替代真實函數的結果的返回值);

//上述代碼是設置sample.dosomething()的返回值,當執行了sample.dosomething()這個函數時,就會得到someAction,從而解除了對真實的sample.dosomething()函數的依賴


上述代碼為被測函數定義一個可替代真實函數的結果的返回值。當使用這個函數后,這個可驗證的結果便會產生影響,從而代替函數的真實結果,這樣便解除了對真實函數的依賴。


同時Mock框架也可以驗證函數的執行次數,代碼如下:


List list?=?mock(List.class);?//Mock得到一個對象

list.add(1);?//執行一個函數

verify(list).add(1);?//驗證這個函數的執行

verify(list,time(3)).add(1);?//驗證這個函數的執行次數


在一些需要解除網絡依賴的場景中,多使用Mock。比如對retrofit框架的網絡依賴解除如下:


public?class?MockClient?implements?Client?{

@Override

public?Response execute(Request request)?throws?IOException?{

Uri uri?=?Uri.parse(request.getUrl());

String?responseString?=?"";

if(uri.getPath().equals("/path/of/interest"))?{

responseString?=?"返回的json1";//這里是設置返回值

}?else?{

responseString?=?"返回的json2";

}

return?new?Response(request.getUrl(),?200,?"nothing",?Collections.EMPTY_LIST,?new?TypedByteArray("application/json",responseString.getBytes()));

}

}

//MockClient使用方式如下:

RestAdapter.Builder builder?=?new?RestAdapter.Builder();

builder.setClient(new?MockClient());


這種方式下retrofit的response可以由單元測試編寫者設置,而不來源于網絡,從而解除了對網絡環境的依賴。

在實際項目中使用Robolectric構建單元測試


單元測試的范圍

在Android項目中,單元測試的對象是組件狀態、控件行為、界面元素和自定義函數。本文并不推薦對每個函數進行一對一的測試,像onStart()、onDestroy()這些周期函數并不需要全部覆蓋到。商業項目多采用Scrum模式,要求快速迭代,有時候未必有較多的時間寫單元測試,不再要求逐個函數寫單元測試。

本文單元測試的case多來源于一個簡短的業務邏輯,單元測試case需要對這段業務邏輯進行驗證。在驗證的過程中,開發人員可以深度了解業務流程,同時新人來了看一下項目單元測試就知道哪個邏輯跑了多少函數,需要注意哪些邊界——是的,單元測試需要像文檔一樣具備業務指導能力。

在大型項目中,遇到需要改動基類中代碼的需求時,往往不能準確快速地知道改動后的影響范圍,緊急時多采用創建子類覆蓋父類函數的辦法,但這不是長久之計,在足夠覆蓋率的單元測試支持下,跑一下單元測試就知道某個函數改動后的影響,可以放心地修改基類。


美團的Android單元測試編寫流程如圖4所示。


圖4 美團Android單元測試編寫流程


單元測試最終需要輸出文檔式的單元測試代碼,為線上代碼提供良好的代碼穩定性保證。

單元測試的流程

實際項目中,單元測試對象與頁面是一對一的,并不建議跨頁面,這樣的單元測試藕合度太大,維護困難。單元測試需要找到頁面的入口,分析項目頁面中的元素、業務邏輯,這里的邏輯不僅僅包括界面元素的展示以及控件組件的行為,還包括代碼的處理邏輯。然后可以創建單元測試case列表(列表用于紀錄項目中單元測試的范圍,便于單元測試的管理以及新人了解業務流程),列表中記錄單元測試對象的頁面,對象中的case邏輯以及名稱等。工程師可以根據這個列表開始寫單元測試代碼。

單元測試是工程師代碼級別的質量保證工程,上述流程并不能完全覆蓋重要的業務邏輯以及邊界條件,因此,需要寫完后,看覆蓋率,找出單元測試中沒有覆蓋到的函數分支條件等,然后繼續補充單元測試case列表,并在單元測試工程代碼中補上case。


直到規劃的頁面中所有邏輯的重要分支、邊界條件都被覆蓋,該項目的單元測試結束。單元測試流程如圖5所示。


圖5 單元測試執行流程



上述分析頁面入口所得到結果便是@Before標記的函數中的代碼,之后的循環便是所有的case(@Test標記的函數)。


單元測試項目實踐

為了系統的介紹單元測試的實施過程,本文創建了一個小型的demo項目作為測試對象。demo的功能是供用戶發布所見的新聞到服務端,并瀏覽所有已經發表的新聞,是個典型的自媒體應用。該demo的開發和測試涉及到TextView、EditView、ListView、Button以及自定義View,包含了網絡請求、多線程、異步任務以及界面跳轉等。能夠為多數商業項目提供參照樣例。項目頁面如圖6所示。


圖6 單元測試case設計


首先需要分析App的每個頁面,針對頁面提取出簡短的業務邏輯,提取出的業務邏輯如圖6綠色圈圖所示。根據這些邏輯來設計單元測試的case(帶有@Test注解的那個函數),這里的業務邏輯不僅指需求中的業務,還包括其他需要維護的代碼邏輯。業務流程不允許跨頁面,以免增加單元測試case的維護成本。針對demo中界面的單元測試case設計如下:


表1 單元測試case列表


接下來需要在單元測試工程中實現上述case,最小斷言數是業務邏輯上的判斷,并不是代碼的邊界條件,真實的case需要考慮代碼的邊界條件,比如數組為空等條件,因此,最終的斷言數量會大于等于最小斷言數。在需求業務上,最小斷言數也是該需求的業務條件。

寫完case后需要跑一遍單元測試并檢查覆蓋率報告,當覆蓋率報告中缺少有些單元測試case列表中沒有但是實際邏輯中會有的邏輯時,需要更新單元測試case列表,添加遺漏的邏輯,并將對應的代碼補上。直到所有需要維護的邏輯都被覆蓋,該項目中的單元測試才算完成。單元測試并不是QA的黑盒測試,需要保證對代碼邏輯的覆蓋。

對表1分析,第一個頁面的“發布新聞”的case可以直接調用“編寫新聞”的case,以滿足條件“2.編寫了新聞的前提下,點擊發布按鈕”,在JUnit框架下,case(帶@Test注解的那個函數)也是個函數,直接調用這個函數就不是case,和case是無關的,兩者并不會相互影響,可以直接調用以減少重復代碼。第二個頁面不同于第一個,一進入就需要網絡請求,后續業務都需要依賴這個網絡請求,單元測試不應該對某一個條件過度耦合,因此,需要用mock解除耦合,直接mock出網絡請求得到的數據,單獨驗證頁面對數據的響應。

總結

單元測試并不是一個能直接產生回報的工程,它的運行以及覆蓋率也不能直接提升代碼質量,但其帶來的代碼控制力能夠大幅度降低大規模協同開發的風險。現在的商業App開發都是大型團隊協作開發,不斷會有新人加入,無論新人是剛入行的應屆生還是工作多年,在代碼存在一定業務耦合度的時候,修改代碼就有一定風險,可能會影響之前比較隱蔽的業務邏輯,或者是丟失曾經的補丁,如果有高覆蓋率的單元測試工程,就能很快定位到新增代碼對現有項目的影響,與QA驗收不同,這種影響是代碼級的。

在本文所設計的單元測試流程中,單元測試的case和具體頁面的具體業務流程以及該業務的代碼邏輯緊密聯系,單元測試如同技術文檔一般,能夠體現出一個業務邏輯運行了多少函數,需要注意什么樣的條件。這是一種新人了解業務流程、對業務進行代碼級別融入的好辦法,看一下以前的單元測試case,就能知道與該case對應的那個頁面上的那個業務邏輯會執行多少函數,以及這些函數可能出現的結果。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 173,269評論 25 708
  • 一.基本介紹 背景: 目前處于高速迭代開發中的Android項目往往需要除黑盒測試外更加可靠的質量保障,這正是單元...
    anmi7閱讀 2,057評論 0 6
  • 前言 在之前的系列博客中,主要圍繞的是測試工具的介紹與使用。經過幾個月的沉寂,在項目中摸索與實踐單元測試,曾經踩坑...
    水木飛雪閱讀 2,879評論 0 8
  • As you wonder through the streets,you see thousands of ma...
    Lynnnnn閱讀 447評論 0 3
  • 我很懷疑自己是否人格分裂,總感覺自己的思想會時間沖洗,沖洗過后的思想就變成了另一個模樣,之前一直都在很努力的求知,...
    善行無痕閱讀 255評論 0 1