[譯] 使用 Espresso 和 Mockito 測試 MVP

使用 Espresso 和 Mockito 測試 MVP

做爲軟件開發者,咱們盡最大努力作正確的事情確保咱們並不是無能,而且讓其餘同事以及領導信任咱們所寫的代碼。咱們遵照最好的編程習慣、使用好的架構模式,可是有時發現要確切的測試咱們所寫的代碼很難。html

就我的而言,我發現一些開源項目的開發者很是善於打造使人驚歎的產品(能夠打造任何你能夠想象的應用),可是因爲某些緣由缺少編寫正確測試的能力,甚至一點都沒有。前端

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

在開始前須要解釋一下,本文假設你熟悉 MVP 模型而且以前使用過。本文不會介紹 MVP 模型,也不會介紹它的工做原理。一樣,須要提一下的是我使用了一個我喜歡的 MVP 庫 —— 由 Hannes Dorfman 編寫的 Mosby。爲了方便起見,我使用了 view 綁定庫 ButterKnifereact

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

這是一個很是簡單的 Android 應用,它只作一件事:當點擊按鈕時隱藏或者顯示一個 TextView。ios

這是應用起初的樣子:git

Initial

這是按鈕點擊後的樣子:github

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 目錄。

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 測試不一樣。

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前端後端產品設計 等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃

相關文章
相關標籤/搜索