測試與基本規範 為什麼需要測試? 為了穩定性,能夠明確的瞭解是否正確的完成開發。 更加易於維護,能夠在修改代碼後保證功能不被破壞。 集成一些工具,規範開發規範,使得代碼更加穩定( 如通過 phabricator differential 發diff時提交需要執行的單元測試,在開發流程上就可以保證遠端 ...
測試與基本規範
為什麼需要測試?
- 為了穩定性,能夠明確的瞭解是否正確的完成開發。
- 更加易於維護,能夠在修改代碼後保證功能不被破壞。
- 集成一些工具,規範開發規範,使得代碼更加穩定( 如通過 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
Android Studio中的單元測試與模擬測試
control + shift + R (Android Studio 預設執行單元測試快捷鍵)。
1. 本地單元測試
直接在開發機上面進行運行測試。
在沒有依賴或者僅僅只需要簡單的Android庫依賴的情況下,有限考慮使用該類單元測試。
./gradlew check
(1)代碼存儲
如果是對應不同的flavor或者是build type,直接在test後面加上對應尾碼(如對應名為myFlavor
的單元測試代碼,應該放在src/testMyFlavor/java
下麵)。
src/test/java
(2)Google官方推薦引用
dependencies { // Required -- JUnit 4 framework,用於單元測試,google官方推薦 testCompile 'junit:junit:4.12' // Optional -- Mockito framework,用於模擬架構,google官方推薦 // http://www.manongjc.com/article/1546.html testCompile 'org.mockito:mockito-core:1.10.19' }
(3)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
(1)代碼存儲:
src/androidTest/java
(2)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 // http://www.manongjc.com/article/1546.html 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' }
(3)常見的UI測試
需要模擬Android系統環境。
主要三點:
- UI載入好後展示的信息是否正確。
- 在用戶某個操作後UI信息是否展示正確。
- 展示正確的頁面供用戶操作。
(4)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" // http://www.manongjc.com/article/1537.html 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" // http://www.manongjc.com/article/1532.html onView(withId(R.id.display)).check(matches(withText("Test"))); } }
(5)非同步交互
建議關閉設備中”設置->開發者選項中”的動畫,因為這些動畫可能會是的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中的事務,理解關閉自己 // http://www.manongjc.com/article/1531.html 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())); } }
(6)自定義匹配器
// 定義 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,然後比對下是否相同 // http://www.manongjc.com/article/1524.html return matcherText.matches(editTextField.getHint().toString()); } }; } // 使用 onView(withItemHint("test")).check(matches(isDisplayed()));
拓展工具
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中 // http://www.manongjc.com/article/1519.html 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 // http://www.manongjc.com/article/1507.html 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));
(1)幾個主要的匹配器:
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 |
字元串匹配 |
(2)自定義匹配器
// 自定義 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() // http://www.manongjc.com/article/1503.html 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"); }
(1)對於靜態的方法的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系統上。所有需要使用到系統架構庫的,如(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事件 // http://www.manongjc.com/article/1502.html 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。
拓展思路
1. Android Robots
Instrumentation Testing Robots – Jake Wharton
假如我們需要測試: 發送 $42 到 “[email protected]”,然後驗證是否成功。
(1)通常的做法
(2)Robot思想
在寫真正的UI測試的時候,只需要關註要測試什麼,而不需要關註需要怎麼測試,換句話說就是讓測試邏輯與View或Presenter解耦,而與數據產生關係。
首先通過封裝一個Robot去處理How的部分:
然後在寫測試的時候,只關註需要測試什麼:
最終的思想原理