Android單元測試

一.基本介紹

背景:

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

正是由于測試在開發中的重要地位,才會在IT界刮起了 TDD 的旋風。TDD,也就是測試驅動開發模式。它旨在強調在開發功能代碼之前,先編寫測試代碼。也就是說在明確要開發某個功能后,首先思考如何對這個功能進行測試,并完成測試代碼的編寫,然后編寫相關的代碼滿足這些測試用例。然后循環進行添加其他功能,直到完成全部功能的開發。

二.Java 測試工具(框架)

1.JUnit(推薦使用JUnit4)
JUnit 在日常開發中還是很常用的,而且 Java 的各種 IDE (Eclipse、MyEclipse、IntelliJ IDEA)都集成了 JUnit 的組件。當然,自己添加插件也是很方便的。JUnit 框架是 Java 語言單元測試當前的一站式解決方案。這個框架值得稱贊,因為它把測試驅動的開發思想介紹給 Java 開發人員并教給他們如何有效地編寫單元測試。

2.TestNG
TestNG,即Testing Next Generation,下一代測試技術。是根據JUnit和NUnit思想,采用 jdk 的 annotation 技術來強化測試功能并借助XML 文件強化測試組織結構而構建的測試框架。TestNG 的強大之處還在于不僅可以用來做單元測試,還可以用來做集成測試。

重點介紹下JUnit4

JUnit是Java單元測試框架,已經在Eclipse中默認安裝。目前主流的有JUnit3和JUnit4。JUnit3中,測試用例需要繼承TestCase類。JUnit4中,測試用例無需繼承TestCase類,只需要使用@Test等注解,建議使用JUnit4。

JUnit4通過注解的方式來識別測試方法。目前支持的主要注解有:

  • @BeforeClass 全局只會執行一次,而且是第一個運行
  • @Before 在測試方法運行之前運行
  • @Test 測試方法
  • @After 在測試方法運行之后允許
  • @AfterClass 全局只會執行一次,而且是最后一個運行
  • @Ignore 忽略此方法

@Before 該方法在每次測試方法調用前都會調用 @Test 說明了該方法需要測試 @BeforeClass 該方法在所有測試方法之前調用,只會被調用一次 @After 該方法在每次測試方法調用后都會調用 @AfterClass 該方法在所有測試方法之后調用,只會被調用一次 @Ignore 忽略該方法

三.單元測試范圍

一般來說,單元測試任務包括

  1. 接口功能測試:用來保證接口功能的正確性。
  2. 局部數據結構測試(不常用):用來保證接口中的數據結構是正確的。 比如(1).變量有無初始值,(2).變量是否溢出.
  3. 邊界條件測試
    (1).變量沒有賦值(即為NULL)
    (2).變量是數值(或字符)
    -主要邊界:最小值,最大值,無窮大(對于DOUBLE等)
    -溢出邊界(期望異常或拒絕服務):最小值-1,最大值+1
    -臨近邊界:最小值+1,最大值-1
    (3). 變量是字符串
    -引用“字符變量”的邊界
    -空字符串
    -對字符串長度應用“數值變量”的邊界
    (4).變量是集合
    -空集合
    -對集合的大小應用“數值變量”的邊界
    -調整次序:升序、降序
    (5). 變量有規律
    -比如對于Math.sqrt,給出n2-1,和n2+1的邊界
    (6). 所有獨立執行通路測試:保證每一條代碼,每個分支都經過測試
    -代碼覆蓋率
    1>.語句覆蓋:保證每一個語句都執行到了
    2>.判定覆蓋(分支覆蓋):保證每一個分支都執行到
    3>.條件覆蓋:保證每一個條件都覆蓋到true和false(即if、while中的條件語句)
    4>.路徑覆蓋:保證每一個路徑都覆蓋到

-相關軟件 (Cobertura:語句覆蓋)

  1. 各條錯誤處理通路測試:保證每一個異常都經過測試

如下是一個JUnit4的示例:

/**
 * Created by huanming on 17/3/13.
 */
public class Junit4TestCase {

    @BeforeClass
    public static void setUpBeforeClass() {
        System.out.println("Set up before class");
    }

    @Before
    public void setUp() throws Exception {
        System.out.println("Set up");
    }

    @Test
    public void testMathPow() {
        System.out.println("Test Math.pow");
        Assert.assertEquals(4.0, Math.pow(2.0, 2.0), 0.0);
    }

    @Test
    public void testMathMin() {
        System.out.println("Test Math.min");
        Assert.assertEquals(2.0, Math.min(2.0, 4.0), 0.0);
    }

    // 期望此方法拋出NullPointerException異常
    @Test(expected = NullPointerException.class)
    public void testException() {
        System.out.println("Test exception");
        Object obj = null;
        obj.toString();
    }

    // 忽略此測試方法
    @Ignore
    @Test
    public void testMathMax() {
        Assert.fail("沒有實現");
    }
    // 使用“假設”來忽略測試方法
    @Test
    public void testAssume(){
        System.out.println("Test assume");
        // 當假設失敗時,則會停止運行,但這并不會意味測試方法失敗。
        Assume.assumeTrue(false);
        Assert.fail("沒有實現");
    }

    @After
    public void tearDown() throws Exception {
        System.out.println("Tear down");
    }

    @AfterClass
    public static void tearDownAfterClass() {
        System.out.println("Tear down After class");
    }

}

運行結果:


屏幕快照 2017-03-17 下午2.26.04.png

四. 單元測試框架>Robolectric

參考文章:
http://robolectric.org
https://github.com/robolectric/robolectric
https://en.wikipedia.org/wiki/Unit_testing
https://github.com/square/okhttp/tree/master/mockwebserver

  1. 介紹
    (1). Robolectric 是一個開源的framework,他們的做法是通過實現一套JVM能運行的Android代碼,然后在unit test運行的時候去截取android相關的代碼調用,然后轉到他們的他們實現的代碼去執行這個調用的過程。
    舉個例子說明一下,比如android里面有個類叫TextView,他們實現了一個類叫ShadowTextView。這個類基本上實現了TextView的所有公共接口,假設你在unit test里面寫到
    String text = textView.getText().toString();。在這個unit test運行的時候,Robolectric會自動判斷你調用了Android相關的代碼textView.getText(),然后這個調用過程在底層截取了,轉到ShadowTextViewgetText實現。而ShadowTextView是真正實現了getText這個方法的,所以這個過程便可以正常執行。
    (2). 除了實現Android里面的類的現有接口,Robolectric還做了另外一件事情,極大地方便了unit testing的工作。那就是他們給每個Shadow類額外增加了很多接口,可以讀取對應的Android類的一些狀態。比如我們知道ImageView有一個方法叫setImageResource(resourceId),然而并沒有一個對應的getter方法叫getImageResourceId(),這樣你是沒有辦法測試這個ImageView是不是顯示了你想要的image。而在Robolectric實現的對應的ShadowImageView里面,則提供了getImageResourceId()這個接口。你可以用來測試它是不是正確的顯示了你想要的Image.

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

testCompile 'junit:junit:4.12'
    testCompile('org.robolectric:robolectric:3.0') {
        exclude module: 'commons-logging'
    }

Gradle對Robolectric 2.4的支持并不像3.0這樣好,但Robolectric 2.4所有的測試框架均在一個包里,如果使用Robolectric 2.4,則需要如下配置:

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

需要注意:Android Studio小于2.0的版本,要支持單元測試需要設置“Build Variants”,路徑是“View -->Tool Windows-->Build Variants”,然后設置為“Unit Tests”;當版本為2.0時,默認就支持。

屏幕快照 2017-03-19 下午1.47.32.png
                           圖2 單元測試工程位置

如圖1所示的綠色文件夾即是單元測試工程。這些代碼能夠檢測目標代碼的正確性,打包時單元測試的代碼不會被編譯進入APK中。

Robolectric最麻煩就是下載依賴! 由于我們生活在天朝,下載國外的依賴很慢,即使有了翻墻,效果也一般。

注意:第一次運行可能需要下載一些library,依賴庫,可能需要花一點時間,這個跟unit test本身沒關。

第二種方法:maven地址指向 阿里云的地址。
build.gradle

allprojects {
        repositories {
            //依賴庫,阿里云地址
            maven { url 'http://maven.aliyun.com/nexus/content/groups/public/' }
            jcenter()
        }
    }

具體原理參考: http://www.lxweimin.com/p/a01628c3ea16

五.Robolectric使用介紹

Mock

參考文章:
http://www.open-open.com/lib/view/open1470724287040.html

配置:

testCompile 'org.mockito:mockito-core:1.9.5'

說白了就是打樁(Stub)或則模擬,當你調用一個不好在測試中創建的對象時,Mock框架為你模擬一個和真實對象類似的替身來完成相應的行為。
mock對象就是在調試期間用來作為真實對象的替代品。Mockito是Java中常見的Mock框架。

Robolectric在文檔中聲稱:“No Mocking Frameworks Required”:對于Robolectric的另一種可選方法是使用mock框架,比如Mockito;或者模擬出Android SDK。雖然這是個有效的方法,但基本上是應用代碼的反向實現。

Mockito雖然不能模擬final類、匿名類和Java基本類型;對于final方法和static方法,不能對其 when(…).thenReturn(…) 操作。另外mock對象,大多都需要植入到應用代碼中,從而進行verify(...)操作;但應用代碼中不一定有相應的set方法,如果要植入,就需要為了測試添加應用代碼。

但是, Mockito + Powermock可以解決上述的問題。

示例:

@Implements(HttpClient.class)
public class ShadowHttpClient {

    protected static boolean isHandleError = false;
    protected static boolean isRaiseException = false;
    public static String lastRequestPath;
    public static String lastRequestData;
    public static List<String> allExecutedAction = new ArrayList<String>();
    public static List<String> allRequestData = new ArrayList<String>();
    private static ResponseObjectConvert converter;
    private static List<HttpResponseResult> responseResultList;
    private static int position = 0;

    @RealObject
    HttpClient httpClient;

    public void __constructor__(String host, int port, boolean isEncryptionEnabled) {

    }

    @Implementation
    public HttpResponseResult sendRequestGetResponse(String path, String request) {
        lastRequestPath = path;
        lastRequestData = request;
        allExecutedAction.add(path);
        allRequestData.add(request);
        if (isRaiseException) {
            throw new RuntimeException();
        }

        if (converter != null) {
            if (isHandleError) {
                setResponseResultList(asList(new HttpResponseResult(FAILED, converter.convertResponse(), null)));
            } else {
                setResponseResultList(asList(new HttpResponseResult(SUCCEEDED, converter.convertResponse(), null)));
            }
        }

        return responseResultList.get(position++);
    }

    @Implementation
    public HttpResponseResult getResponse(String path) {
        return sendRequestGetResponse(path,"");
    }

    public static void reset() {
        lastRequestPath = null;
        lastRequestData = null;
        allExecutedAction.clear();
        allRequestData.clear();

        ShadowHttpClient.converter = null;
        ShadowHttpClient.responseResultList = null;
        ShadowHttpClient.isHandleError = false;
        ShadowHttpClient.isRaiseException = false;
    }

    public static void setRaiseException(boolean isRaiseException) {
        ShadowHttpClient.isRaiseException = isRaiseException;
    }

    public static void setConverter(ResponseObjectConvert converter) {
        ShadowHttpClient.converter = converter;
    }

    public static void setHandleError(boolean handleError) {
        ShadowHttpClient.isHandleError = handleError;
    }

    public static void setResponseResultList(List<HttpResponseResult> responseResultList) {
        position = 0;
        ShadowHttpClient.responseResultList = responseResultList;
    }

    public interface ResponseObjectConvert {
        public String convertResponse();
    }

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可以由單元測試編寫者設置,而不來源于網絡,從而解除了對網絡環境的依賴。

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

Robolectric定義了大量的Shadow類,修改或者擴展了Android OS類的行為。當一個Android OS類被實例化,Robolectric會搜索相應的Shadow類;如果找到了,將創建與之關聯的Shadow對象。Android OS方法每次被調用時,Robolectirc確保:如果存在,Shadow類中的相應方法先被調用,這樣就有機會做測試相關邏輯。這種策略可運用于所有的方法,包括static和final方法。

@Implements(Point.class)
public class ShadowPoint {
  @RealObject private Point realPoint;
  ...
  public void __constructor__(int x, int y) {
    realPoint.x = x;
    realPoint.y = y;
  }
}

上述實例中,@Implements是聲明Shadow的對象,@RealObject是獲取一個Android 對象,constructor則是該Shadow的構造函數,Shadow還可以修改一些函數的功能,只需要在重載該函數的時候添加@Implementation,這種方式可以有效擴展Robolectric的功能。
Shadow是通過對真實的Android對象進行函數重載、初始化等方式對Android對象進行擴展,Shadow出來的對象的功能接近Android對象,可以看成是對Android對象一種修復。自定義的Shadow需要在config中聲明,聲明寫法是@Config(shadows=ShadowPoint.class)。

常見Robolectric用法

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

  1. 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 testMainActivity() {
        MainActivity mainActivity = Robolectric.setupActivity(MainActivity.class);
        mainActivity.findViewById(R.id.textView1).performClick();

        Intent expectedIntent = new Intent(mainActivity, SecondActivity.class);
        ShadowActivity shadowActivity = Shadows.shadowOf(mainActivity);
        Intent actualIntent = shadowActivity.getNextStartedActivity();
        Assert.assertEquals(expectedIntent, actualIntent);
    }
  1. 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是否彈出。

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等控件稍有不同。

六.Robolectric單元測試編寫結構

如下實例:


未完待續......

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

推薦閱讀更多精彩內容

  • Android單元測試介紹 處于高速迭代開發中的Android項目往往需要除黑盒測試外更加可靠的質量保障,這正是單...
    東經315度閱讀 3,148評論 6 37
  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 173,268評論 25 708
  • 為什么要做單元測試 學習過或者了解軟件工程的人一定對這個東西不陌生,很多人也知道這個東西很重要,但是總是以各種借口...
    DanieX閱讀 631評論 0 3
  • 今天有客戶問我:有沒有聽說過九型人格? 事實是聽過,但沒有太了解。大致就是將人的性格分成九種,然后讓人對號入座等等...
    萍空間閱讀 204評論 0 0
  • 三月二十五日,春分后第五天,氣溫回升、陰雨轉晴。這日天緣茶室,有位新客到訪——覓仙泉。 覓仙泉是源自大別山深層巖石...
    小金瓜閱讀 896評論 0 3