深入解剖安卓單元測試,逐一攻破難點

 Android單元測試介紹

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

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

圖1 單元測試工程位置

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

 Java單元測試

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

圖2 單元測試示例

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

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

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

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

既沒有返回值,也沒有改變狀態,又沒有觸發行為的函數是不可測試的,在項目中不應該存在。當存在同時具備上述多種特性時,本文建議採用多個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)。基於Robolectric的代碼結構如下:

//省略一堆import

@RunWith(RobolectricTestRunner.class)

public class MainActivityTest {

@Before

public void setUp() {

//執行初始化的操作

}

<a href="jobbole.com/members/mad">@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了。測試代碼如下:

<a href="jobbole.com/members/mad">@Test</a>

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開發的重要邏輯,其測試方法如下:

<a href="jobbole.com/members/mad">@Test</a>

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的方法如下:

<a href="jobbole.com/members/mad">@Test</a>

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的引用。

 控制項的點擊以及可視驗證

<a href="jobbole.com/members/mad">@Test</a>

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框架的網路依賴解除如下:

//代碼參考了參考文獻[3]

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對應的那個頁面上的那個業務邏輯會執行多少函數,以及這些函數可能出現的結果。


推薦閱讀:

TAG:軟體測試 | 軟體測試管理 | 單元測試 |