前言
我們先回顧一下,在上一篇博客中,主要分享了Android單元測(cè)試的邏輯測(cè)試部分。接下來(lái),我們重點(diǎn)講解Android單元測(cè)試的UI測(cè)試部分!
何為UI測(cè)試呢?就是對(duì)用戶界面的交互元素進(jìn)行測(cè)試,如TextView、ImageView,驗(yàn)證其可見(jiàn)性,驗(yàn)證圖片、文字是否顯示正確。不過(guò)實(shí)踐發(fā)現(xiàn),UI測(cè)試有點(diǎn)像集成測(cè)試,View的狀態(tài)往往對(duì)數(shù)據(jù)(網(wǎng)絡(luò)數(shù)據(jù)、數(shù)據(jù)庫(kù)SQLite或者SharedPeferences)有依賴,這就要求我們要通過(guò)Mock的形式對(duì)數(shù)據(jù)依賴進(jìn)行隔離,不然就沒(méi)法測(cè)試驗(yàn)證。在時(shí)間不太允許的情況下,我個(gè)人覺(jué)得,UI測(cè)試覆蓋項(xiàng)目中核心或者關(guān)鍵的業(yè)務(wù)流程即可。依具體情況而定,界面本來(lái)就比較簡(jiǎn)單的話,感覺(jué)沒(méi)有必要單元覆蓋。
針對(duì)Android UI測(cè)試,主要使用谷歌推薦的測(cè)試框架Espresso,下面我們來(lái)著重介紹一下。
Espresso
根據(jù)谷歌官方介紹,Espresso最關(guān)鍵的優(yōu)勢(shì)就是它能夠檢測(cè)到主線程空閑狀態(tài)的時(shí)候,在適當(dāng)?shù)臅r(shí)候運(yùn)行測(cè)試代碼,這樣就沒(méi)必要通過(guò)Thread.sleep()去讓主線程睡眠的方式去同步測(cè)試。說(shuō)白了,就是Espresso框架在測(cè)試app時(shí),不會(huì)通過(guò)阻塞主線程去同步UI測(cè)試。
示例
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
簡(jiǎn)單解釋一下:通過(guò)onView()方法在界面上定位到R.id.my_view這個(gè)Button,然后通過(guò)perform(click())方式在這個(gè)Button上面觸發(fā)點(diǎn)擊事件,最后通過(guò)check(matches(isDisplayed()))來(lái)檢查這個(gè)Button是否還顯示著。就這么簡(jiǎn)單,通過(guò)這三個(gè)步驟就完成了UI的測(cè)試工作。
UI測(cè)試三部曲

Espresso有三個(gè)重要的類,分別是Matchers(匹配器),ViewAction(界面行為),ViewAssertions(界面判斷),其中Matchers是常常是通過(guò)匹配條件來(lái)需找UI組件或過(guò)濾UI,而ViewAction是來(lái)模擬用戶操作界面的行為,ViewAssertions對(duì)模擬行為操作的View進(jìn)行變換和結(jié)果驗(yàn)證,其三者關(guān)系如圖所示:

異步方法測(cè)試
Espresso官方文檔有這樣一段話:
Espresso測(cè)試有個(gè)很強(qiáng)大之處就是它在多個(gè)測(cè)試操作中是線程安全的,它會(huì)等待當(dāng)前進(jìn)程的消息隊(duì)列中的UI事件,并且在任何一個(gè)測(cè)試操作中會(huì)等待其中的AsyncTask結(jié)束才會(huì)執(zhí)行下一個(gè)測(cè)試。
也就是說(shuō),如果代碼中是通過(guò)AsyncTask或者AsyncTaskCompat方式來(lái)執(zhí)行異步任務(wù),并不需要去額外的處理,只需要等待Espresso處理,它會(huì)幫助我們執(zhí)行異步方法后,再執(zhí)行我們的測(cè)試代碼進(jìn)行斷言判斷。
但是項(xiàng)目中執(zhí)行異步可能不是通過(guò)AsyncTask方式,或許通過(guò)Volley、Retrofit、Thread。那么問(wèn)題來(lái)了,這樣的異步任務(wù)該如何測(cè)試?如果不處理的話,就會(huì)出現(xiàn)這樣的問(wèn)題,測(cè)試代碼執(zhí)行完畢了,但異步耗時(shí)任務(wù)可能還未執(zhí)行完,那么你想在異步任務(wù)執(zhí)行完后驗(yàn)證返回結(jié)果是否正確,好像就無(wú)能為力!就不賣關(guān)子了,現(xiàn)在我們就說(shuō)一下解決方法吧。
1.使用IdlingResource
看過(guò)Espresso源碼的同學(xué),應(yīng)該會(huì)知道,Espresso會(huì)等待AsyncTask和IdlingResource執(zhí)行完畢后才會(huì)執(zhí)行我們寫的測(cè)試代碼。這樣就只需要實(shí)現(xiàn)Espresso提供的IdlingResource接口,就可以實(shí)現(xiàn)測(cè)試異步方法。具體如何實(shí)踐,可以參考這個(gè)博客,但是你發(fā)現(xiàn)了沒(méi)有,這種方式對(duì)原有的代碼有侵入性。
2.使用AsyncTask提供的EXECUTOR
因?yàn)镋spresso會(huì)等待AsyncTask執(zhí)行完,所以我們只需要想辦法把異步線程執(zhí)行切換到AsyncTask所在的線程池執(zhí)行,就可以測(cè)試異步任務(wù)。
(1)、Executor
使用Executor線程池來(lái)執(zhí)行異步任務(wù),那么就很簡(jiǎn)單了,使用AsyncTask.THREAD_POOL_EXECUTOR代替項(xiàng)目中的Executor來(lái)執(zhí)行Runnable
(2)、RxJava
隨著RxJava越來(lái)越流行,正是其牛逼的切換線程的功能,通過(guò)以下方式就可以讓你很方便地測(cè)試RxJava異步任務(wù):
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);
}
});
其他異步線程的框架如果要測(cè)試,也可以通過(guò)以上兩種方式來(lái)解決。
Activity跳轉(zhuǎn)
很多時(shí)候,在界面中點(diǎn)擊一個(gè)View跳轉(zhuǎn)到另外的一個(gè)Activity,我們要如何驗(yàn)證這個(gè)View點(diǎn)擊之后是否跳轉(zhuǎn)到正確的Activity呢?Espresso為我們提供了Intented,這個(gè)是干嘛用的呢。要先加上build.gradle配置:
androidTestCompile ('com.android.support.test.espresso:espresso-intents:2.2.2') {
exclude group: 'com.google.code.findbugs'
}
Espresso-Intents記錄了在測(cè)試應(yīng)用時(shí)所有嘗試啟動(dòng)的Activity。使用intent的API,類似于Mockito.verify(),你可以斷言指定的intnet是否被接收。下面是一個(gè)簡(jiǎn)單的驗(yàn)證intent已經(jīng)發(fā)出的的例子:
@Test
public void validateIntentSentToPackage() {
//用戶的動(dòng)作,結(jié)果是啟動(dòng)一個(gè)外部的“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.
//通過(guò)封裝的RecordeIntentMatcher來(lái)驗(yàn)證作用于“phone” activity的intent已經(jīng)被發(fā)送
intended(toPackage("com.android.phone"));
}
使用intent API,類似于Mockito.when(),你可以為通過(guò)startActivityForResult()方法打開(kāi)的Activity mock設(shè)置一個(gè)返回結(jié)果Intent。對(duì)于無(wú)法操控用戶操作,也不能控制在測(cè)試環(huán)境下返回結(jié)果的activity尤其重要。下面來(lái)一個(gè)簡(jiǎn)單的例子:
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);
我來(lái)簡(jiǎn)單解釋一下這個(gè)例子,點(diǎn)擊R.id.progress_business_license_pic打開(kāi)SelectImageActivity來(lái)選擇圖片,如上面所說(shuō),模擬用戶選擇圖片的動(dòng)作,返回imageResult攜帶圖片地址Uri.parse("content://com.android.providers.media.documents/document/image%3A1575");
項(xiàng)目實(shí)踐
項(xiàng)目中有一個(gè)信息填寫的界面:

如上圖所示,這是一個(gè)很典型的用戶信息界面,涉及到商戶名稱、商戶品類、商戶電話和商戶地址等基本信息的填寫。在UI單元測(cè)試中,我們勢(shì)必關(guān)心其填寫的合法性驗(yàn)證和非法信息的顯示,以及在填寫合法信息后是否可以正確的進(jìn)入下一步驟。
在這里,只要所有的基本信息合法填寫后,界面右上角的“下一步”按鈕就會(huì)高亮顯示且可以點(diǎn)擊進(jìn)入下一步,這個(gè)按鈕默認(rèn)是置灰的。我們就按照這種方式來(lái)驗(yàn)證“下一步”按鈕是否可以高亮點(diǎn)擊。但這期間會(huì)遇到兩個(gè)問(wèn)題:
- 當(dāng)在“商戶品類”上點(diǎn)擊,就會(huì)觸發(fā)網(wǎng)絡(luò)請(qǐng)求去服務(wù)端拉取商戶品類列表,所以就必須把這個(gè)異步的網(wǎng)絡(luò)請(qǐng)求切換到當(dāng)前線程來(lái)執(zhí)行,具體方式可以參考上面,否則就遇到:測(cè)試代碼執(zhí)行完畢,而這個(gè)網(wǎng)絡(luò)請(qǐng)求還沒(méi)有執(zhí)行完。
- 在選擇“商戶地址”的時(shí)候,點(diǎn)擊會(huì)跳到地圖地位的
AddressActivity,然而我們希望隔離這種依賴,只針對(duì)當(dāng)前界面進(jìn)行測(cè)試,所以這個(gè)時(shí)候就需要intending幫我們解決這樣頭疼的問(wèn)題,具體方式可以參考上面。
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;
/**
* 模擬用戶的點(diǎn)擊行為
*
* @param id
*/
public void testClick(final int id) {
onView(withId(id)).perform(closeSoftKeyboard(), click());
}
/**
* 模擬用戶的點(diǎn)擊行為
*
* @param text
*/
public void testClick(String text) {
onView(withText(text)).perform(closeSoftKeyboard(), click());
}
/**
* 模擬用戶的點(diǎn)擊行為
*
* @param id
* @param text
*/
public void testClick(final int id, String text) {
onView(allOf(withId(id), withText(text))).perform(closeSoftKeyboard(), click());
}
/**
* 模擬用戶的點(diǎn)擊行為
*
* @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是否可見(jiàn)
*
* @param id
*/
public void testViewVisible(final int id) {
onView(withId(id))
.check(matches(isDisplayed()));
}
/**
* 檢查View是否可見(jiàn)
*
* @param text
*/
public void testViewVisible(String text) {
onView(withText(text))
.check(matches(isDisplayed()));
}
/**
* 檢查View是否可見(jiàn)
*
* @param id
* @param text
*/
public void testViewVisible(final int id, String text) {
onView(allOf(withId(id), withText(text)))
.check(matches(isDisplayed()));
}
/**
* 檢查View是否不可見(jiàn)
*
* @param id
*/
public void testViewUnVisible(final int id) {
onView(withId(id))
.check(matches(not(isDisplayed())));
}
/**
* 模擬用戶點(diǎn)擊Dialog
*
* @param id
*/
public void testDialogClick(int id) {
onView(withId(id)).inRoot(isDialog())
.check(matches(isDisplayed()))
.perform(click());
}
/**
* 模擬用戶點(diǎn)擊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;
}
/**
* 獲取可見(jiàn)的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之外可見(jiàn)的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是否可見(jiàn)
*
* @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);
}
/**
* 在執(zhí)行執(zhí)行測(cè)試代碼前,先登錄
*/
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);
}
});
// 在執(zhí)行測(cè)試前,執(zhí)行用戶登錄操作,防止對(duì)用戶信息有依賴
loginAccount();
}
@After
public void tearDown() {
// 清除用戶數(shù)據(jù)
OrderSet.getInstance().clearOrder();
OrderSet.getInstance().clearIgnoreOrder();
PreferenceUtil.clearAll();
OAuthManager.getInstance().clear();
}
}
測(cè)試代碼
@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, "上海市普陀區(qū)", "真北路788號(hào)", 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, "測(cè)試商戶");
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);
}
}
一鍵運(yùn)行測(cè)試Case
// 運(yùn)行src/test/路徑下的Case
./gradlew testDebugUnitTest --continue
// 運(yùn)行src/androidTest/路徑下的Case
./gradlew connectedDebugAndroidTest --continue
通過(guò)上述兩個(gè)命令就可以一鍵運(yùn)行我們所有的測(cè)試Case,還可以把運(yùn)行結(jié)果和覆蓋率都展示給我們看,是不是很方便呀!
結(jié)尾
通過(guò)兩個(gè)篇幅(Android單元測(cè)試實(shí)踐—邏輯測(cè)試和Android單元測(cè)試實(shí)踐—UI測(cè)試)的方式講述在Android環(huán)境下如何實(shí)踐單元測(cè)試,主要包括MVP代碼重構(gòu)、代碼編寫時(shí)的注意點(diǎn)、純Java代碼測(cè)試以及Android代碼測(cè)試,希望對(duì)您有幫助,要是有什么不足的地方,歡迎大家指正!最后,感謝您對(duì)本博客的關(guān)注與支持!