Android 單元測試和 UI 測試初步實踐

* 本篇文章已受權微信公衆號 guolin_blog(郭霖)獨家發佈

本文預計閱讀時間爲15-20分鐘

Android 測試簡介

對於大多數 Android 商業項目,基本都是處於高速迭代的開發階段,這個階段不只僅是對項目的開發效率,也對項目的產品質量提出了更高的要求。java

一般大型項目都是經過黑盒測試等方式來提供質量相關的保障,但同時筆者認爲也須要 Android 端的單元測試以及能自動在 Android 平臺上運行的 UI 測試,這幾種測試有如下幾個優點:android

  • 更早發現代碼中存在的 bug 等問題,提早 fix bug;
  • 更好地設計:在進行項目重構的時候,保證重構的新代碼能正確運行,這樣就能在業務不斷迭代的同時,更好地保障產品質量。

Android 測試代碼位置

在 Android Studio 中新建新的項目時,它已自動爲兩種測試類型建立了對應的代碼目錄:數據庫

  • 單元測試用例:位於 module-name/src/test/java 目錄下,只依賴 JVM 環境而不須要 Android 環境
  • InstrumentTest 測試/ UI 測試用例:位於 module-name/src/androidTest/java 目錄下,在 Android 環境下才能運行

接下來,筆者將嘗試爲本身的項目(基於 MVP 架構開發)補充相應的單元測試用例和 UI 測試用例,來初步實踐下如何在 Android 平臺編寫和運行相關的測試用例。微信

Android 單元測試實踐

建立新用例

若是須要編寫一個新的本地單元測試用例,只需打開你想測試的 java 代碼文件,而後點擊類名 -- ⇧⌘T(Windows:Ctrl+Shift+T)-- 選擇要生成的方法 -- 選擇 test 文件夾,對應於本地單元測試 -- 完成。網絡

增長依賴庫

須要 JUnit 和 Mockito 框架支持,因此在 build.gradle 中增長:架構

testImplementation "junit:junit:4.12"
testImplementation "org.mockito:mockito-core:2.7.1"
複製代碼

編寫測試代碼

通常來講,編寫一段測試代碼須要三個步驟:app

  • 環境初始化
  • 執行操做
  • 驗證結果正確性

筆者主要測試的是 MVP 架構中 P 層的代碼。在筆者的項目中,P 層是經過 Dagger2 機制,注入一個 DataManager,也就是數據獲取源。同時也須要一個 V 層的代理,這樣在 P 層經過數據源獲取數據以後,就能將數據交給 V 層,由 V 層去展現。框架

代碼調用大體邏輯以下:異步

mPresenter = new NewsPresenter(mDataManager);
mPresenter.getNews();
mPresenter.attach(mView);
--> mView.showProgress(); // 在數據未加載完前加載進度條
--> mView.showNews(news);
--> mView.hideProgress(); // 在數據加載完後隱藏進度條
複製代碼

對應着,實際編寫 P 層的單元測試用例的時候,並不須要一個真實的數據源,只須要經過 Mockito 框架,mock 出一個測試用的 DataManager 和 V 層代理。ide

對應着 Presenter 類,新建立的測試代碼以下:

/** * Created by Xu on 2019/04/05. * * @author Xu */
public class NewsPresenterTest {
    @ClassRule
    public static final RxImmediateSchedulerRule schedulers = new RxImmediateSchedulerRule();

    @Mock
    private NewsContract.View view;
    @Mock
    protected DataManager mMockDataManager;
    private NewsPresenter newsPresenter;
    
    @Before
    public void setUp() {
        MockitoAnnotations.initMocks(this);
        newsPresenter = new NewsPresenter(mMockDataManager);
        newsPresenter.attach(view);
    }
    
    @Test
    public void getNewsAndLoadIntoView() {
        TencentNewsResultBean resultBean = new TencentNewsResultBean();
        resultBean.setData(new ArrayList<>());
        when(mMockDataManager.getNews()).thenReturn(Flowable.just(resultBean));

        newsPresenter.getNews();

        // 測試model是否有獲取數據
        verify(mMockDataManager).getNews();

        // 測試view是否調用相應接口
        verify(view).showProgress();
        verify(view).showNews(anyList());
        verify(view).hideProgress();
    }

    @After
    public void tearDown() {
        newsPresenter.detach();
    }
}
複製代碼

在其中:

  1. 在代碼開頭,聲明瞭一個 @ClassRule;

什麼是 @ClassRule 呢?它跟 @Rule 註解幾乎相同,能夠在全部類方法開始前進行一些相關的初始化調用操做。使用這個註解,能夠在執行測試用例的時候加入特有的操做,而不影響原有用例代碼,有效減小耦合程度。

這裏主要是由於項目中使用了 RxJava2,而 RxJava 是須要 Android 環境支持的,若是直接運行 JUnit 測試用例會報錯,因此在此處增長了一個 @ClassRule,具體可參考 stackoverflow.com/questions/4…

/** * Created by Xu on 2019/04/05. * * @author Xu */
public class RxImmediateSchedulerRule implements TestRule {
    private Scheduler immediate = new Scheduler() {
        @Override
        public Disposable scheduleDirect(@NonNull Runnable run, long delay, @NonNull TimeUnit unit) {
            // this prevents StackOverflowErrors when scheduling with a delay
            return super.scheduleDirect(run, 0, unit);
        }

        @Override
        public Worker createWorker() {
            return new ExecutorScheduler.ExecutorWorker(Runnable::run);
        }
    };

    @Override
    public Statement apply(final Statement base, Description description) {
        return new Statement() {
            @Override
            public void evaluate() throws Throwable {
                RxJavaPlugins.setInitIoSchedulerHandler(scheduler -> immediate);
                RxJavaPlugins.setInitComputationSchedulerHandler(scheduler -> immediate);
                RxJavaPlugins.setInitNewThreadSchedulerHandler(scheduler -> immediate);
                RxJavaPlugins.setInitSingleSchedulerHandler(scheduler -> immediate);
                RxAndroidPlugins.setInitMainThreadSchedulerHandler(scheduler -> immediate);

                try {
                    base.evaluate();
                } finally {
                    RxJavaPlugins.reset();
                    RxAndroidPlugins.reset();
                }
            }
        };
    }
}
複製代碼
  1. 採用 Mockito 框架 mock 一個測試用的 DataManager 和 V 層代理 NewsContract.View。所謂的 mock 就是建立一個類的虛假的對象,在測試環境中,用來替換掉真實的對象,以達到驗證對象方法調用狀況,或是指定這個對象的某些方法返回特定的值等;
  2. @Before 註解的方法會在執行測試用例以前執行,這裏作一個初始化的操做,主要是 Mockito 框架的初始化及 presenter 的初始化;@After 註解的方法會在執行測試用例以後執行,這裏作一個 presenter 的 detach() 操做,防止出現內存泄露等問題;
  3. @Test 註解的方法是實際執行的測試方法。這裏根據以前的業務代碼邏輯:
  • 環境初始化:因爲 NewsPresenter 的業務邏輯中是須要 DataManager 返回一個 NewsResultBean 實例才能進行後續的操做,而 mock 的話只能返回一個空對象,因此在代碼前兩行筆者經過 Mockito 的 when() 方法,在程序調用 DataManager#getNews() 方法時返回一個空的 NewsResultBean 實例。
  • 執行操做:執行 P 層的 NewsPresenter#getNews()。在業務邏輯中,執行此方法以後,會先調用 DataManager#getNews(),而後將數據交給 V 層的代理。
  • 驗證結果正確性:通常來講,咱們要驗證一個方法執行結果是否正確,最簡單的方法的就是看執行完的方法輸出是否與預期輸出相一致。但在這裏,NewsPresenter#getNews() 爲一個 void 方法,沒有返回值,那麼該怎麼驗證呢?其實這個方法也是有輸出的,輸出就是:調用了 DataManager#getNews() 方法,獲取到數據後調用 NewsContract.View#showNews(news) 顯示數據。因此這裏主要驗證的是 DataManager#getNews() 和 NewsContract.View#showProgress(),NewsContract.View#showNews(news) 和 NewsContract.View#hideProgress() 這三個方法是否有被調用到,這裏運用到 Mockito 的 verify() 方法。

至此,一個 Android 的單元測試用例編寫完成。經過 Android Studio 直接運行此單元測試用例,結果以下:

須要明白一個點:單元測試它只是測試一個方法單元,它不是測試一整個 APP 的功能流程,即單元測試不會涉及到數據庫或網絡等複雜的外部環境。好比說這裏咱們只測試到 NewsPresenter#getNews() 方法,並無測試 NewsFragment 的整個初始化到顯示的過程是否正常,數據是否有誤。(這樣的測試每每稱之爲集成測試)

Android UI 測試實踐

建立新用例

若是要編寫一個新的本地 UI 測試用例,只需打開你想測試的 java 代碼文件,而後點擊類名 -- ⇧⌘T(Windows:Ctrl+Shift+T)-- 選擇要生成的方法 -- 選擇 androidTest 文件夾,對應於本地 UI 測試 -- 完成。

增長依賴庫

須要 Espresso 框架支持,因此在 build.gradle 中增長(注意是 androidTestImplementation):

androidTestImplementation "androidx.test:runner:1.1.0"
androidTestImplementation "androidx.test:rules:1.1.0"
androidTestImplementation "androidx.test.espresso:espresso-core:3.0.2"
androidTestImplementation "androidx.test.espresso:espresso-contrib:3.0.2"
androidTestImplementation "androidx.test.espresso:espresso-intents:3.0.2"
androidTestImplementation "androidx.test.espresso.idling:idling-concurrent:3.0.2"
androidTestImplementation "androidx.test.espresso:espresso-idling-resource:3.0.2"
複製代碼

編寫測試代碼

筆者主要測試的代碼爲 NewsDetailActivity,主要功能是加載 intent 傳遞過來的新聞標題和新聞原文地址,而後在 Toolbar 中顯示新聞標題,在 Webview 中加載此新聞。

對應着,實際編寫測試代碼的時候,能夠構造一個測試用的 intent,在 intent 中加入須要的測試數據,而後啓動這個 activity,檢查數據是否正確便可。這裏咱們藉助 Espresso 框架,它有三個重要的組成部分:ViewMatchers(根據視圖 id 或其餘屬性匹配指定的 View),ViewActions(執行 View 的某些行爲,例如點擊事件),ViewAssertions(檢查 View 的某些狀態,例如指定 View 是否顯示在屏幕上)。

新建立的 UI 測試代碼以下:

/** * Created by Xu on 2019/04/09. */
@RunWith(AndroidJUnit4.class)
@LargeTest
public class NewsDetailActivityTest {

    @Rule
    public ActivityTestRule<NewsDetailActivity> newsDetailActivityActivityTestRule =
            new ActivityTestRule<>(NewsDetailActivity.class, true, false);

    @Before
    public void setUp() {
        Intent intent = new Intent(InstrumentationRegistry.getInstrumentation().getTargetContext(), NewsDetailActivity.class);
        intent.putExtra(Constants.NEWS_URL, TestConstants.NEWS_DETAIL_ACTIVITY_TEST_URL);
        intent.putExtra(Constants.NEWS_IMG, TestConstants.NEWS_DETAIL_ACTIVITY_TEST_IMG);
        intent.putExtra(Constants.NEWS_TITLE, TestConstants.NEWS_DETAIL_ACTIVITY_TEST_TITLE);
        newsDetailActivityActivityTestRule.launchActivity(intent);
        IdlingRegistry.getInstance().register(newsDetailActivityActivityTestRule.getActivity().getCountingIdlingResource());
    }

    @Test
    public void showNewsDetail() {
        onView(withId(R.id.toolbar)).check(matches(isDisplayed()));
        onView(withId(R.id.iv_news_detail_pic)).check(matches(isDisplayed()));
        onView(withId(R.id.clp_toolbar)).check(matches(isDisplayed()));
        onView(withId(R.id.clp_toolbar)).check(matches(withCollapsingToolbarLayoutText(is(TestConstants.NEWS_DETAIL_ACTIVITY_TEST_TITLE))));
    }

    @After
    public void tearDown() {
        IdlingRegistry.getInstance().unregister(newsDetailActivityActivityTestRule.getActivity().getCountingIdlingResource());
    }
}
複製代碼

在其中:

  1. 在類聲明的開頭,添加了兩個註解 @RunWith(AndroidJUnit4.class) 和 @LargeTest;

@RunWith 註解能夠改變 JUnit 測試用例的的默認執行類,因爲這裏是須要 Android 環境且使用到 Espresso 框架,因此 @RunWith 選擇 AndroidJUnit4 類。@LargeTest 表示此測試用例會使用到外部文件系統或者網絡,而且運行時間大於 1000 ms。

  1. 聲明瞭一個變量 newsDetailActivityActivityTestRule 並用 @Rule 註解,newsDetailActivityActivityTestRule 是 ActivityTestRule 的實例化對象。ActivityTestRule 主要用來測試單個 Activity,這個 Activity 將在 @Test 和 @Before 前啓動。它其中包含一些基礎功能,例如啓動 Activity,獲取當前 Activity 實例等;
  2. 一樣的,這裏 @Before 註解的方法會在執行測試用例以前執行,這裏構造一個測試用 intent,最後經過 newsDetailActivityActivityTestRule#launchActivity(intent) 方法啓動待測試 Activity,並作一個 IdlingResource 的綁定;@After 註解的方法會在執行測試用例以後執行,這裏作一個 IdlingResource 的解綁操做;

什麼是 IdlingResource 呢?

一般來講,大多數 APP 在設計業務功能的過程當中,會有不少的異步任務,例如使用 Rxjava 發起網絡請求等,可是 Espresso 並不知道你的異步任務何時結束,若是單純使用 Thread.sleep() 等待異步回調的結果又過於「硬核」,因此須要藉助於 IdlingResource 這個類。

它須要在業務代碼中添加相關的邏輯。例如在 NewsDetailActivity 中,會接收到 intent 傳遞過來的新聞圖片地址,而後使用 Glide 異步加載此圖片,大體代碼以下:

public class NewsDetailActivity extends AppCompatActivity {

    @BindView(R.id.iv_news_detail_pic)
    private ImageView ivNewsDetailPic;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_news);
        // 省略部分代碼邏輯
        
        // 開始發起異步操做,App開始進入忙碌狀態
        EspressoIdlingResource.increment();
        
        // 開始加載圖片
        Glide.with(context).asBitmap().load(imgUrl).into(new GlideDrawableImageViewTarget(mAvatar) {
            @Override
            public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition<? super Bitmap> transition) {
                super.onResourceReady(resource, transition);
                // 異步操做結束,將App設置成空閒狀態
                if (!EspressoIdlingResource.getIdlingResource().isIdleNow()) {
                    EspressoIdlingResource.decrement();
                }
            }
        });
    }
    
    // 省略代碼
    
    @VisibleForTesting
    public IdlingResource getCountingIdlingResource() {
        return EspressoIdlingResource.getIdlingResource();
    }
}

public class EspressoIdlingResource {
    private static final String RESOURCE = "GLOBAL";

    // Espresso 提供了一個實現好的 CountingIdlingResource 類
    // 若是沒有特別需求的話,直接使用它便可
    private static CountingIdlingResource countingIdlingResource = new CountingIdlingResource(RESOURCE);

    public static void increment() {
        countingIdlingResource.increment();
    }

    public static void decrement() {
        countingIdlingResource.decrement();
    }

    public static IdlingResource getIdlingResource() {
        if (countingIdlingResource == null) {
            countingIdlingResource = new CountingIdlingResource(RESOURCE);
        }
        return countingIdlingResource;
    }

}
複製代碼

再加上咱們在測試代碼中聲明的 IdlingRegistry.getInstance().register() 和 IdlingRegistry.getInstance().unregister() 方法,根據 APP 是否處於忙碌狀態來判斷異步任務是否完成,這樣 Espresso 就能作到對異步任務進行相應的測試。

  1. @Test 註解的方法是實際執行的測試方法。這裏根據以前的業務代碼邏輯:
  • 環境初始化:模擬了測試的 intent 數據
  • 執行操做:加載 intent 傳遞過來的數據
  • 驗證結果正確性:檢查對應的 UI 樣式是否正常顯示測試數據,這裏主要利用 Espresso 的 幾個重要的 API:
    • onView():得到視圖 view,這裏經過 withId() 方法搜索,即根據 id 來獲取對應的 view
    • check():檢驗視圖 view,能夠檢查視圖文本是否匹配或者視圖是否顯示等,主要依靠 match() 方法返回對應的匹配類,Espresso 也自帶不少已封裝好的 View Matchers 供使用

以鏈式代碼的形式編寫驗證測試結果的代碼,例如 onView(withId(R.id.toolbar)).check(matches(isDisplayed())); 意思就是獲取 id 爲 R.id.toolbar 的 view,檢查這個 view 是否正常顯示。

若是 Espresso 自帶的 View Matchers 不能知足需求的話,咱們也能夠自定義一個 matcher,例如 onView(withId(R.id.clp_toolbar)).check(matches(withCollapsingToolbarLayoutText(is(TestConstants.NEWS_DETAIL_ACTIVITY_TEST_TITLE)))); ,咱們獲取到的 view 是一個 CollapsingToolbarLayout,是一個特殊樣式的 Toolbar,咱們要檢查其中的標題是否與測試數據相匹配,咱們能夠編寫自定義的 Matcher:

public static Matcher<View> withCollapsingToolbarLayoutText(Matcher<String> stringMatcher) {
    return new BoundedMatcher<View, CollapsingToolbarLayout>(CollapsingToolbarLayout.class) {
        @Override
        public void describeTo(Description description) {
            description.appendText("with CollapsingToolbarLayout title: ");
            stringMatcher.describeTo(description);
        }

        @Override
        protected boolean matchesSafely(CollapsingToolbarLayout collapsingToolbarLayout) {
            return stringMatcher.matches(collapsingToolbarLayout.getTitle());
        }
    };
}
複製代碼

這裏傳入一個 String 類型的匹配器(經過 is() 方法返回),返回一個 CollapsingToolbarLayout title 的 Matcher。

至此,一個 Android 的 UI 測試用例編寫完成。經過 Android Studio 直接運行此用例,結果以下:

總結

本文主要從測試的兩個不一樣粒度:單元測試和 UI 測試入手,綜合參考 Google Sample 項目中的測試代碼,作一個初步實踐,分析編寫並運行相關的測試用例。

筆者認爲編寫 Android 的測試用例的大體流程以下:

  1. 肯定須要編寫的測試用例粒度;
  2. 分析針對須要測試的頁面,提取出較爲重要且簡短的業務代碼邏輯;
  3. 根據這些邏輯,經過三步走(初始化--執行--驗證)方法來設計測試用例,這裏的業務邏輯不只僅是指業務需求,還包括其餘須要維護的業務或公共代碼邏輯;
  4. 在作單元測試時,我的認爲測試的業務邏輯不須要跨不少頁面,在當前頁面執行便可,以避免增長單元測試用例的維護成本;
  5. 單元測試用例並不能直接提高代碼質量,但可以在進行項目重構的時候,保證重構的新代碼能正確運行,下降風險。

這篇文章會同步到個人我的日誌,若有問題,請你們踊躍提出,謝謝你們!

相關文章
相關標籤/搜索