Android單元測(cè)試—UI測(cè)試(Espresso)

前言

我們先回顧一下,在上一篇博客中,主要分享了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)系如圖所示:

image.png

異步方法測(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è)信息填寫的界面:

項(xiàng)目例子

如上圖所示,這是一個(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)題:

  1. 當(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í)行完。
  2. 在選擇“商戶地址”的時(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)注與支持!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容