[譯] 使用 Espresso 和 Mockito 測試 MVP

skyar2009發表於2019-03-04

使用 Espresso 和 Mockito 測試 MVP

作為軟體開發者,我們盡最大努力做正確的事情確保我們並非無能,並且讓其他同事以及領導信任我們所寫的程式碼。我們遵守最好的程式設計習慣、使用好的架構模式,但是有時發現要確切的測試我們所寫的程式碼很難。

就個人而言,我發現一些開源專案的開發者非常善於打造令人驚歎的產品(可以打造任何你可以想象的應用),但是由於某些原因缺乏編寫正確測試的能力,甚至一點都沒有。

本文是關於如何對廣泛應用的 MVP 架構模型進行單元測試的簡單教程。

在開始前需要解釋一下,本文假設你熟悉 MVP 模型並且之前使用過。本文不會介紹 MVP 模型,也不會介紹它的工作原理。同樣,需要提一下的是我使用了一個我喜歡的 MVP 庫 —— 由 Hannes Dorfman 編寫的 Mosby。為了方便起見,我使用了 view 繫結庫 ButterKnife

那麼這個應用究竟長什麼樣呢?

這是一個非常簡單的 Android 應用,它只做一件事:當點選按鈕時隱藏或者顯示一個 TextView。

這是應用起初的樣子:

[譯] 使用 Espresso 和 Mockito 測試 MVP
Initial

這是按鈕點選後的樣子:

[譯] 使用 Espresso 和 Mockito 測試 MVP
724E8fE.png

出於文章的需要,我們假設這是一個價值數百萬的產品,並且它現在的樣子將會持續很長時間。一旦發生變化,我們需要立刻知曉。

應用中有三部分內容:一個有應用名的藍色工具欄,一個顯示 “Hello World” 的 TextView,以及一個控制 TextView 顯隱的按鈕。

開始前需要做下說明,本文的所有程式碼都可以在我的 GitHub 找到;如果你不想閱讀後文,可以放心去直接閱讀原始碼。原始碼中的註釋十分明確。

我們開始吧!

Espresso 測試

我們首先對炫酷的 ToolBar 進行測試。畢竟是一個價值數百萬的應用,我們需要確保它的正確性。

如下是測試 ToolBar 的完整程式碼。如果你看不懂這到底是什麼鬼,也沒關係,後面我們一起過一下。

@RunWith (AndroidJUnit4.class)
public class MainActivityTest {

    @Rule
    public ActivityTestRule activityTestRule =
            new ActivityTestRule<>(MainActivity.class);

    @Test
    public void testToolbarDesign() {
        onView(withId(R.id.toolbar)).check(matches(isDisplayed()));

        onView(withText(R.string.app_name)).check(matches(withParent(withId(R.id.toolbar))));

        onView(withId(R.id.toolbar)).check(matches(withToolbarBackGroundColor()));
    }

    private Matcher<? super View> withToolbarBackGroundColor() {
        return new BoundedMatcher<View, View>(View.class) {
            @Override
            public boolean matchesSafely(View view) {
                final ColorDrawable buttonColor = (ColorDrawable) view.getBackground();

                return ContextCompat
                        .getColor(activityTestRule.getActivity(), R.color.colorPrimary) ==
                        buttonColor.getColor();
            }

            @Override
            public void describeTo(Description description) {
            }
        };
    }
}複製程式碼

首先,我們需要告訴 JUnit 所執行測試的型別。對應於第一行程式碼(@runwith (AndroidJUnit4.class))。它這樣宣告,“嘿,聽著,我將在真機上使用 JUnit4 進行 Android 測試”。

那麼 Android 測試到底是什麼呢?Android 測試是在 Android 裝置上而非電腦上的 Java 虛擬機器 (JVM) 的測試。這就意味著 Android 裝置需要連線到電腦以便執行測試。這就使得測試可以訪問 Android 框架功能性 API。

測試程式碼存放在 androidTest 目錄。

[譯] 使用 Espresso 和 Mockito 測試 MVP
android_test_directory

下面我們看一下 “ActivityTestRule”,如下 Android 文件做出了詳細的介紹:

“本規則針對單個 Activity 的功能性測試。測試的 Activity 會在 Test 註釋的測試以及 Before 註釋的方法執行之前啟動。會在測試完成以及 After 註釋的方法結束後停止。在測試期間可以直接對 Activity 進行操作。”

本質上是說,“這是我要測試的 Activity”。

下面我們具體看下 testToolBarDesign() 方法具體做了什麼。

測試 toolbar

onView(withId(R.id.toolbar)).check(matches(isDisplayed()));複製程式碼

這段測試程式碼是找到 ID 為 “R.id.toolbar” 的 view,然後檢查它的可見性。如果本行程式碼執行失敗,測試會立刻結束並不會進行其餘的測試。

onView(withText(R.string.app_name)).check(matches(withParent(withId(R.id.toolbar))));複製程式碼

這行是說,“嘿,讓我們看看是否有文字內容為 R.string.app_name 的 textView ,並且看看它的父 View 的 id 是否為 R.id.toolbar”。

最後一行的測試更有趣一些。它是要確認 toolbar 的背景色是否和應用的首要顏色一致。

onView(withId(R.id.toolbar)).check(matches(withToolbarBackGroundColor()));複製程式碼

Espresso 沒有提供直接的方式來做此校驗,因此我們需要建立 Matcher。Matcher 確切的說是我們前面使用的判斷 view 屬性是否與預期一致的工具。這裡,我們需要匹配首要顏色是否與 toolbar 背景一致。

我們需要建立一個 Matcher 並覆蓋 matchesSafely() 方法。該方法裡面的程式碼十分易懂。首先我們獲取 toolbar 背景色,然後與應用首要顏色對比。如果相等,返回 true 否則返回 false。

測試 TextView 的隱藏/顯示

在講程式碼之前,我需要說下程式碼有點長,但是十分易讀。我對程式碼內容作了詳細註釋。


@RunWith (AndroidJUnit4.class)
public class MainActivityTest {

    @Rule
    public ActivityTestRule activityTestRule =
            new ActivityTestRule<>(MainActivity.class);

    // ...

    @Test
    public void testHideShowTextView() {

        // Check the TextView is displayed with the right text
        onView(withId(R.id.tv_to_show_hide)).check(matches(isDisplayed()));
        onView(withId(R.id.tv_to_show_hide)).check(matches(withText("Hello World!")));

        // Check the button is displayed with the right initial text
        onView(withId(R.id.btn_change_visibility)).check(matches(isDisplayed()));
        onView(withId(R.id.btn_change_visibility)).check(matches(withText("Hide")));

        // Click on the button
        onView(withId(R.id.btn_change_visibility)).perform(click());

        // Check that the TextView is now hidden
        onView(withId(R.id.tv_to_show_hide)).check(matches(not(isDisplayed())));

        // Check that the button has the proper text
        onView(withId(R.id.btn_change_visibility)).check(matches(withText("Show")));

        // Click on the button
        onView(withId(R.id.btn_change_visibility)).perform(click());

        // Check the TextView is displayed again with the right text
        onView(withId(R.id.tv_to_show_hide)).check(matches(isDisplayed()));
        onView(withId(R.id.tv_to_show_hide)).check(matches(withText("Hello World!")));

        // Check that the button has the proper text
        onView(withId(R.id.btn_change_visibility)).check(matches(isDisplayed()));
        onView(withId(R.id.btn_change_visibility)).check(matches(withText("Hide")));
    }

    // ...
}複製程式碼

這段程式碼主要功能是保證應用開啟時,ID 為 “R.id.tv_to_show_hide” 的 TextView 處於顯示狀態,並且其顯示內容為 “Hello World!”

然後檢查按鈕也是顯示狀態,並且其文案(預設)顯示為 “Hide”。

接著點選按鈕。點選按鈕十分簡單,如何實現的也十分易懂。這裡我們對找到相應 ID 的 view 執行 .perform() (而非 “.check”),並且在其內執行 click() 方法。perform() 方法實際是執行傳入的操作。這裡對應是 click() 操作。

因為點選了 “Hide” 按鈕,我們需要驗證 TextView 是否真的隱藏了。具體做法是在 disDisplayed() 方法前置一個 “not()”,並且按鈕文案變為 “Show”。其實這就和 java 中的 “!=” 操作符一樣。



@RunWith (AndroidJUnit4.class)
public class MainActivityTest {
    // ...

    @Test
    public void testHideShowTextView() {

        // ...

        // Check that the TextView is now hidden
        onView(withId(R.id.tv_to_show_hide)).check(matches(not(isDisplayed())));

        // Check that the button has the proper text
        onView(withId(R.id.btn_change_visibility)).check(matches(withText("Show")));

        // ...
    }

    // ...
}複製程式碼

後面的程式碼是前面程式碼的反轉。再次點選按鈕,驗證 TextView 重新顯示,並且按鈕文案符合當前狀態。

就這些。

如下是全部的 UI 測試程式碼:


@RunWith (AndroidJUnit4.class)
public class MainActivityTest {

    @Rule
    public ActivityTestRule activityTestRule =
            new ActivityTestRule<>(MainActivity.class);

    @Test
    public void testToolbarDesign() {
        onView(withId(R.id.toolbar)).check(matches(isDisplayed()));

        onView(withText(R.string.app_name)).check(matches(withParent(withId(R.id.toolbar))));

        onView(withId(R.id.toolbar)).check(matches(withToolbarBackGroundColor()));
    }

    @Test
    public void testHideShowTextView() {

        // Check the TextView is displayed with the right text
        onView(withId(R.id.tv_to_show_hide)).check(matches(isDisplayed()));
        onView(withId(R.id.tv_to_show_hide)).check(matches(withText("Hello World!")));

        // Check the button is displayed with the right initial text
        onView(withId(R.id.btn_change_visibility)).check(matches(isDisplayed()));
        onView(withId(R.id.btn_change_visibility)).check(matches(withText("Hide")));

        // Click on the button
        onView(withId(R.id.btn_change_visibility)).perform(click());

        // Check that the TextView is now hidden
        onView(withId(R.id.tv_to_show_hide)).check(matches(not(isDisplayed())));

        // Check that the button has the proper text
        onView(withId(R.id.btn_change_visibility)).check(matches(withText("Show")));

        // Click on the button
        onView(withId(R.id.btn_change_visibility)).perform(click());

        // Check the TextView is displayed again with the right text
        onView(withId(R.id.tv_to_show_hide)).check(matches(isDisplayed()));
        onView(withId(R.id.tv_to_show_hide)).check(matches(withText("Hello World!")));

        // Check that the button has the proper text
        onView(withId(R.id.btn_change_visibility)).check(matches(isDisplayed()));
        onView(withId(R.id.btn_change_visibility)).check(matches(withText("Hide")));
    }

    private Matcher<? super View> withToolbarBackGroundColor() {
        return new BoundedMatcher<View, View>(View.class) {
            @Override
            public boolean matchesSafely(View view) {
                final ColorDrawable buttonColor = (ColorDrawable) view.getBackground();

                return ContextCompat
                        .getColor(activityTestRule.getActivity(), R.color.colorPrimary) ==
                        buttonColor.getColor();
            }

            @Override
            public void describeTo(Description description) {
            }
        };
    }
}複製程式碼

單元測試

單元測試最大特點是在本機的 JVM 環境上執行(與 Android 測試不同)。無需連線裝置,測試跑的也更快。缺點就是無法訪問 Android 框架 API。總之進行 UI 之外的測試時,儘量使用單元測試而非 Android/Instrumentation 測試。測試執行的越快越好。

下面我們看下單元測試的目錄。單元測試的位置與 Android 測試不同。

[譯] 使用 Espresso 和 Mockito 測試 MVP
different_location

開始前我們先看下 presenter 以及關於 model 需要考慮的問題。

首先看下 presenter


public class MainPresenterImpl extends MvpBasePresenter implements MainPresenter {

    @Override
    public void reverseViewVisibility(final View view) {
        if (view != null) {
            if (view.isShown()) {
                Utils.hideView(view);

                setButtonText("Show");
            } else {
                Utils.showView(view);

                setButtonText("Hide");
            }
        }
    }

    private void setButtonText(final String text) {
        if (isViewAttached()) {
            getView().setButtonText(text);
        }
    }
}複製程式碼

很簡單。兩個方法:一個檢查 view 是否可見。如果可見就隱藏它,反之顯示。之後將按鈕的文案改為 “Hide” 或 “Show”。

reverseViewVisibility() 方法呼叫 “model” 對傳入的 view 進行可見性設定。

下面看下 model

public final class Utils {

    // ...

    public static void showView(View view) {
        if (view != null) {
            view.setVisibility(View.VISIBLE);
        }
    }

    public static void hideView(View view) {
        if (view != null) {
            view.setVisibility(View.GONE);
        }
    }複製程式碼

兩個方法:showView(View) 和 hideView(View)。具體功能十分直觀。檢查 view 是否為 null,不為 null 則對其進行顯隱設定。

現在我們對 presenter 和 model 都有所瞭解了,下面我們開始測試。畢竟這是一個數百萬的產品,我們不能有任何錯誤。

我們首先測試 presenter。當使用 presenter (任何 presenter)時,我們需要確保 view 已與之關聯。注意:我們並不測試 view。我們只需要確保 view 的繫結以便確認是否在正確的時間呼叫了正確的 view 方法。記住,這很重要。

這裡我們使用 Mockito 進行測試,就像單元測試那樣,我們需要告訴 Android,“嘿,我們需要使用 MockitoJUnitRunner 進行測試。”實際操作時在測試類的頂部新增 @RunWith (MockitoJUnitRunner.class) 即可。

從前面可知我們需要兩個東西:一是模擬一個 View (因為 presenter 使用了 View 物件,對其進行顯隱控制),另外一個是 presenter。

下面展示瞭如何使用 Mockito 進行模擬

@RunWith (MockitoJUnitRunner.class)
public class MainPresenterImplTest {

    MainPresenterImpl presenter;

    @Before
    public void setUp() throws Exception {
        presenter = new MainPResenterImpl();
        presenter.attachView(Mockito.mock(MainView));
    }

    // ...
}複製程式碼

我們要寫的第一個測試是 “testReverseViewVisibilityFromVisibleToGone”。顧名思義,我們將要驗證的是,當可見的 View 被傳入 presenter 的 reverseViewVisibility() 方法時,presenter 能正確地設定 View 的可見性。

   @Test
    public void testReverseViewVisibilityFromVisibleToGone() throws Exception {
        final View view = Mockito.mock(View.class);
        when(view.isShown()).thenReturn(true);

        presenter.reverseViewVisibility(view);

        Mockito.verify(view, Mockito.atLeastOnce()).setVisibility(View.GONE);
        Mockito.verify(presenter.getView(), Mockito.atLeastOnce()).setButtonText(anyString());
    }複製程式碼

我們一起看下,這裡具體做了什麼?由於我們要測試的是 view 從可見到不可見的操作,我們需要 view 一開始是可見的,因此我們希望一開始呼叫 view 的 isShown() 方法返回是 true。接著,以模擬的 view 作為入參呼叫 presenter 的 reverseViewVisibility() 方法。現在我們需要確認 view 最近被呼叫的方法是 setVisibility(),並且設定為 GONE。然後,我們需要確認與 presenter 繫結的 view 的 setButtonText() 方法是否呼叫。並不難吧?

嗯,接著我們進行相反的測試。在繼續閱讀下面的程式碼之前,試著自己想一下怎麼做。如何測試從隱藏到顯示的情況?根據上面已知的資訊思考一下。

程式碼實現如下:

    @Test
    public void testReverseViewVisibilityFromGoneToVisible() throws Exception {
        final View view = Mockito.mock(View.class);
        when(view.isShown()).thenReturn(false);

        presenter.reverseViewVisibility(view);

        Mockito.verify(view, Mockito.atLeastOnce()).setVisibility(View.VISIBLE);
        Mockito.verify(presenter.getView(), Mockito.atLeastOnce()).setButtonText(anyString());
    }複製程式碼

接著測試 “Model”。和前面一樣,我們首先在類頂部新增註解 @RunWith (MockitoJUnitRunner.class) 。

@RunWith(MockitoJUnitRunner.class)

publicclassUtilsTest{

    // ...

}複製程式碼

如前面所說,Utils 類首先檢查 view 是否為 null。如果不為 null 將執行顯隱操作,反之什麼都不會做。

Utils 類的測試十分簡單,因此我不再逐行解釋,大家直接看程式碼即可。

@RunWith (MockitoJUnitRunner.class)
public class UtilsTest {

    @Test
    public void testShowView() throws Exception {
        final View view = Mockito.mock(View.class);

        Utils.showView(view);

        Mockito.verify(view).setVisibility(View.VISIBLE);
    }

    @Test
    public void testHideView() throws Exception {
        final View view = Mockito.mock(View.class);

        Utils.hideView(view);

        Mockito.verify(view).setVisibility(View.GONE);
    }

    @Test
    public void testShowViewWithNullView() throws Exception {
        Utils.showView(null);
    }

    @Test
    public void testHideViewWithNullView() throws Exception {
        Utils.hideView(null);
    }
}複製程式碼

我解釋下 testShowViewWithNullView() 和 testHideViewWithNullView() 方法的作用。為什麼要進行這些測試?試想下,我們不希望因為 view 為 null 時呼叫方法造成整個應用的崩潰。

我們看下 Utils 的 showView() 方法。如果不做 null 檢查,當 view 為 null 時應用會丟擲 NullPointerException 並崩潰。

public final class Utils {

    // ...

    public static void showView(View view) {
        if (view != null) {
            view.setVisibility(View.VISIBLE);
        }
    }

    // ...
}複製程式碼

另外一些情況下,我們需要應用丟擲一個異常。我們如何測試一個異常?十分簡單:只需要對 @Test 註解傳遞一個 expected 引數進行指定:

@RunWith (MockitoJUnitRunner.class)
public class UtilsTest {

    // ...

    @Test (expected = NullPointerException.class)
    public void testShowViewWithNullView() throws Exception {
        Utils.showView(null);
    }
}複製程式碼

如果沒有異常丟擲,該測試會失敗。

再次提示,你可以在 GitHub 獲取全部程式碼。

本文接近尾聲,需要提醒大家的是:測試並不總是像本例這樣簡單,但也不意味著不會如此或不該如此。作為開發者,我們需要確保應用正確的執行。我們需要確保大家信任我們的程式碼。我已經持續這樣做許多年了,你可能無法想象測試拯救了我多少次,甚至是像改變 view ID 這樣最簡單的事。

沒有人是完美的,但是測試讓我們趨近完美。保持編碼,保持測試,直到永遠!


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章