- 原文地址:TESTING MVP USING ESPRESSO AND MOCKITO
- 原文作者:Josias Sena
- 譯文出自:掘金翻譯計劃
- 譯者:skyar2009
- 校對者:lovexiaov, GangsterHyj
使用 Espresso 和 Mockito 測試 MVP
作為軟體開發者,我們盡最大努力做正確的事情確保我們並非無能,並且讓其他同事以及領導信任我們所寫的程式碼。我們遵守最好的程式設計習慣、使用好的架構模式,但是有時發現要確切的測試我們所寫的程式碼很難。
就個人而言,我發現一些開源專案的開發者非常善於打造令人驚歎的產品(可以打造任何你可以想象的應用),但是由於某些原因缺乏編寫正確測試的能力,甚至一點都沒有。
本文是關於如何對廣泛應用的 MVP 架構模型進行單元測試的簡單教程。
在開始前需要解釋一下,本文假設你熟悉 MVP 模型並且之前使用過。本文不會介紹 MVP 模型,也不會介紹它的工作原理。同樣,需要提一下的是我使用了一個我喜歡的 MVP 庫 —— 由 Hannes Dorfman 編寫的 Mosby。為了方便起見,我使用了 view 繫結庫 ButterKnife。
那麼這個應用究竟長什麼樣呢?
這是一個非常簡單的 Android 應用,它只做一件事:當點選按鈕時隱藏或者顯示一個 TextView。
這是應用起初的樣子:
這是按鈕點選後的樣子:
出於文章的需要,我們假設這是一個價值數百萬的產品,並且它現在的樣子將會持續很長時間。一旦發生變化,我們需要立刻知曉。
應用中有三部分內容:一個有應用名的藍色工具欄,一個顯示 “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 目錄。
下面我們看一下 “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 測試不同。
開始前我們先看下 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 這樣最簡單的事。
沒有人是完美的,但是測試讓我們趨近完美。保持編碼,保持測試,直到永遠!
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、React、前端、後端、產品、設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃。