Android 單元測試只看這一篇就夠了

本文由玉剛說寫做平臺提供寫做贊助java

原做者:Jdqmandroid

版權聲明:本文版權歸微信公衆號玉剛說全部,未經許可,不得以任何形式轉載git

單元測試是應用程序測試策略中的基本測試,經過對代碼進行單元測試,能夠輕鬆地驗證單個單元的邏輯是否正確,在每次構建以後運行單元測試,能夠幫助您快速捕獲和修復因代碼更改(重構、優化等)帶來的迴歸問題。本文主要聊聊Android中的單元測試。github

單元測試的目的以及測試內容

爲何要進行單元測試?算法

  • 提升穩定性,可以明確地瞭解是否正確的完成開發;
  • 快速反饋bug,跑一遍單元測試用例,定位bug;
  • 在開發週期中儘早經過單元測試檢查bug,最小化技術債,越日後可能修復bug的代價會越大,嚴重的狀況下會影響項目進度;
  • 爲代碼重構提供安全保障,在優化代碼時不用擔憂迴歸問題,在重構後跑一遍測試用例,沒經過說明重構多是有問題的,更加易於維護。

單元測試要測什麼?shell

  • 列出想要測試覆蓋的正常、異常狀況,進行測試驗證;
  • 性能測試,例如某個算法的耗時等等。

單元測試的分類數據庫

  1. 本地測試(Local tests): 只在本地機器JVM上運行,以最小化執行時間,這種單元測試不依賴於Android框架,或者即便有依賴,也很方便使用模擬框架來模擬依賴,以達到隔離Android依賴的目的,模擬框架如google推薦的[Mockito][1];api

  2. 儀器化測試(Instrumented tests): 在真機或模擬器上運行的單元測試,因爲須要跑到設備上,比較慢,這些測試能夠訪問儀器(Android系統)信息,好比被測應用程序的上下文,通常地,依賴不太方便經過模擬框架模擬時採用這種方式。安全

JUnit 註解bash

瞭解一些JUnit註解,有助於更好理解後續的內容。

Annotation 描述
@Test public void method() 定義所在方法爲單元測試方法
@Test (expected = Exception.class) public void method() 測試方法若沒有拋出Annotation中的Exception類型(子類也能夠)->失敗
@Test(timeout=100) public void method() 性能測試,若是方法耗時超過100毫秒->失敗
@Before public void method() 這個方法在每一個測試以前執行,用於準備測試環境(如: 初始化類,讀輸入流等),在一個測試類中,每一個@Test方法的執行都會觸發一次調用。
@After public void method() 這個方法在每一個測試以後執行,用於清理測試環境數據,在一個測試類中,每一個@Test方法的執行都會觸發一次調用。
@BeforeClass public static void method() 這個方法在全部測試開始以前執行一次,用於作一些耗時的初始化工做(如: 鏈接數據庫),方法必須是static
@AfterClass public static void method() 這個方法在全部測試結束以後執行一次,用於清理數據(如: 斷開數據鏈接),方法必須是static
@Ignore或者@Ignore("太耗時") public void method() 忽略當前測試方法,通常用於測試方法尚未準備好,或者太耗時之類的
@FixMethodOrder(MethodSorters.NAME_ASCENDING) public class TestClass{} 使得該測試類中的全部測試方法都按照方法名的字母順序執行,能夠指定3個值,分別是DEFAULT、JVM、NAME_ASCENDING

本地測試

根據單元有沒有外部依賴(如Android依賴、其餘單元的依賴),將本地測試分爲兩類,首先看看沒有依賴的狀況:

1. 添加依賴,google官方推薦:
dependencies {
    // Required -- JUnit 4 framework
    testImplementation 'junit:junit:4.12'
    // Optional -- Mockito framework(可選,用於模擬一些依賴對象,以達到隔離依賴的效果)
    testImplementation 'org.mockito:mockito-core:2.19.0'
}
複製代碼
2. 單元測試代碼存儲位置:

事實上,AS已經幫咱們建立好了測試代碼存儲目錄。

app/src
     ├── androidTestjava (儀器化單元測試、UI測試)
     ├── main/java (業務代碼)
     └── test/java  (本地單元測試)
複製代碼
3. 建立測試類:

能夠本身手動在相應目錄建立測試類,AS也提供了一種快捷方式:選擇對應的類->將光標停留在類名上->按下ALT + ENTER->在彈出的彈窗中選擇Create Test

Create Test

Note: 勾選setUp/@Before會生成一個帶@Before註解的setUp()空方法,tearDown/@After則會生成一個帶@After的空方法。

import static org.hamcrest.core.Is.is;
import static org.junit.Assert.assertThat;

public class EmailValidatorTest {
    
    @Test
    public void isValidEmail() {
        assertThat(EmailValidator.isValidEmail("name@email.com"), is(true));
    }
}
複製代碼
4. 運行測試用例:
  1. 運行單個測試方法:選中@Test註解或者方法名,右鍵選擇Run
  2. 運行一個測試類中的全部測試方法:打開類文件,在類的範圍內右鍵選擇Run,或者直接選擇類文件直接右鍵Run
  3. 運行一個目錄下的全部測試類:選擇這個目錄,右鍵Run

運行前面測試驗證郵箱格式的例子,測試結果會在Run窗口展現,以下圖:

本地單元測試-經過

從結果能夠清晰的看出,測試的方法爲 EmailValidatorTest 類中的 isValidEmail()方法,測試狀態爲passed,耗時12毫秒。

修改一下前面的例子,傳入一個非法的郵箱地址:

@Test
public void isValidEmail() {
    assertThat(EmailValidator.isValidEmail("#name@email.com"), is(true));
}
複製代碼

本地單元測試-失敗

測試狀態爲failed,耗時14毫秒,同時也給出了詳細的錯誤信息:在15行出現了斷言錯誤,錯誤緣由是指望值(Expected)爲true,但實際(Actual)結果爲false。

也能夠經過命令 gradlew test 來運行全部的測試用例,這種方式能夠添加以下配置,輸出單元測試過程當中各種測試信息:

android {
    ...
    testOptions.unitTests.all {
        testLogging {
            events 'passed', 'skipped', 'failed', 'standardOut', 'standardError'
            outputs.upToDateWhen { false }
            showStandardStreams = true
        }
    }
}
複製代碼

仍是驗證郵箱地址格式的例子 gradlew test

gradlew test

在單元測試中經過System.out或者System.err打印的也會輸出。

5. 經過模擬框架模擬依賴,隔離依賴:

前面驗證郵件格式的例子,本地JVM虛擬機就能提供足夠的運行環境,但若是要測試的單元依賴了Android框架,好比用到了Android中的Context類的一些方法,本地JVM將沒法提供這樣的環境,這時候模擬框架[Mockito][1]就派上用場了。

下面是一個Context#getString(int)的測試用例

import static org.hamcrest.core.Is.is;
import static org.junit.Assert.assertThat;
import static org.mockito.Mockito.when;

@RunWith(MockitoJUnitRunner.class)
public class MockUnitTest {
    private static final String FAKE_STRING = "AndroidUnitTest";

    @Mock
    Context mMockContext;

    @Test
    public void readStringFromContext_LocalizedString() {
        //模擬方法調用的返回值,隔離對Android系統的依賴
        when(mMockContext.getString(R.string.app_name)).thenReturn(FAKE_STRING);
        assertThat(mMockContext.getString(R.string.app_name), is(FAKE_STRING));
        
        when(mMockContext.getPackageName()).thenReturn("com.jdqm.androidunittest");
        System.out.println(mMockContext.getPackageName());
    }
}
複製代碼

read string from context

經過模擬框架[Mockito][1],指定調用context.getString(int)方法的返回值,達到了隔離依賴的目的,其中[Mockito][1]使用的是[cglib][2]動態代理技術。

儀器化測試

在某些狀況下,雖然能夠經過模擬的手段來隔離Android依賴,但代價很大,這種狀況下能夠考慮儀器化的單元測試,有助於減小編寫和維護模擬代碼所需的工做量。

儀器化測試是在真機或模擬器上運行的測試,它們能夠利用Android framework APIs 和 supporting APIs。若是測試用例須要訪問儀器(instrumentation)信息(如應用程序的Context),或者須要Android框架組件的真正實現(如Parcelable或SharedPreferences對象),那麼應該建立儀器化單元測試,因爲要跑到真機或模擬器上,因此會慢一些。

配置:
dependencies {
    androidTestImplementation 'com.android.support:support-annotations:27.1.1'
    androidTestImplementation 'com.android.support.test:runner:1.0.2'
    androidTestImplementation 'com.android.support.test:rules:1.0.2'
}
複製代碼
android {
    ...
    defaultConfig {
        ...
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
}
複製代碼
例子:

這裏舉一個操做SharedPreference的例子,這個例子須要訪問Context類以及SharedPreference的具體實現,採用模擬隔離依賴的話代價會比較大,因此採用儀器化測試比較合適。

這是業務代碼中操做SharedPreference的實現

public class SharedPreferenceDao {
    private SharedPreferences sp;
    
    public SharedPreferenceDao(SharedPreferences sp) {
        this.sp = sp;
    }

    public SharedPreferenceDao(Context context) {
        this(context.getSharedPreferences("config", Context.MODE_PRIVATE));
    }

    public void put(String key, String value) {
        SharedPreferences.Editor editor = sp.edit();
        editor.putString(key, value);
        editor.apply();
    }

    public String get(String key) {
        return sp.getString(key, null);
    }
}
複製代碼

建立儀器化測試類(app/src/androidTest/java)

// @RunWith 只在混合使用 JUnit3 和 JUnit4 須要,若只使用JUnit4,可省略
@RunWith(AndroidJUnit4.class)
public class SharedPreferenceDaoTest {

    public static final String TEST_KEY = "instrumentedTest";
    public static final String TEST_STRING = "玉剛說";

    SharedPreferenceDao spDao;

    @Before
    public void setUp() {
        spDao = new SharedPreferenceDao(App.getContext());
    }

    @Test
    public void sharedPreferenceDaoWriteRead() {
        spDao.put(TEST_KEY, TEST_STRING);
        Assert.assertEquals(TEST_STRING, spDao.get(TEST_KEY));
    }
}
複製代碼

運行方式和本地單元測試同樣,這個過程會向鏈接的設備安裝apk,測試結果將在Run窗口展現,以下圖:

instrumented test passed

經過測試結果能夠清晰看到狀態passed,仔細看打印的log,能夠發現,這個過程向模擬器安裝了兩個apk文件,分別是app-debug.apk和app-debug-androidTest.apk,instrumented測試相關的邏輯在app-debug-androidTest.apk中。簡單介紹一下安裝apk命令pm install:

// 安裝apk
//-t:容許安裝測試 APK
//-r:從新安裝現有應用,保留其數據,相似於替換安裝
//更多請參考 https://developer.android.com/studio/command-line/adb?hl=zh-cn
adb shell pm install -t -r filePath
複製代碼

安裝完這兩個apk後,經過am instrument命令運行instrumented測試用例,該命令的通常格式:

am instrument [flags] <test_package>/<runner_class>
複製代碼

例如本例子中的實際執行命令:

adb shell am instrument -w -r -e debug false -e class 'com.jdqm.androidunittest.SharedPreferenceDaoTest#sharedPreferenceDaoWriteRead' com.jdqm.androidunittest.test/android.support.test.runner.AndroidJUnitRunner
複製代碼
-w: 強制 am instrument 命令等待儀器化測試結束才結束本身(wait),保證命令行窗口在測試期間不關閉,方便查看測試過程的log
-r: 以原始格式輸出結果(raw format)
-e: 以鍵值對的形式提供測試選項,例如 -e debug false
關於這個命令的更多信息請參考
https://developer.android.com/studio/test/command-line?hl=zh-cn
複製代碼

若是你實在無法忍受instrumented test的耗時問題,業界也提供了一個現成的方案[Robolectric][3],下一小節講開源框庫的時候會將這個例子改爲本地本地測試。

經常使用單元測試開源庫

1. Mocktio

https://github.com/mockito/mockito

Mock對象,模擬控制其方法返回值,監控其方法的調用等。

添加依賴

testImplementation 'org.mockito:mockito-core:2.19.0'
複製代碼

例子

import static org.hamcrest.core.Is.is;
import static org.junit.Assert.*;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.*;
import static org.mockito.internal.verification.VerificationModeFactory.atLeast;

@RunWith(MockitoJUnitRunner.class)
public class MyClassTest {

    @Mock
    MyClass test;

    @Test
    public void mockitoTestExample() throws Exception {

        //但是使用註解@Mock替代
        //MyClass test = mock(MyClass.class);

        // 當調用test.getUniqueId()的時候返回43
        when(test.getUniqueId()).thenReturn(18);
        // 當調用test.compareTo()傳入任意的Int值都返回43
        when(test.compareTo(anyInt())).thenReturn(18);

        // 當調用test.close()的時候,拋NullPointerException異常
        doThrow(new NullPointerException()).when(test).close();
        // 當調用test.execute()的時候,什麼都不作
        doNothing().when(test).execute();

        assertThat(test.getUniqueId(), is(18));
        // 驗證是否調用了1次test.getUniqueId()
        verify(test, times(1)).getUniqueId();
        // 驗證是否沒有調用過test.getUniqueId()
        verify(test, never()).getUniqueId();
        // 驗證是否至少調用過2次test.getUniqueId()
        verify(test, atLeast(2)).getUniqueId();
        // 驗證是否最多調用過3次test.getUniqueId()
        verify(test, atMost(3)).getUniqueId();
        // 驗證是否這樣調用過:test.query("test string")
        verify(test).query("test string");
        // 經過Mockito.spy() 封裝List對象並返回將其mock的spy對象
        List list = new LinkedList();
        List spy = spy(list);
        //指定spy.get(0)返回"Jdqm"
        doReturn("Jdqm").when(spy).get(0);
        assertEquals("Jdqm", spy.get(0));
    }
}
複製代碼
2. powermock

https://github.com/powermock/powermock

對於靜態方法的mock

添加依賴

testImplementation 'org.powermock:powermock-api-mockito2:1.7.4'
    testImplementation 'org.powermock:powermock-module-junit4:1.7.4'
複製代碼

Note: 若是使用了Mockito,須要這二者使用兼容的版本,具體參考 https://github.com/powermock/powermock/wiki/Mockito#supported-versions

例子

@RunWith(PowerMockRunner.class)
@PrepareForTest({StaticClass1.class, StaticClass2.class})
public class StaticMockTest {

    @Test
    public void testSomething() throws Exception{
        // mock完靜態類之後,默認全部的方法都不作任何事情
        mockStatic(StaticClass1.class);
        when(StaticClass1.getStaticMethod()).thenReturn("Jdqm");
         StaticClass1.getStaticMethod();
        //驗證是否StaticClass1.getStaticMethod()這個方法被調用了一次
        verifyStatic(StaticClass1.class, times(1));
    }
}

複製代碼

或者是封裝爲非靜態,而後用[Mockito][1]:

class StaticClass1Wraper{
  void someMethod() {
    StaticClass1.someStaticMethod();
  }
複製代碼
3. Robolectric

http://robolectric.org

主要是解決儀器化測試中耗時的缺陷,儀器化測試須要安裝以及跑在Android系統上,也就是須要在Android虛擬機或真機上面,因此十分的耗時,基本上每次來來回回都須要幾分鐘時間。針對這類問題,業界其實已經有了一個現成的解決方案: Pivotal實驗室推出的Robolectric,經過使用Robolectrict模擬Android系統核心庫的Shadow Classes的方式,咱們能夠像寫本地測試同樣寫這類測試,而且直接運行在工做環境的JVM上,十分方便。

添加配置

testImplementation "org.robolectric:robolectric:3.8"

android {
  ...
  testOptions {
    unitTests {
      includeAndroidResources = true
    }
  }
}
複製代碼

例子 模擬打開MainActivity,點擊界面上面的Button,讀取TextView的文本信息。

MainActivity.java

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        final TextView tvResult = findViewById(R.id.tvResult);
        Button button = findViewById(R.id.button);
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                tvResult.setText("Robolectric Rocks!");
            }
        });
    }
}
複製代碼

測試類(app/src/test/java/)

@RunWith(RobolectricTestRunner.class)
public class MyActivityTest {
    
    @Test
    public void clickingButton_shouldChangeResultsViewText() throws Exception {
        MainActivity activity = Robolectric.setupActivity(MainActivity.class);
        Button button =  activity.findViewById(R.id.button);
        TextView results = activity.findViewById(R.id.tvResult);
        //模擬點擊按鈕,調用OnClickListener#onClick
        button.performClick();
        Assert.assertEquals("Robolectric Rocks!", results.getText().toString());
    }
}
複製代碼

測試結果

Robolectric test passed

耗時917毫秒,是要比單純的本地測試慢一些。這個例子很是相似於直接跑到真機或模擬器上,然而它只須要跑在本地JVM便可,這都是得益於Robolectric的Shadow。

Note: 第一次跑須要下載一些依賴,可能時間會久一點,但後續的測試確定比儀器化測試打包兩個apk並安裝的過程快。

在第六小節介紹了經過儀器化測試的方式跑到真機上進行測試SharedPreferences操做,可能吐槽的點都在於耗時太長,如今經過Robolectric改寫爲本地測試來嘗試減小一些耗時。

在實際的項目中,Application可能建立時可能會初始化一些其餘的依賴庫,不太方便單元測試,這裏額外建立一個Application類,不須要在清單文件註冊,直接寫在本地測試目錄便可。

public class RoboApp extends Application {}
複製代碼

在編寫測試類的時候須要經過@Config(application = RoboApp.class)來配置Application,當須要傳入Context的時候調用RuntimeEnvironment.application來獲取:

app/src/test/java/

@RunWith(RobolectricTestRunner.class)
@Config(application = RoboApp.class)
public class SharedPreferenceDaoTest {

    public static final String TEST_KEY = "instrumentedTest";
    public static final String TEST_STRING = "玉剛說";

    SharedPreferenceDao spDao;

    @Before
    public void setUp() {
        //這裏的Context採用RuntimeEnvironment.application來替代應用的Context
        spDao = new SharedPreferenceDao(RuntimeEnvironment.application);
    }

    @Test
    public void sharedPreferenceDaoWriteRead() {
        spDao.put(TEST_KEY, TEST_STRING);
        Assert.assertEquals(TEST_STRING, spDao.get(TEST_KEY));
    }

}
複製代碼

像本地同樣把它跑起來便可。

實踐經驗

1. 代碼中用到了TextUtil.isEmpty()的如何測試
public static boolean isValidEmail(CharSequence email) {
    if (TextUtils.isEmpty(email)) {
        return false;
    }
    return EMAIL_PATTERN.matcher(email).matches();
}
複製代碼

當你嘗試本地測試這樣的代碼,就會收到一下的異常:

java.lang.RuntimeException: Method isEmpty in android.text.TextUtils not mocked.
複製代碼

這種狀況,直接在本地測試目錄(app/src/test/java)下添加TextUtils類的實現,但必須保證包名相同。

package android.text;

public class TextUtils {
    public static boolean isEmpty(CharSequence str) {
        return str == null || str.length() == 0;
    }
}
複製代碼
2. 隔離native方法
public class Model {
    public native boolean nativeMethod();
}
複製代碼
public class ModelTest {
    Model model;

    @Before
    public void setUp() throws Exception {
        model = mock(Model.class);
    }

    @Test
    public void testNativeMethod() throws Exception {
        when(model.nativeMethod()).thenReturn(true);
        Assert.assertTrue(model.nativeMethod());
    }
}
複製代碼
3. 在內部new,不方便Mock
public class Presenter {

    Model model;
    public Presenter() {
        model = new Model();
    }
    public boolean getBoolean() {
        return model.getBoolean());
    }
}
複製代碼

這種狀況,須要改進一下代碼的寫法,不在內部new,而是經過參數傳遞。

public class Presenter {
    Model model;
    public Presenter(Model model) {
        this.model = model;
    }
    public boolean getBoolean() {
        return model.getBoolean();
    }
}
複製代碼

這樣作方便Mock Model對象。

public class PresenterTest {
    Model     model;
    Presenter presenter;
    
    @Before
    public void setUp() throws Exception {
        // mock Model對象
        model = mock(Model.class);
        presenter = new Presenter(model);
    }

    @Test
    public void testGetBoolean() throws Exception {
        when(model.getBoolean()).thenReturn(true);

        Assert.assertTrue(presenter.getBoolean());
    }
}
複製代碼

從這個例子能夠看出,代碼的框架是否對單元測試友好,也是推動單元測試的一個因素。

4. 本地單元測試-文件操做

在一些涉及到文件讀寫的App,一般都會在運行時調用Environment.getExternalStorageDirectory()獲得機器的外存路徑,一般的作法是跑到真機或者模擬器上進行調試,耗時比較長,能夠經過模擬的方式,在本地JVM完成文件操做。

//注意包名保持一致
package android.os;
public class Environment {
    public static File getExternalStorageDirectory() {
        return new File("本地文件系統目錄");
    }
}
複製代碼

直接在本地單元測試進行調試,再也不須要跑到真機,再把文件pull出來查看。

public class FileDaoTest {

    public static final String TEST_STRING = "Hello Android Unit Test.";
    
    FileDao fileDao;

    @Before
    public void setUp() throws Exception {
        fileDao = new FileDao();
    }

    @Test
    public void testWrite() throws Exception {
        String name = "readme.md";
        fileDao.write(name, TEST_STRING);
        String content = fileDao.read(name);
        Assert.assertEquals(TEST_STRING, content);
    }
}
複製代碼
5. 一些測試心得
  • 考慮可讀性:對於方法名使用表達能力強的方法名,對於測試範式能夠考慮使用一種規範, 如 RSpec-style。方法名能夠採用一種格式,如: [測試的方法][測試的條件][符合預期的結果]。
  • 不要使用邏輯流關鍵字:好比(If/else、for、do/while、switch/case),在一個測試方法中,若是須要有這些,拆分到單獨的每一個測試方法裏。
  • 測試真正須要測試的內容:須要覆蓋的狀況,通常狀況只考慮驗證輸出(如某操做後,顯示什麼,值是什麼)。
  • 不須要考慮測試private的方法:將private方法當作黑盒內部組件,測試對其引用的public方法便可;不考慮測試瑣碎的代碼,如getter或者setter。
  • 每一個單元測試方法,應沒有前後順序:儘量的解耦對於不一樣的測試方法,不該該存在Test A與Test B存在時序性的狀況。

文章給出的一些示例性代碼片斷中,有一些類代碼沒有貼出來,有須要可到如下地址獲取完整代碼: https://github.com/jdqm/AndroidUnitTest

參考資料

https://developer.android.com/training/testing/unit-testing/ https://developer.android.com/training/testing/unit-testing/local-unit-tests https://developer.android.com/training/testing/unit-testing/instrumented-unit-tests https://blog.dreamtobe.cn/2016/05/15/android_test/ https://www.jianshu.com/p/bc99678b1d6e https://developer.android.com/studio/test/command-line?hl=zh-cn https://developer.android.com/studio/command-line/adb?hl=zh-cn

歡迎關注個人微信公衆號,接收第一手技術乾貨
相關文章
相關標籤/搜索