Android單元測試與模擬測試詳解

JacksBlog發表於2016-10-30

測試驅動式程式設計(Test-Driven-Development)在RoR中已經是非常普遍的開發模式,是一種十分可靠、優秀的程式設計思想,可是在Android領域中這塊還沒有普及,今天主要聊聊Android中的單元測試與模擬測試及其常用的一些庫。

I. 測試與基本規範

1. 為什麼需要測試?

  • 為了穩定性,能夠明確的瞭解是否正確的完成開發。
  • 更加易於維護,能夠在修改程式碼後保證功能不被破壞。
  • 整合一些工具,規範開發規範,使得程式碼更加穩定( 如通過 phabricator differential 發diff時提交需要執行的單元測試,在開發流程上就可以保證遠端程式碼的穩定性)。

2. 測什麼?

  • 一般單元測試:
    • 列出想要測試覆蓋的異常情況,進行驗證。
    • 效能測試。
  • 模擬測試: 根據需求,測試使用者真正在使用過程中,介面的反饋與顯示以及一些依賴系統架構的元件的應用測試。

3. 需要注意

  • 考慮可讀性,對於方法名使用表達能力強的方法名,對於測試正規化可以考慮使用一種規範, 如 RSpec-style。方法名可以採用一種格式,如: [測試的方法]_[測試的條件]_[符合預期的結果]
  • 不要使用邏輯流關鍵字(If/else、for、do/while、switch/case),在一個測試方法中,如果需要有這些,拆分到單獨的每個測試方法裡。
  • 測試真正需要測試的內容,需要覆蓋的情況,一般情況只考慮驗證輸出(如某操作後,顯示什麼,值是什麼)。
  • 考慮耗時,Android Studio預設會輸出耗時。
  • 不需要考慮測試private的方法,將private方法當做黑盒內部元件,測試對其引用的public方法即可;不考慮測試瑣碎的程式碼,如getter或者setter
  • 每個單元測試方法,應沒有先後順序;儘可能的解耦對於不同的測試方法,不應該存在Test A與Test B存在時序性的情況。

4. 建立測試

  • 選擇對應的類
  • 將游標停留在類名上
  • 按下ALT + ENTER
  • 在彈出的彈窗中選擇Create Test

II. Android Studio中的單元測試與模擬測試

control + shift + R (Android Studio 預設執行單元測試快捷鍵)。

1. 本地單元測試

直接在開發機上面進行執行測試。
在沒有依賴或者僅僅只需要簡單的Android庫依賴的情況下,有限考慮使用該類單元測試。

./gradlew check

程式碼儲存

如果是對應不同的flavor或者是build type,直接在test後面加上對應字尾(如對應名為myFlavor的單元測試程式碼,應該放在src/testMyFlavor/java下面)。

src/test/java

Google官方推薦引用

dependencies {
    // Required -- JUnit 4 framework,用於單元測試,google官方推薦
    testCompile 'junit:junit:4.12'
    // Optional -- Mockito framework,用於模擬架構,google官方推薦
    testCompile 'org.mockito:mockito-core:1.10.19'
}

JUnit

Annotation
Annotation 描述
@Test public void method() 定義所在方法為單元測試方法
@Test (expected = Exception.class) 如果所在方法沒有丟擲Annotation中的Exception.class->失敗
@Test(timeout=100) 如果方法耗時超過100毫秒->失敗
@Test(expected=Exception.class) 如果方法拋了Exception.class型別的異常->通過
@Before public void method() 這個方法在每個測試之前執行,用於準備測試環境(如: 初始化類,讀輸入流等)
@After public void method() 這個方法在每個測試之後執行,用於清理測試環境資料
BeforeClass public static void method() 這個方法在所有測試開始之前執行一次,用於做一些耗時的初始化工作(如: 連線資料庫)
AfterClass public static void method() 這個方法在所有測試結束之後執行一次,用於清理資料(如: 斷開資料連線)
@Ignore或者@Ignore("Why disabled") 忽略當前測試方法,一般用於測試方法還沒有準備好,或者太耗時之類的
@FixMethodOrder(MethodSorters.NAME_ASCENDING) public class TestClass{} 使得該測試方法中的所有測試都按照方法中的字母順序測試
Assume.assumeFalse(boolean condition) 如果滿足condition,就不執行對應方法

2. 模擬測試

需要執行在Android裝置或者虛擬機器上的測試。

主要用於測試: 單元(Android SDK層引用關係的相關的單元測試)、UI、應用元件整合測試(Service、Content Provider等)。

./gradlew connectedAndroidTest

程式碼儲存:

src/androidTest/java

Google官方推薦引用

dependencies {
    androidTestCompile 'com.android.support:support-annotations:23.0.1'
    androidTestCompile 'com.android.support.test:runner:0.4.1'
    androidTestCompile 'com.android.support.test:rules:0.4.1'
    // Optional -- Hamcrest library
    androidTestCompile 'org.hamcrest:hamcrest-library:1.3'
    // Optional -- UI testing with Espresso
    androidTestCompile 'com.android.support.test.espresso:espresso-core:2.2.1'
    // Optional -- UI testing with UI Automator
    androidTestCompile 'com.android.support.test.uiautomator:uiautomator-v18:2.1.1'
}

常見的UI測試

需要模擬Android系統環境。

主要三點:
  1. UI載入好後展示的資訊是否正確。
  2. 在使用者某個操作後UI資訊是否展示正確。
  3. 展示正確的頁面供使用者操作。

Espresso

谷歌官方提供用於UI互動測試

import static android.support.test.espresso.Espresso.onView;
import static android.support.test.espresso.action.ViewActions.click;
import static android.support.test.espresso.assertion.ViewAssertions.matches;
import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed;
import static android.support.test.espresso.matcher.ViewMatchers.withId;

// 對於Id為R.id.my_view的View: 觸發點選,檢測是否顯示
onView(withId(R.id.my_view)).perform(click())               
                            .check(matches(isDisplayed()));
// 對於文字打頭是"ABC"的View: 檢測是否沒有Enable
onView(withText(startsWith("ABC"))).check(matches(not(isEnabled()));
// 按返回鍵
pressBack();
// 對於Id為R.id.button的View: 檢測內容是否是"Start new activity"
onView(withId(R.id.button)).check(matches(withText(("Start new activity"))));
// 對於Id為R.id.viewId的View: 檢測內容是否不包含"YYZZ"
onView(withId(R.id.viewId)).check(matches(withText(not(containsString("YYZZ")))));
// 對於Id為R.id.inputField的View: 輸入"NewText",然後關閉軟鍵盤
onView(withId(R.id.inputField)).perform(typeText("NewText"), closeSoftKeyboard());
// 對於Id為R.id.inputField的View: 清除內容
onView(withId(R.id.inputField)).perform(clearText());
啟動一個開啟ActivityIntent
@RunWith(AndroidJUnit4.class)
public class SecondActivityTest {
    @Rule
    public ActivityTestRule<SecondActivity> rule =
            new ActivityTestRule(SecondActivity.class, true,
                                  // 這個引數為false,不讓SecondActivity自動啟動
                                  // 如果為true,將會在所有@Before之前啟動,在最後一個@After之後關閉
                                  false);
    @Test
    public void demonstrateIntentPrep() {
        Intent intent = new Intent();
        intent.putExtra("EXTRA", "Test");
        // 啟動SecondActivity並傳入intent
        rule.launchActivity(intent);
        // 對於Id為R.id.display的View: 檢測內容是否是"Text"
        onView(withId(R.id.display)).check(matches(withText("Test")));
    }
}
非同步互動

建議關閉裝置中”設定->開發者選項中”的動畫,因為這些動畫可能會是的Espresso在檢測非同步任務的時候產生混淆: 視窗動畫縮放(Window animation scale)、過渡動畫縮放(Transition animation scale)、動畫程式時長縮放(Animator duration scale)。

針對AsyncTask,在測試的時候,如觸發點選事件以後拋了一個AsyncTask任務,在測試的時候直接onView(withId(R.id.update)).perform(click()),然後直接進行檢測,此時的檢測就是在AsyncTask#onPostExecute之後。

// 通過實現IdlingResource,block住當非空閒的時候,當空閒時進行檢測,非空閒的這段時間處理非同步事情
public class IntentServiceIdlingResource implements IdlingResource {
    ResourceCallback resourceCallback;
    private Context context;

    public IntentServiceIdlingResource(Context context) { this.context = context; }

    @Override public String getName() { return IntentServiceIdlingResource.class.getName(); }

    @Override public void registerIdleTransitionCallback( ResourceCallback resourceCallback) { this.resourceCallback = resourceCallback; }

    @Override public boolean isIdleNow() {
      // 是否是空閒
      // 如果IntentService 沒有在執行,就說明非同步任務結束,IntentService特質就是啟動以後處理完Intent中的事務,理解關閉自己
        boolean idle = !isIntentServiceRunning();
        if (idle && resourceCallback != null) {
          // 回撥告知非同步任務結束
            resourceCallback.onTransitionToIdle();
        }
        return idle;
    }

    private boolean isIntentServiceRunning() {
        ActivityManager manager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
        // Get all running services
        List<ActivityManager.RunningServiceInfo> runningServices = manager.getRunningServices(Integer.MAX_VALUE);
        // check if our is running
        for (ActivityManager.RunningServiceInfo info : runningServices) {
            if (MyIntentService.class.getName().equals(info.service.getClassName())) {
                return true;
            }
        }
        return false;
    }
}

// 使用IntentServiceIdlingResource來測試,MyIntentService服務啟動結束這個非同步事務,之後的結果。
@RunWith(AndroidJUnit4.class)
public class IntegrationTest {

    @Rule
    public ActivityTestRule rule = new ActivityTestRule(MainActivity.class);
    IntentServiceIdlingResource idlingResource;

    @Before
    public void before() {
        Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
        Context ctx = instrumentation.getTargetContext();
        idlingResource = new IntentServiceIdlingResource(ctx);
        // 註冊這個非同步監聽
        Espresso.registerIdlingResources(idlingResource);

    }
    @After
    public void after() {
        // 取消註冊這個非同步監聽
        Espresso.unregisterIdlingResources(idlingResource);

    }

    @Test
    public void runSequence() {
        // MainActivity中點選R.id.action_settings這個View的時候,會啟動MyIntentService
        onView(withId(R.id.action_settings)).perform(click());
        // 這時候IntentServiceIdlingResource#isIdleNow會返回false,因為MyIntentService服務啟動了
        // 這個情況下,這裡會block住.............
        // 直到IntentServiceIdlingResource#isIdleNow返回true,並且回撥了IntentServiceIdlingResource#onTransitionToIdle
        // 這個情況下,繼續執行,這時我們就可以測試非同步結束以後的情況了。
        onView(withText("Broadcast")).check(matches(notNullValue()));
    }
}
自定義匹配器
// 定義
public static Matcher<View> withItemHint(String itemHintText) {
  checkArgument(!(itemHintText.equals(null)));
  return withItemHint(is(itemHintText));
}

public static Matcher<View> withItemHint(final Matcher<String> matcherText) {
  checkNotNull(matcherText);
  return new BoundedMatcher<View, EditText>(EditText.class) {

    @Override
    public void describeTo(Description description) {
      description.appendText("with item hint: " + matcherText);
    }

    @Override
    protected boolean matchesSafely(EditText editTextField) {
      // 取出hint,然後比對下是否相同
      return matcherText.matches(editTextField.getHint().toString());
    }
  };
}

// 使用
onView(withItemHint("test")).check(matches(isDisplayed()));

III. 擴充工具

1. AssertJ Android

square/assertj-android
極大的提高可讀性。

import static org.assertj.core.api.Assertions.*;

// 斷言: view是GONE的
assertThat(view).isGone();

MyClass test = new MyClass("Frodo");
MyClass test1 = new MyClass("Sauron");
MyClass test2 = new MyClass("Jacks");

List<MyClass> testList = new ArrayList<>();
testList.add(test);
testList.add(test1);

// 斷言: test.getName()等於"Frodo"
assertThat(test.getName()).isEqualTo("Frodo");
// 斷言: test不等於test1並且在testList中
assertThat(test).isNotEqualTo(test1)
                 .isIn(testList);
// 斷言: test.getName()的字串,是由"Fro"打頭,以"do"結尾,忽略大小寫會等於"frodo"
assertThat(test.getName()).startsWith("Fro")
                            .endsWith("do")
                            .isEqualToIgnoringCase("frodo");
// 斷言: testList有2個資料,包含test,test1,不包含test2
assertThat(list).hasSize(2)
                .contains(test, test1)
                .doesNotContain(test2);

// 斷言: 提取testList佇列中所有資料中的成員變數名為name的變數,並且包含name為"Frodo"與"Sauron"
//      並且不包含name為"Jacks"
assertThat(testList).extracting("name")
                    .contains("Frodo", "Sauron")
                    .doesNotContain("Jacks");

2. Hamcrest

JavaHamcrest
通過已有的通配方法,快速的對程式碼條件進行測試
org.hamcrest:hamcrest-junit:(version)

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.equalTo;

// 斷言: a等於b
assertThat(a, equalTo(b));
assertThat(a, is(equalTo(b)));
assertThat(a, is(b));
// 斷言: a不等於b
assertThat(actual, is(not(equalTo(b))));

List<Integer> list = Arrays.asList(5, 2, 4);
// 斷言: list有3個資料
assertThat(list, hasSize(3));
// 斷言: list中有5,2,4,並且順序也一致
assertThat(list, contains(5, 2, 4));
// 斷言: list中包含5,2,4
assertThat(list, containsInAnyOrder(2, 4, 5));
// 斷言: list中的每一個資料都大於1
assertThat(list, everyItem(greaterThan(1)));
// 斷言: fellowship中包含有成員變數"race",並且其值不是ORC
assertThat(fellowship, everyItem(hasProperty("race", is(not((ORC))))));
// 斷言: object1中與object2相同的成員變數都是相同的值
assertThat(object1, samePropertyValuesAs(object2));

Integer[] ints = new Integer[] { 7, 5, 12, 16 };
// 斷言: 陣列中包含7,5,12,16
assertThat(ints, arrayContaining(7, 5, 12, 16));
幾個主要的匹配器:
Mather 描述
allOf 所有都匹配
anyOf 任意一個匹配
not 不是
equalTo 物件等於
is
hasToString 包含toString
instanceOf,isCompatibleType 類的型別是否匹配
notNullValue,nullValue 測試null
sameInstance 相同例項
hasEntry,hasKey,hasValue 測試Map中的EntryKeyValue
hasItem,hasItems 測試集合(collection)中包含元素
hasItemInArray 測試陣列中包含元素
closeTo 測試浮點數是否接近指定值
greaterThan,greaterThanOrEqualTo,lessThan,lessThanOrEqualTo 資料對比
equalToIgnoringCase 忽略大小寫字串對比
equalToIgnoringWhiteSpace 忽略空格字串對比
containsString,endsWith,startsWith,isEmptyString,isEmptyOrNullString 字串匹配
自定義匹配器
// 自定義
import org.hamcrest.Description;
import org.hamcrest.TypeSafeMatcher;

public class RegexMatcher extends TypeSafeMatcher<String> {
    private final String regex;

    public RegexMatcher(final String regex) { this.regex = regex; }
    @Override
    public void describeTo(final Description description) { description.appendText("matches regular expression=`" + regex + "`"); }

    @Override
    public boolean matchesSafely(final String string) { return string.matches(regex); }

    // 上層呼叫的入口
    public static RegexMatcher matchesRegex(final String regex) {
        return new RegexMatcher(regex);
    }
}

// 使用
String s = "aaabbbaaa";
assertThat(s, RegexMatcher.matchesRegex("a*b*a"));

3. Mockito

Mockito
Mock物件,控制其返回值,監控其方法的呼叫。
org.mockito:mockito-all:(version)

// import如相關類
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;

// 建立一個Mock的物件
 MyClass test = mock(MyClass.class);

// 當呼叫test.getUniqueId()的時候返回43
when(test.getUniqueId()).thenReturn(43);
// 當呼叫test.compareTo()傳入任意的Int值都返回43
when(test.compareTo(anyInt())).thenReturn(43);
// 當呼叫test.compareTo()傳入的是Target.class型別物件時返回43
when(test.compareTo(isA(Target.class))).thenReturn(43);
// 當呼叫test.close()的時候,拋IOException異常
doThrow(new IOException()).when(test).close();
// 當呼叫test.execute()的時候,什麼都不做
doNothing().when(test).execute();

// 驗證是否呼叫了兩次test.getUniqueId()
verify(test, times(2)).getUniqueId();
// 驗證是否沒有呼叫過test.getUniqueId()
verify(test, never()).getUniqueId();
// 驗證是否至少呼叫過兩次test.getUniqueId()
verify(test, atLeast(2)).getUniqueId();
// 驗證是否最多呼叫過三次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)返回"foo"
doReturn("foo").when(spy).get(0);

assertEquals("foo", spy.get(0));
對訪問方法時,傳入引數進行快照
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import static org.junit.Assert.assertEquals;

@Captor
private ArgumentCaptor<Integer> captor;

@Test
public void testCapture(){
  MyClass test = mock(MyClass.class);

  test.compareTo(3, 4);
  verify(test).compareTo(captor.capture(), eq(4));

  assertEquals(3, (int)captor.getValue());

  // 需要特別注意,如果是可變陣列(vargars)引數,如方法 test.doSomething(String... params)
  // 此時是使用ArgumentCaptor<String>,而非ArgumentCaptor<String[]>
  ArgumentCaptor<String> varArgs = ArgumentCaptor.forClass(String.class);
  test.doSomething("param-1", "param-2");
  verify(test).doSomething(varArgs.capture());

  // 這裡直接使用getAllValues()而非getValue(),來獲取可變陣列引數的所有傳入引數
  assertThat(varArgs.getAllValues()).contains("param-1", "param-2");
}
對於靜態的方法的Mock:

可以使用 PowerMock:

org.powermock:powermock-api-mockito:(version) & org.powermock:powermock-module-junit4:(version)(For PowerMockRunner.class)

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

  @Test
  public void testSomething() {
    // mock完靜態類以後,預設所有的方法都不做任何事情
    mockStatic(StaticClass1.class);
    when(StaticClass1.getStaticMethod()).andReturn("anything");

    // 驗證是否StaticClass1.getStaticMethod()這個方法被呼叫了一次
    verifyStatic(time(1));
    StaticClass1.getStaticMethod();

    when(StaticClass1.getStaticMethod()).andReturn("what ever");

    // 驗證是否StaticClass2.getStaticMethod()這個方法被至少呼叫了一次
    verifyStatic(atLeastOnce());
    StaticClass2.getStaticMethod();

    // 通過任何引數建立File的實力,都直接返回fileInstance物件
    whenNew(File.class).withAnyArguments().thenReturn(fileInstance);
  }
}

或者是封裝為非靜態,然後用Mockito:

class FooWraper{
  void someMethod() {
    Foo.someStaticMethod();
  }
}

4. Robolectric

Robolectric
讓模擬測試直接在開發機上完成,而不需要在Android系統上。所有需要使用到系統架構庫的,如(HandlerHandlerThread)都需要使用Robolectric,或者進行模擬測試。

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

5. Robotium

RobotiumTech/robotium
(Integration Tests)模擬使用者操作,事件流測試。

@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class)
public class MyActivityTest{

  @Test
  public void doSomethingTests(){
    // 獲取Application物件
    Application application = RuntimeEnvironment.application;

    // 啟動WelcomeActivity
    WelcomeActivity activity = Robolectric.setupActivity(WelcomeActivity.class);
    // 觸發activity中Id為R.id.login的View的click事件
    activity.findViewById(R.id.login).performClick();

    Intent expectedIntent = new Intent(activity, LoginActivity.class);
    // 在activity之後,啟動的Activity是否是LoginActivity
    assertThat(shadowOf(activity).getNextStartedActivity()).isEqualTo(expectedIntent);
  }
}

通過模擬使用者的操作的行為事件流進行測試,這類測試無法避免需要在虛擬機器或者裝置上面執行的。是一些使用者操作流程與視覺顯示強相關的很好的選擇。

6. Test Butler

linkedin/test-butler
避免裝置/模擬器系統或者環境的錯誤,導致測試的失敗。

通常我們在進行UI測試的時候,會遇到由於模擬器或者裝置的錯誤,如系統的crash、ANR、或是未預期的Wifi、CPU罷工,或者是鎖屏,這些外再環境因素導致測試不過。Test-Butler引入就是避免這些環境因素導致UI測試不過。

該庫被谷歌官方推薦過,並且收到谷歌工程師的Review。

IV. 擴充思路

1. Android Robots

Instrumentation Testing Robots – Jake Wharton

假如我們需要測試: 傳送 $42 到 “foo@bar.com”,然後驗證是否成功。

通常的做法

Robot思想

在寫真正的UI測試的時候,只需要關注要測試什麼,而不需要關注需要怎麼測試,換句話說就是讓測試邏輯與View或Presenter解耦,而與資料產生關係。

首先通過封裝一個Robot去處理How的部分:

然後在寫測試的時候,只關注需要測試什麼:

最終的思想原理

相關文章