[译] 使用 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前端后端产品设计 等领域,想要查看更多优质译文请持续关注 掘金翻译计划

相关文章
相关标签/搜索