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

前言

我們先回顧一下,在上一篇博客中,主要分享了Android單元測試的邏輯測試部分。接下來,我們重點講解Android單元測試的UI測試部分!

何為UI測試呢?就是對用戶界面的交互元素進行測試,如TextViewImageView,驗證其可見性,驗證圖片、文字是否顯示正確。不過實踐發現,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進行變換和結果驗證,其三者關系如圖所示:

image.png

異步方法測試

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單元測試中,我們勢必關心其填寫的合法性驗證和非法信息的顯示,以及在填寫合法信息后是否可以正確的進入下一步驟。

在這里,只要所有的基本信息合法填寫后,界面右上角的“下一步”按鈕就會高亮顯示且可以點擊進入下一步,這個按鈕默認是置灰的。我們就按照這種方式來驗證“下一步”按鈕是否可以高亮點擊。但這期間會遇到兩個問題:

  1. 當在“商戶品類”上點擊,就會觸發網絡請求去服務端拉取商戶品類列表,所以就必須把這個異步的網絡請求切換到當前線程來執行,具體方式可以參考上面,否則就遇到:測試代碼執行完畢,而這個網絡請求還沒有執行完。
  2. 在選擇“商戶地址”的時候,點擊會跳到地圖地位的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代碼測試,希望對您有幫助,要是有什么不足的地方,歡迎大家指正!最后,感謝您對本博客的關注與支持!

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容