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

玉剛說發表於2019-03-04

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

原作者:Jdqm

版權宣告:本文版權歸微信公眾號玉剛說所有,未經許可,不得以任何形式轉載

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

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

為什麼要進行單元測試?

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

單元測試要測什麼?

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

單元測試的分類

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

  2. 儀器化測試(Instrumented tests): 在真機或模擬器上執行的單元測試,由於需要跑到裝置上,比較慢,這些測試可以訪問儀器(Android系統)資訊,比如被測應用程式的上下文,一般地,依賴不太方便通過模擬框架模擬時採用這種方式。

JUnit 註解

瞭解一些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

Android 單元測試只看這一篇就夠了
歡迎關注我的微信公眾號,接收第一手技術乾貨

相關文章