Android單元測試與模擬測試詳解
測試驅動式程式設計(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系統環境。
主要三點:
- UI載入好後展示的資訊是否正確。
- 在使用者某個操作後UI資訊是否展示正確。
- 展示正確的頁面供使用者操作。
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());
啟動一個開啟Activity
的Intent
@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 中的Entry 、Key 、Value |
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)
(ForPowerMockRunner.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系統上。所有需要使用到系統架構庫的,如(Handler
、HandlerThread
)都需要使用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
假如我們需要測試: 傳送 $42 到 “foo@bar.com”,然後驗證是否成功。
通常的做法
Robot思想
在寫真正的UI測試的時候,只需要關注要測試什麼,而不需要關注需要怎麼測試,換句話說就是讓測試邏輯與View或Presenter解耦,而與資料產生關係。
首先通過封裝一個Robot去處理How的部分:
然後在寫測試的時候,只關注需要測試什麼:
最終的思想原理
相關文章
- 單元測試模擬框架Mockito框架Mockito
- 測試 之Java單元測試、Android單元測試JavaAndroid
- 單元測試之模擬物件技術物件
- 偽物件、樁物件、模擬物件|單元測試物件
- Android - 單元測試Android
- android單元測試Android
- [Android] 單元測試Android
- Android單元測試-對Activity的測試Android
- Android單元測試-對View的測試AndroidView
- 單元測試與MockitoMockito
- 單元測試與 PowerMockMock
- PHP 單元測試與資料庫測試PHP資料庫
- android按鍵模擬測試Android
- 單元測試:單元測試中的mockMock
- javascript單元測試框架mochajs詳解JavaScript框架JS
- 圖文詳解C#單元測試C#
- Android 單元測試實踐Android
- 關於 Android 單元測試Android
- Android單元測試(一):前言Android
- [Android]單元測試例項Android
- Android自動化測試入門(四)單元測試Android
- 前端測試:Part II (單元測試)前端
- Mokito 單元測試與 Spring-Boot 整合測試Springboot
- Go 單元測試之HTTP請求與API測試GoHTTPAPI
- SpringBoot中的response和request模擬單元測試Spring Boot
- 黑盒測試、白盒測試、單元測試、整合測試、系統測試、驗收測試的區別與聯絡...
- 程式碼重構與單元測試——重構1的單元測試(四)
- Vue 應用單元測試的策略與實踐 04 - Vuex 單元測試Vue
- Flutter 學習之路 - 測試(單元測試,Widget 測試,整合測試)Flutter
- [iOS單元測試系列]單元測試編碼規範iOS
- [android]android自動化測試十之單元測試例項Android
- 模擬測試框架-Mockito框架Mockito
- 單元測試與封裝5.1封裝
- Cmocka 單元測試配置與使用Mock
- 單元測試 - 測試場景記錄
- Go 單元測試之mock介面測試GoMock
- 測試氣味-整潔單元測試
- 程式碼重構與單元測試——測試專案(二)