前言
我們先回顧一下,在上一篇博客中,主要分享了Android單元測試的邏輯測試部分。接下來,我們重點講解Android單元測試的UI測試部分!
何為UI測試呢?就是對用戶界面的交互元素進行測試,如TextView
、ImageView
,驗證其可見性,驗證圖片、文字是否顯示正確。不過實踐發現,UI測試有點像集成測試,View的狀態往往對數據(網絡數據、數據庫SQLite
或者SharedPeferences
)有依賴,這就要求我們要通過Mock的形式對數據依賴進行隔離,不然就沒法測試驗證。在時間不太允許的情況下,我個人覺得,UI測試覆蓋項目中核心或者關鍵的業務流程即可。依具體情況而定,界面本來就比較簡單的話,感覺沒有必要單元覆蓋。
針對Android UI測試,主要使用谷歌推薦的測試框架Espresso,下面我們來著重介紹一下。
Espresso
根據谷歌官方介紹,Espresso
最關鍵的優勢就是它能夠檢測到主線程空閑狀態的時候,在適當的時候運行測試代碼,這樣就沒必要通過Thread.sleep()
去讓主線程睡眠的方式去同步測試。說白了,就是Espresso
框架在測試app時,不會通過阻塞主線程去同步UI測試。
示例
onView(withId(R.id.my_view)) // withId(R.id.my_view) is a ViewMatcher
.perform(click()) // click() is a ViewAction
.check(matches(isDisplayed())); // matches(isDisplayed()) is a ViewAssertion
簡單解釋一下:通過onView()
方法在界面上定位到R.id.my_view
這個Button,然后通過perform(click())
方式在這個Button上面觸發點擊事件,最后通過check(matches(isDisplayed()))
來檢查這個Button是否還顯示著。就這么簡單,通過這三個步驟就完成了UI的測試工作。
UI測試三部曲
Espresso有三個重要的類,分別是Matchers(匹配器),ViewAction(界面行為),ViewAssertions(界面判斷),其中Matchers是常常是通過匹配條件來需找UI組件或過濾UI,而ViewAction是來模擬用戶操作界面的行為,ViewAssertions對模擬行為操作的View進行變換和結果驗證,其三者關系如圖所示:
異步方法測試
Espresso官方文檔有這樣一段話:
Espresso測試有個很強大之處就是它在多個測試操作中是線程安全的,它會等待當前進程的消息隊列中的UI事件,并且在任何一個測試操作中會等待其中的AsyncTask結束才會執行下一個測試。
也就是說,如果代碼中是通過AsyncTask或者AsyncTaskCompat方式來執行異步任務,并不需要去額外的處理,只需要等待Espresso處理,它會幫助我們執行異步方法后,再執行我們的測試代碼進行斷言判斷。
但是項目中執行異步可能不是通過AsyncTask方式,或許通過Volley、Retrofit、Thread。那么問題來了,這樣的異步任務該如何測試?如果不處理的話,就會出現這樣的問題,測試代碼執行完畢了,但異步耗時任務可能還未執行完,那么你想在異步任務執行完后驗證返回結果是否正確,好像就無能為力!就不賣關子了,現在我們就說一下解決方法吧。
1.使用IdlingResource
看過Espresso源碼的同學,應該會知道,Espresso會等待AsyncTask和IdlingResource執行完畢后才會執行我們寫的測試代碼。這樣就只需要實現Espresso提供的IdlingResource接口,就可以實現測試異步方法。具體如何實踐,可以參考這個博客,但是你發現了沒有,這種方式對原有的代碼有侵入性。
2.使用AsyncTask提供的EXECUTOR
因為Espresso會等待AsyncTask執行完,所以我們只需要想辦法把異步線程執行切換到AsyncTask所在的線程池執行,就可以測試異步任務。
(1)、Executor
使用Executor線程池來執行異步任務,那么就很簡單了,使用AsyncTask.THREAD_POOL_EXECUTOR
代替項目中的Executor來執行Runnable
(2)、RxJava
隨著RxJava越來越流行,正是其牛逼的切換線程的功能,通過以下方式就可以讓你很方便地測試RxJava異步任務:
RxJavaPlugins.getInstance().reset();
RxJavaPlugins.getInstance().registerSchedulersHook(new RxJavaSchedulersHook() {
@Override
public Scheduler getComputationScheduler() {
return Schedulers.from(AsyncTask.THREAD_POOL_EXECUTOR);
}
@Override
public Scheduler getIOScheduler() {
return Schedulers.from(AsyncTask.THREAD_POOL_EXECUTOR);
}
@Override
public Scheduler getNewThreadScheduler() {
return Schedulers.from(AsyncTask.THREAD_POOL_EXECUTOR);
}
});
其他異步線程的框架如果要測試,也可以通過以上兩種方式來解決。
Activity跳轉
很多時候,在界面中點擊一個View跳轉到另外的一個Activity,我們要如何驗證這個View點擊之后是否跳轉到正確的Activity呢?Espresso為我們提供了Intented
,這個是干嘛用的呢。要先加上build.gradle配置:
androidTestCompile ('com.android.support.test.espresso:espresso-intents:2.2.2') {
exclude group: 'com.google.code.findbugs'
}
Espresso-Intents
記錄了在測試應用時所有嘗試啟動的Activity。使用intent的API,類似于Mockito.verify()
,你可以斷言指定的intnet是否被接收。下面是一個簡單的驗證intent已經發出的的例子:
@Test
public void validateIntentSentToPackage() {
//用戶的動作,結果是啟動一個外部的“phone”Activity
user.clickOnView(system.getView(R.id.callButton));
// Using a canned RecordedIntentMatcher to validate that an intent resolving
// to the "phone" activity has been sent.
//通過封裝的RecordeIntentMatcher來驗證作用于“phone” activity的intent已經被發送
intended(toPackage("com.android.phone"));
}
使用intent API,類似于Mockito.when()
,你可以為通過startActivityForResult()
方法打開的Activity
mock設置一個返回結果Intent。對于無法操控用戶操作,也不能控制在測試環境下返回結果的activity尤其重要。下面來一個簡單的例子:
Uri uri = Uri.parse("content://com.android.providers.media.documents/document/image%3A1575");
Intent imageIntent = new Intent();
imageIntent.setData(uri);
Instrumentation.ActivityResult imageResult = new Instrumentation.ActivityResult(Activity.RESULT_OK, imageIntent);
intending(hasComponent(SelectImageActivity.class.getName())).respondWith(imageResult);
testClick(R.id.progress_business_license_pic);
我來簡單解釋一下這個例子,點擊R.id.progress_business_license_pic
打開SelectImageActivity
來選擇圖片,如上面所說,模擬用戶選擇圖片的動作,返回imageResult
攜帶圖片地址Uri.parse("content://com.android.providers.media.documents/document/image%3A1575");
項目實踐
項目中有一個信息填寫的界面:
如上圖所示,這是一個很典型的用戶信息界面,涉及到商戶名稱、商戶品類、商戶電話和商戶地址等基本信息的填寫。在UI單元測試中,我們勢必關心其填寫的合法性驗證和非法信息的顯示,以及在填寫合法信息后是否可以正確的進入下一步驟。
在這里,只要所有的基本信息合法填寫后,界面右上角的“下一步”按鈕就會高亮顯示且可以點擊進入下一步,這個按鈕默認是置灰的。我們就按照這種方式來驗證“下一步”按鈕是否可以高亮點擊。但這期間會遇到兩個問題:
- 當在“商戶品類”上點擊,就會觸發網絡請求去服務端拉取商戶品類列表,所以就必須把這個異步的網絡請求切換到當前線程來執行,具體方式可以參考上面,否則就遇到:測試代碼執行完畢,而這個網絡請求還沒有執行完。
- 在選擇“商戶地址”的時候,點擊會跳到地圖地位的
AddressActivity
,然而我們希望隔離這種依賴,只針對當前界面進行測試,所以這個時候就需要intending
幫我們解決這樣頭疼的問題,具體方式可以參考上面。
build.gradle配置
添加testInstrumentationRunner
:
defaultConfig {
...
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
添加編譯依賴:
dependencies {
...
androidTestCompile 'com.android.support:support-annotations:23.4.0'
androidTestCompile 'com.android.support.test:runner:0.5'
androidTestCompile ('com.android.support.test.espresso:espresso-intents:2.2.2') {
exclude group: 'com.google.code.findbugs'
}
androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2') {
exclude group: 'com.google.code.findbugs'
}
androidTestCompile 'org.mockito:mockito-android:2.6.3'
}
BaseEspresso基類
@LargeTest
@RunWith(AndroidJUnit4.class)
public class BaseEspresso<T extends Activity> {
// 添加Mock攔截器
static {
ArrayList<Interceptor> interceptors = new ArrayList<>();
interceptors.add(new OkHttpMockInterceptor());
CustomClient.getInstance(interceptors);
}
@Rule
public IntentsTestRule<T> mActivityRule = new IntentsTestRule<T>((Class<T>) ((ParameterizedType) getClass().getGenericSuperclass()).getActualTypeArguments()[0], true, false);
public T mActivity = null;
/**
* 模擬用戶的點擊行為
*
* @param id
*/
public void testClick(final int id) {
onView(withId(id)).perform(closeSoftKeyboard(), click());
}
/**
* 模擬用戶的點擊行為
*
* @param text
*/
public void testClick(String text) {
onView(withText(text)).perform(closeSoftKeyboard(), click());
}
/**
* 模擬用戶的點擊行為
*
* @param id
* @param text
*/
public void testClick(final int id, String text) {
onView(allOf(withId(id), withText(text))).perform(closeSoftKeyboard(), click());
}
/**
* 模擬用戶的點擊行為
*
* @param id
* @param scrollTo
*/
public void testClick(final int id, boolean scrollTo) {
if (scrollTo) {
onView(allOf(withId(id))).perform(closeSoftKeyboard(), scrollTo(), click());
} else {
onView(allOf(withId(id))).perform(closeSoftKeyboard(), click());
}
}
/**
* 模擬用戶的輸入文本行為
*
* @param id
* @param text
* @return
*/
public String testInputText(final int id, String text) {
onView(withId(id)).perform(scrollTo(), clearText(), replaceText(text), closeSoftKeyboard());
return text;
}
/**
* 檢查View的文本變化是否正確
*
* @param id
* @param text
*/
public void testTextEquals(final int id, String text) {
onView(withId(id)).check(matches(withText(text)));
}
/**
* 檢查View是否可見
*
* @param id
*/
public void testViewVisible(final int id) {
onView(withId(id))
.check(matches(isDisplayed()));
}
/**
* 檢查View是否可見
*
* @param text
*/
public void testViewVisible(String text) {
onView(withText(text))
.check(matches(isDisplayed()));
}
/**
* 檢查View是否可見
*
* @param id
* @param text
*/
public void testViewVisible(final int id, String text) {
onView(allOf(withId(id), withText(text)))
.check(matches(isDisplayed()));
}
/**
* 檢查View是否不可見
*
* @param id
*/
public void testViewUnVisible(final int id) {
onView(withId(id))
.check(matches(not(isDisplayed())));
}
/**
* 模擬用戶點擊Dialog
*
* @param id
*/
public void testDialogClick(int id) {
onView(withId(id)).inRoot(isDialog())
.check(matches(isDisplayed()))
.perform(click());
}
/**
* 模擬用戶點擊Dialog
*
* @param text
*/
public void testDialogClick(String text) {
onView(withText(text)).inRoot(isDialog())
.check(matches(isDisplayed()))
.perform(click());
}
/**
* 檢查View是否可用
*
* @param id
*/
public void testViewEnable(int id) {
onView(withId(id))
.check(matches(isEnabled()));
}
/**
* 檢查View是否不可用
*
* @param id
*/
public void testViewUnEnable(int id) {
onView(withId(id))
.check(matches(not(isEnabled())));
}
/**
* 初始化Activity
*
* @return
*/
public T getActivity() {
return getActivity(null);
}
/**
* 初始化Activity
*
* @return
*/
public T getActivity(Intent intent) {
if (intent == null) {
intent = new Intent();
}
if (mActivity == null) {
mActivityRule.launchActivity(intent);
mActivity = mActivityRule.getActivity();
}
return mActivity;
}
/**
* 獲取可見的Fragment
*
* @return
*/
public Fragment getVisibleFragment() {
if (mActivity == null) {
getActivity();
}
if (!(mActivity instanceof FragmentActivity)) {
return null;
}
FragmentManager fm = ((FragmentActivity) mActivity).getSupportFragmentManager();
if (fm == null || fm.getFragments() == null || fm.getFragments().size() == 0) {
return null;
}
for (int i = fm.getFragments().size() - 1; i >= 0; --i) {
Fragment fragment = fm.getFragments().get(i);
if (fragment != null
&& fragment.isResumed()
&& fragment.isVisible()
&& fragment.getUserVisibleHint()) {
return fragment;
}
}
return null;
}
/**
* 獲取除DialogFragment之外可見的Fragment
*
* @return
*/
public Fragment getVisibleExcludeDialogFragment() {
if (mActivity == null) {
getActivity();
}
if (!(mActivity instanceof FragmentActivity)) {
return null;
}
FragmentManager fm = ((FragmentActivity) mActivity).getSupportFragmentManager();
if (fm == null || fm.getFragments() == null || fm.getFragments().size() == 0) {
return null;
}
for (int i = fm.getFragments().size() - 1; i >= 0; --i) {
Fragment fragment = fm.getFragments().get(i);
if (fragment != null
&& !(fragment instanceof DialogFragment)
&& fragment.isResumed()
&& fragment.isVisible()
&& fragment.getUserVisibleHint()) {
return fragment;
}
}
return null;
}
/**
* 檢查Fragment是否可見
*
* @param type
*/
public void testFragmentVisible(Class type) {
Assert.assertTrue(type.isInstance(getVisibleFragment()));
}
/**
* 休眠
*
* @param time
*/
public void sleep(long time) {
try {
Thread.sleep(time);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/**
* 獲取字符串
*
* @param resId
* @return
*/
public String getString(int resId) {
if (mActivity == null) {
getActivity();
}
return mActivity.getString(resId);
}
/**
* 在執行執行測試代碼前,先登錄
*/
protected void loginAccount() {
UserApi.getInstance().loginByPwd("13636330012", "123abc")
.subscribe(new Subscriber<LoginResult>() {
@Override
public void onCompleted() {
}
@Override
public void onError(Throwable e) {
}
@Override
public void onNext(LoginResult loginResult) {
AuthInfo ai = OAuthManager.getInstance().getAuth();
ai.setSargerasToken(loginResult.getSargerasToken());
OAuthManager.getInstance().saveAuth(ai);
Store store = AppCookie.getStoreInfo();
store.setAccountType(loginResult.getAccountType());
AppCookie.saveStoreInfo(store);
}
});
}
@Before
public void setUp() {
// 切換RxJava的工作線程
RxJavaPlugins.getInstance().reset();
RxJavaPlugins.getInstance().registerSchedulersHook(new RxJavaSchedulersHook() {
@Override
public Scheduler getComputationScheduler() {
return Schedulers.from(AsyncTask.THREAD_POOL_EXECUTOR);
}
@Override
public Scheduler getIOScheduler() {
return Schedulers.from(AsyncTask.THREAD_POOL_EXECUTOR);
}
@Override
public Scheduler getNewThreadScheduler() {
return Schedulers.from(AsyncTask.THREAD_POOL_EXECUTOR);
}
});
// 在執行測試前,執行用戶登錄操作,防止對用戶信息有依賴
loginAccount();
}
@After
public void tearDown() {
// 清除用戶數據
OrderSet.getInstance().clearOrder();
OrderSet.getInstance().clearIgnoreOrder();
PreferenceUtil.clearAll();
OAuthManager.getInstance().clear();
}
}
測試代碼
@LargeTest
public class CreateAuthenticationActivityTest extends BaseEspresso<CreateAuthenticationActivity> {
@Test
public void activityTestBasic() {
AppCookie.setTempAuthentication(null);
Shop shop = AppCookie.getShopInfo();
if (shop == null) {
shop = new Shop();
}
int status = shop.getVerifyStatus();
shop.setVerifyStatus(Shop.STATUS_UNAUTHORIZED);
AppCookie.saveShopInfo(shop);
getActivity();
Assert.assertNotNull(mActivity);
Fragment fragment = getVisibleFragment();
Assert.assertTrue(fragment instanceof AuthBasicFragment);
testViewVisible(R.id.et_retailer_name);
Intent addressIntent = new Intent();
FeoAddress address = new FeoAddress(true, "上海市普陀區", "真北路788號", 0, 0, false);
addressIntent.putExtra(SearchAddressActivity.ADDRESS_ITEM, address);
Instrumentation.ActivityResult result = new Instrumentation.ActivityResult(Activity.RESULT_OK, addressIntent);
intending(hasComponent(SearchAddressActivity.class.getName())).respondWith(result);
intending(hasComponent(SelectCityActivity.class.getName())).respondWith(result);
testInputText(R.id.et_retailer_name, "測試商戶");
Assert.assertTrue(!(((AuthBasicFragment) fragment).menuItem.isEnabled()));
testClick(R.id.tv_retailer_category_select);
sleep(1000);
testDialogClick(R.id.tv_completed);
Assert.assertTrue(!(((AuthBasicFragment) fragment).menuItem.isEnabled()));
testInputText(R.id.et_retailer_phone, "13600000000");
Assert.assertTrue(!(((AuthBasicFragment) fragment).menuItem.isEnabled()));
testClick(R.id.tv_retailer_poi_address);
Assert.assertTrue(((AuthBasicFragment) fragment).menuItem.isEnabled());
testClick(R.id.menu_auth);
fragment = getVisibleFragment();
Assert.assertTrue(fragment instanceof AuthCertificateFragment);
Assert.assertTrue(!((AuthCertificateFragment) fragment).menuItem.isEnabled());
shop = AppCookie.getShopInfo();
shop.setVerifyStatus(status);
AppCookie.saveShopInfo(shop);
}
}
一鍵運行測試Case
// 運行src/test/路徑下的Case
./gradlew testDebugUnitTest --continue
// 運行src/androidTest/路徑下的Case
./gradlew connectedDebugAndroidTest --continue
通過上述兩個命令就可以一鍵運行我們所有的測試Case,還可以把運行結果和覆蓋率都展示給我們看,是不是很方便呀!
結尾
通過兩個篇幅(Android單元測試實踐—邏輯測試和Android單元測試實踐—UI測試)的方式講述在Android環境下如何實踐單元測試,主要包括MVP代碼重構、代碼編寫時的注意點、純Java代碼測試以及Android代碼測試,希望對您有幫助,要是有什么不足的地方,歡迎大家指正!最后,感謝您對本博客的關注與支持!