一文全面瞭解Android單元測試

jsonchao發表於2020-01-08

轉載請作明出處:juejin.im/post/5b4381…

前言

成為一名優秀的Android開發,需要一份完備的知識體系,在這裡,讓我們一起成長為自己所想的那樣~。

==》完整專案單元測試學習案例

眾所周知,一個好的專案需要不斷地打造,而一些有效的測試則是加速這一過程的利器。本篇博文將帶你瞭解並逐步深入Android單元測試。

什麼是單元測試?


單元測試就是針對類中的某一個方法進行驗證是否正確的過程,單元就是指獨立的粒子,在Android和Java中大都是指方法。

為什麼要進行單元測試?


使用單元測試可以提高開發效率,當專案隨著迭代越來越大時,每一次編譯、執行、打包、除錯需要耗費的時間會隨之上升,因此,使用單元測試可以不需這一步驟就可以對單個方法進行功能或邏輯測試。 同時,為了能測試每一個細分功能模組,需要將其相關程式碼抽成相應的方法封裝起來,這也在一定程度上改善了程式碼的設計。因為是單個方法的測試,所以能更快地定位到bug。

單元測試case需要對這段業務邏輯進行驗證。在驗證的過程中,開發人員可以深度瞭解業務流程,同時新人來了看一下專案單元測試就知道哪個邏輯跑了多少函式,需要注意哪些邊界——是的,單元測試做的好和文件一樣具備業務指導能力。

Android測試的分類


Android測試主要分為三個方面:

單元測試(Junit4、Mockito、PowerMockito、Robolectric)
UI測試(Espresso、UI Automator)
壓力測試(Monkey)
複製程式碼

一、單元測試之基礎Junit4


什麼是Junit4?


Junit4是事實上的Java標準測試庫,並且它是JUnit框架有史以來的最大改進,其主要目標便是利用Java5的Annotation特性簡化測試用例的編寫。

開始使用Junit4進行單元測試


1.Android Studio已經自動整合了Junit4測試框架,如下
dependencies {
    ...
    testImplementation 'junit:junit:4.12'
}
複製程式碼
2.Junit4框架使用時涉及到的重要註解如下
@Test 指明這是一個測試方法 (@Test註解可以接受2個引數,一個是預期錯誤expected,一個是超時時間timeout,
格式如 @Test(expected = IndexOutOfBoundsException.class), @Test(timeout = 1000)
@Before 在所有測試方法之前執行
@After 在所有測試方法之後執行
@BeforeClass 在該類的所有測試方法和@Before方法之前執行 (修飾的方法必須是靜態的)
@AfterClass 在該類的所有測試方法和@After方法之後執行(修飾的方法必須是靜態的)
@Ignore 忽略此單元測試
複製程式碼

此外,很多時候,因為某些原因(比如正式程式碼還沒有實現等),我們可能想讓JUnit忽略某些方法,讓它在跑所有測試方法的時候不要跑這個測試方法。要達到這個目的也很簡單,只需要在要被忽略的測試方法前面加上@Ignore就可以了

3.主要的測試方法——斷言
assertEquals(expected, actual) 判斷2個值是否相等,相等則測試通過。
assertEquals(expected, actual, tolerance) tolerance 偏差值
複製程式碼

注意:上面的每一個方法,都有一個過載的方法,可以加一個String型別的引數,表示如果驗證失敗的話,將用這個字串作為失敗的結果報告

4.自定義Junit Rule——實現TestRule介面並重寫apply方法
public class JsonChaoRule implements TestRule {

    @Override
    public Statement apply(final Statement base, final Description description) {
        Statement repeatStatement =  new Statement() {
            @Override
            public void evaluate() throws Throwable {
                    //測試前的初始化工作
                    //執行測試方法
                    base.evaluate();
                    //測試後的釋放資源等工作
            }
        };
        return repeatStatement;
    }
}
複製程式碼

然後在想要的測試類中使用@Rule註解宣告使用JsonChaoRule即可(注意被@Rule註解的變數必須是final的):

@Rule
public final JsonChaoRule repeatRule = new JsonChaoRule();
複製程式碼
5.開始上手,使用Junit4進行單元測試
1.編寫測試類。
2.滑鼠右鍵點選測試類,選擇選擇Go To->Test
(或者使用快捷鍵Ctrl+Shift+T,此快捷鍵可
以在方法和測試方法之間來回切換)在Test/java/專案
測試資料夾/下自動生成測試模板。
3.使用斷言(assertEqual、assertEqualArrayEquals等等)進行單元測試。
4.右鍵點選測試類,Run編寫好的測試類。
複製程式碼
6.使用Android Studio自帶的Gradle指令碼自動化單元測試

點選Android Studio中的Gradle projects下的app/Tasks/verification/test即可同時測試module下所有的測試類(案例),並在module下的build/reports/tests/下生成對應的index.html測試報告

7.對Junit4的總結:
優點:速度快,支援程式碼覆蓋率等程式碼質量的檢測工具,
缺點:無法單獨對Android UI,一些類進行操作,與原生JAVA有一些差異。
複製程式碼

可能涉及到的額外的概念:

打樁方法:使方法簡單快速地返回一個有效的結果。

測試驅動開發:編寫測試,實現功能使測試通過,然後不斷地使用這種方式實現功能的快速迭代開發。

二、單元測試之基礎Mockito


什麼是Mockito?

Mockito 是美味的 Java 單元測試 Mock 框架,mock可以模擬各種各樣的物件,從而代替真正的物件做出希望的響應。

開始使用Mockito進行單元測試

1.在build.gradle裡面新增Mcokito的依賴
testImplementation 'org.mockito:mockito-core:2.7.1'
複製程式碼
2.使用mock()方法模擬物件
Person mPerson = mock(Person.class); 
複製程式碼
能量補充站(-vov-)

在JUnit框架下,case(帶@Test註解的那個函式)也是個函式,直接呼叫這個函式就不是case,和case是無關的,兩者並不會相互影響,可以直接呼叫以減少重複程式碼。單元測試不應該對某一個條件過度耦合,因此,需要用mock解除耦合,直接mock出網路請求得到的資料,單獨驗證頁面對資料的響應。

3.驗證方法的呼叫,指定方法的返回值,或者執行特定的動作
when(iMathUtils.sum(1, 1)).thenReturn(2); 
doReturn(3).when(iMathUtils).sum(1,1);   
//給方法設定樁可以設定多次,只會返回最後一次設定的值
doReturn(2).when(iMathUtils).sum(1,1);

//驗證方法呼叫次數
//方法呼叫1次
Mockito.when(mockValidator.verifyPassword("xiaochuang_is_handsome")).thenReturn(true);
//方法呼叫3次
Mockito.when(mockValidator.verifyPassword("xiaochuang_is_handsome")
, Mockito.times(3).thenReturn(true);

//verify方法用於驗證“模仿物件”的互動或驗證發生的某些行為
verify(mPerson, atLeast(2)).getAge();

//引數匹配器,用於匹配特定的引數
any()
contains()
argThat()
when(mPerson.eat(any(String.class))).thenReturn("米飯");

//除了mock()外,spy()也可以模擬物件,spy與mock的
//唯一區別就是預設行為不一樣:spy物件的方法預設呼叫
//真實的邏輯,mock物件的方法預設什麼都不做,或直接
//返回預設值
//如果要保留原來物件的功能,而僅僅修改一個或幾個
//方法的返回值,可以採用spy方法,無參構造的類初始
//化也使用spy方法
Person mPerson = spy(Person.class); 

//檢查入參的mocks是否有任何未經驗證的互動
verifyNoMoreInteractions(iMathUtils);
複製程式碼
4.使用Mockito後的思考

簡單的測試會使整體的程式碼更簡單,更可讀、更可維護。如果你不能把測試寫的很簡單,那麼請在測試時重構你的程式碼

優點:豐富強大的方式驗證“模仿物件”的互動或驗證發生的某些行為
缺點:Mockito框架不支援mock匿名類、final類、static方法、private方法。
複製程式碼

雖然,static方法可以使用wrapper靜態類的方式實現mockito的單元測試,但是,畢竟過於繁瑣,因此,PowerMockito由此而來。

三、拯救Mockito於水深火熱的PowerMockito


什麼是PowerMockito?


PowerMockito是一個擴充套件了Mockito的具有更強大功能的單元測試框架,它支援mock匿名類、final類、static方法、private方法

開始PowerMockito之旅


1.在build.gradle裡面新增Mcokito的依賴
testImplementation 'org.powermock:powermock-module-junit4:1.6.5'
testImplementation 'org.powermock:powermock-api-mockito:1.6.5'
複製程式碼
2.用PowerMockito來模擬物件
//使用PowerMock須加註解@PrepareForTest和@RunWith(PowerMockRunner.class)(@PrepareForTest()裡寫的是對應方法所在的類	,mockito支援的方法使用PowerMock的形式實現時,可以不加這兩個註解)
@PrepareForTest(T.class)
@RunWith(PowerMockRunner.class)

//mock含靜態方法或欄位的類	
PowerMockito.mockStatic(Banana.class);

//Powermock提供了一個Whitebox的class,可以方便的繞開許可權限制,可以get/set private屬性,實現注入。也可以呼叫private方法。也可以處理static的//屬性/方法,根據不同需求選擇不同引數的方法即可。
//修改類裡面靜態欄位的值
Whitebox.setInternalState(Banana.class, "COLOR", "藍色");

//呼叫類中的真實方法
PowerMockito.when(banana.getBananaInfo()).thenCallRealMethod();

//驗證私有方法是否被呼叫
PowerMockito.verifyPrivate(banana, times(1)).invoke("flavor");

//忽略呼叫私有方法
PowerMockito.suppress(PowerMockito.method(Banana.class, "flavor"));

//修改私有變數
MemberModifier.field(Banana.class, "fruit").set(banana, "西瓜");

//使用PowerMockito mock出來的物件可以直接呼叫final方法
Banana banana = PowerMockito.mock(Banana.class);

//whenNew 方法的意思是之後 new 這個物件時,返回某個被 Mock //的物件而不是讓真的 new //新的物件。如果構造方法有引數,可以在withNoArguments方法中傳入。
PowerMockito.whenNew(Banana.class).withNoArguments().thenReturn(banana);
複製程式碼
3.使用PowerMockRule來代替@RunWith(PowerMockRunner.class)的方式,需要多新增以下依賴:
testImplementation "org.powermock:powermock-module-junit4-rule:1.7.4"
testImplementation "org.powermock:powermock-classloading-xstream:1.7.4"
複製程式碼

使用示例如下:

@Rule
public PowerMockRule mPowerMockRule = new PowerMockRule();
複製程式碼
4.使用Parameterized來進行引數化測試:

通過註解@Parameterized.parameters提供一系列資料給構造器中的構造引數或給被註解@Parameterized.parameter註解的public全域性變數

RunWith(Parameterized.class)
public class ParameterizedTest {

    private int num;
    private boolean truth;

    public ParameterizedTest(int num, boolean truth) {
        this.num = num;
        this.truth = truth;
    }

    //被此註解註解的方法將把返回的列表資料中的元素對應注入到測試類
    //的建構函式ParameterizedTest(int num, boolean truth)中
    @Parameterized.Parameters
    public static Collection providerTruth() {
        return Arrays.asList(new Object[][]{
                {0, true},
                {1, false},
                {2, true},
                {3, false},
                {4, true},
                {5, false}
        });
    }

//    //也可不使用建構函式注入的方式,使用註解注入public變數的方式
//    @Parameterized.Parameter
//    public int num;
//    //value = 1指定括號裡的第二個Boolean值
//    @Parameterized.Parameter(value = 1)
//    public boolean truth;

    @Test
    public void printTest() {
        Assert.assertEquals(truth, print(num));
        System.out.println(num);
    }

    private boolean print(int num) {
        return num % 2 == 0;
    }

}
複製程式碼

四、能在Java單元測試裡面執行Android程式碼的Robolectric


什麼是Robolectric?


Robolectric通過一套能執行在JVM上的Android程式碼,解決了在Java單元測試中很難進行Android單元測試的痛點。

進入Roboletric的領地


1.在build.gradle裡面新增Robolectric的依賴
    //Robolectric核心
    testImplementation "org.robolectric:robolectric:3.8"
    //支援support-v4
    testImplementation 'org.robolectric:shadows-support-v4:3.4-rc2'
    //支援Multidex功能
    testImplementation "org.robolectric:shadows-multidex:3.+" 
複製程式碼
2.Robolectric常用用法

首先給指定的測試類上面進行配置

@RunWith(RobolectricTestRunner.class)
//目前Robolectric最高支援sdk版本為23。
@Config(constants = BuildConfig.class, sdk = 23)
複製程式碼

下面是一些常用用法

//當Robolectric.setupActivity()方法返回的時候,
//預設會呼叫Activity的onCreate()、onStart()、onResume()
mTestActivity = Robolectric.setupActivity(TestActivity.class);

//獲取TestActivity對應的影子類,從而能獲取其相應的動作或行為
ShadowActivity shadowActivity = Shadows.shadowOf(mTestActivity);
Intent intent = shadowActivity.getNextStartedActivity();

//使用ShadowToast類獲取展示toast時相應的動作或行為
Toast latestToast = ShadowToast.getLatestToast();
Assert.assertNull(latestToast);
//直接通過ShadowToast簡單工廠類獲取Toast中的文字
Assert.assertEquals("hahaha", ShadowToast.getTextOfLatestToast());

//使用ShadowAlertDialog類獲取展示AlertDialog時相應的
//動作或行為(暫時只支援app包下的,不支援v7。。。)
latestAlertDialog = ShadowAlertDialog.getLatestAlertDialog();
AlertDialog latestAlertDialog = ShadowAlertDialog.getLatestAlertDialog();
Assert.assertNull(latestAlertDialog);
    
//使用RuntimeEnvironment.application可以獲取到
//Application,方便我們使用。比如訪問資原始檔。
Application application = RuntimeEnvironment.application;
String appName = application.getString(R.string.app_name);
Assert.assertEquals("WanAndroid", appName);

//也可以直接通過ShadowApplication獲取application
ShadowApplication application = ShadowApplication.getInstance();
Assert.assertNotNull(application.hasReceiverForIntent(intent));
複製程式碼

自定義Shadow類

@Implements(Person.class)
public class ShadowPerson {

    @Implementation
    public String getName() {
        return "AndroidUT";
    }

}

@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class,
        sdk = 23,
        shadows = {ShadowPerson.class})

    Person person = new Person();
    //實際上呼叫的是ShadowPerson的方法,輸出JsonChao
    Log.d("test", person.getName());
     
    ShadowPerson shadowPerson = Shadow.extract(person);
    //測試通過
    Assert.assertEquals("JsonChao", shadowPerson.getName());
    
}
複製程式碼

注意: 非同步測試出現一些問題(比如改變一些編碼習慣,比如回撥函式不能寫成匿名內部類物件,需要定義一個全域性變數,並破壞其封裝性,即提供一個get方法,供UT呼叫),解決方案使用Mockito來結合進行測試,將非同步轉為同步

3.Robolectric的優缺點
優點:支援大部分Android平臺依賴類底層的引用與模擬。
缺點:非同步測試有些問題,需要結合一些框架來配合完成更多功能。
複製程式碼

五、單元測試覆蓋率報告生成之jacoco


什麼是Jacoco


Jacoco的全稱為Java Code Coverage(Java程式碼覆蓋率),可以生成java的單元測試程式碼覆蓋率報告

加入Jacoco到你的單元測試大家族


在應用Module下加入jacoco.gradle自定義指令碼,app.gradle apply from它,同步,即可看到在app的Task下生成了Report目錄,Report目錄 下生成了JacocoTestReport任務。

apply plugin: 'jacoco'

jacoco {
    toolVersion = "0.7.7.201606060606" //指定jacoco的版本
    reportsDir = file("$buildDir/JacocoReport") //指定jacoco生成報告的資料夾
}

//依賴於testDebugUnitTest任務
task jacocoTestReport(type: JacocoReport, dependsOn: 'testDebugUnitTest') {
    group = "reporting" //指定task的分組
    reports {
        xml.enabled = true //開啟xml報告
        html.enabled = true //開啟html報告
    }

    def debugTree = fileTree(dir: "${buildDir}/intermediates/classes/debug",
            includes: ["**/*Presenter.*"],
            excludes: ["*.*"])//指定類資料夾、包含類的規則及排除類的規則,
            //這裡我們生成所有Presenter類的測試報告
    def mainSrc = "${project.projectDir}/src/main/java" //指定原始碼目錄

    sourceDirectories = files([mainSrc])
    classDirectories = files([debugTree])
    executionData = files("${buildDir}/jacoco/testDebugUnitTest.exec")//指定報告資料的路徑
}
複製程式碼

在Gradle構建板塊Gradle.projects下的app/Task/verification下,其中testDebugUnitTest構建任務會生成單元測試結果報告,包含xml及html格式,分別對應test-results和reports資料夾;jacocoTestReport任務會生成單元測試覆蓋率報告,結果存放在jacoco和JacocoReport資料夾。

image

生成的JacocoReport資料夾下的index.html即對應的單元測試覆蓋率報告,用瀏覽器開啟後,可以看到覆蓋情況被不同的顏色標識出來,其中綠色表示程式碼被單元測試覆蓋到,黃色表示部分覆蓋,紅色則表示完全沒有覆蓋到

六、單元測試的流程


要驗證程式正確性,必然要給出所有可能的條件(極限程式設計),並驗證其行為或結果,才算是100%覆蓋條件。實際專案中,驗證一般條件邊界條件就OK了。

在實際專案中,單元測試物件與頁面是一對一的,並不建議跨頁面,這樣的單元測試耦合太大,維護困難。 需要寫完後,看覆蓋率,找出單元測試中沒有覆蓋到的函式分支條件等,然後繼續補充單元測試case列表,並在單元測試工程程式碼中補上case。 直到規劃的頁面中所有邏輯的重要分支、邊界條件都被覆蓋,該專案的單元測試結束。

建議(-ovo-)~

可以從公司專案小規模使用,形成自己的單元測試風格後,就可以跟大範圍地推廣了。

參考連結:

1、必知必會 | Android 測試相關的方方面面都在這兒

2、在Android Studio中進行單元測試和UI測試

3、Android單元測試(一)

4、Android單元測試(二)

很感謝您閱讀這篇文章,希望您能將它分享給您的朋友或技術群,這對我意義重大。

希望我們能成為朋友,在 Github掘金上一起分享知識。

相關文章