欢迎参加我们将于 6 月 3 日举行的 #Android11:Beta 版发布会

Espresso 测试方案

本文档介绍如何设置各种常见的 Espresso 测试。

匹配另一个视图旁边的视图

布局可能会包含某些本身不唯一的视图。例如,联系人列表中的某个重复通话按钮可能会与视图层次结构中的其他通话按钮具有相同的 R.id、包含相同的文本,并且具有相同的属性。

例如,在下面的 Activity 中,包含文本 "7" 的视图在多行间重复:

一个列表 Activity,在一个包含 3 个项目的列表中显示了同一视图元素的 3 个副本

通常,非唯一视图会与位于其旁边的某个唯一标签配对,如通话按钮旁边的联系人的姓名。在这种情况下,您可以使用 hasSibling() 匹配器来缩小选择范围:

Kotlin

    onView(allOf(withText("7"), hasSibling(withText("item: 0"))))
        .perform(click())
    

Java

    onView(allOf(withText("7"), hasSibling(withText("item: 0"))))
        .perform(click());
    

匹配操作栏内的视图

ActionBarTestActivity 具有两个不同的操作栏:一个普通操作栏和一个根据选项菜单创建的上下文操作栏。这两个操作栏都有一个始终可见的项目和两个仅在溢出菜单中可见的项目。点击某个项目后,会使一个 TextView 变为所点击项目的内容。

匹配这两个操作栏上的可见图标非常简单,如以下代码段所示:

Kotlin

    fun testClickActionBarItem() {
        // We make sure the contextual action bar is hidden.
        onView(withId(R.id.hide_contextual_action_bar))
            .perform(click())

        // Click on the icon - we can find it by the r.Id.
        onView(withId(R.id.action_save))
            .perform(click())

        // Verify that we have really clicked on the icon
        // by checking the TextView content.
        onView(withId(R.id.text_action_bar_result))
            .check(matches(withText("Save")))
    }
    

Java

    public void testClickActionBarItem() {
        // We make sure the contextual action bar is hidden.
        onView(withId(R.id.hide_contextual_action_bar))
            .perform(click());

        // Click on the icon - we can find it by the r.Id.
        onView(withId(R.id.action_save))
            .perform(click());

        // Verify that we have really clicked on the icon
        // by checking the TextView content.
        onView(withId(R.id.text_action_bar_result))
            .check(matches(withText("Save")));
    }
    

保存按钮在 Activity 顶部的操作栏上

上下文操作栏的代码看起来完全相同:

Kotlin

    fun testClickActionModeItem() {
        // Make sure we show the contextual action bar.
        onView(withId(R.id.show_contextual_action_bar))
            .perform(click())

        // Click on the icon.
        onView((withId(R.id.action_lock)))
            .perform(click())

        // Verify that we have really clicked on the icon
        // by checking the TextView content.
        onView(withId(R.id.text_action_bar_result))
            .check(matches(withText("Lock")))
    }
    

Java

    public void testClickActionModeItem() {
        // Make sure we show the contextual action bar.
        onView(withId(R.id.show_contextual_action_bar))
            .perform(click());

        // Click on the icon.
        onView((withId(R.id.action_lock)))
            .perform(click());

        // Verify that we have really clicked on the icon
        // by checking the TextView content.
        onView(withId(R.id.text_action_bar_result))
            .check(matches(withText("Lock")));
    }
    

锁定按钮在 Activity 顶部的操作栏上

点击溢出菜单中的项目对于普通操作栏来说稍微复杂一些,因为某些设备具有硬件溢出菜单按钮,点击该按钮会在选项菜单中打开溢出项目,而某些设备具有软件溢出菜单按钮,点击该按钮会打开普通溢出菜单。不过请放心,Espresso 为我们解决了这一问题。

对于普通操作栏:

Kotlin

    fun testActionBarOverflow() {
        // Make sure we hide the contextual action bar.
        onView(withId(R.id.hide_contextual_action_bar))
            .perform(click())

        // Open the options menu OR open the overflow menu, depending on whether
        // the device has a hardware or software overflow menu button.
        openActionBarOverflowOrOptionsMenu(
                ApplicationProvider.getApplicationContext<Context>())

        // Click the item.
        onView(withText("World"))
            .perform(click())

        // Verify that we have really clicked on the icon by checking
        // the TextView content.
        onView(withId(R.id.text_action_bar_result))
            .check(matches(withText("World")))
    }
    

Java

    public void testActionBarOverflow() {
        // Make sure we hide the contextual action bar.
        onView(withId(R.id.hide_contextual_action_bar))
            .perform(click());

        // Open the options menu OR open the overflow menu, depending on whether
        // the device has a hardware or software overflow menu button.
        openActionBarOverflowOrOptionsMenu(
                ApplicationProvider.getApplicationContext());

        // Click the item.
        onView(withText("World"))
            .perform(click());

        // Verify that we have really clicked on the icon by checking
        // the TextView content.
        onView(withId(R.id.text_action_bar_result))
            .check(matches(withText("World")));
    }
    

溢出菜单按钮可见,并且一个列表显示在屏幕顶部附近的操作栏下方

在具有硬件溢出菜单按钮的设备上,如下所示:

没有溢出菜单按钮,并且一个列表显示在屏幕底部附近

对于上下文操作栏,同样非常简单:

Kotlin

    fun testActionModeOverflow() {
        // Show the contextual action bar.
        onView(withId(R.id.show_contextual_action_bar))
            .perform(click())

        // Open the overflow menu from contextual action mode.
        openContextualActionModeOverflowMenu()

        // Click on the item.
        onView(withText("Key"))
            .perform(click())

        // Verify that we have really clicked on the icon by
        // checking the TextView content.
        onView(withId(R.id.text_action_bar_result))
            .check(matches(withText("Key")))
        }
    }
    

Java

    public void testActionModeOverflow() {
        // Show the contextual action bar.
        onView(withId(R.id.show_contextual_action_bar))
            .perform(click());

        // Open the overflow menu from contextual action mode.
        openContextualActionModeOverflowMenu();

        // Click on the item.
        onView(withText("Key"))
            .perform(click());

        // Verify that we have really clicked on the icon by
        // checking the TextView content.
        onView(withId(R.id.text_action_bar_result))
            .check(matches(withText("Key")));
        }
    }
    

溢出菜单按钮显示在操作栏中,并且选项列表显示在屏幕顶部附近的操作栏下方

要查看这些示例的完整代码,请查看 GitHub 上的 ActionBarTest.java 示例。

断言不会显示视图

执行一系列操作后,您肯定想要断言被测界面的状态。有时,这可能是否定的情况,例如当不会发生某件事时。请注意,您可以使用 ViewAssertions.matches() 将任何 hamcrest 视图匹配器变成 ViewAssertion

在下面的示例中,我们采用 isDisplayed() 匹配器,并使用标准的 not() 匹配器将其反转:

Kotlin

    import androidx.test.espresso.Espresso.onView
    import androidx.test.espresso.assertion.ViewAssertions.matches
    import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
    import androidx.test.espresso.matcher.ViewMatchers.withId
    import org.hamcrest.Matchers.not

    onView(withId(R.id.bottom_left))
        .check(matches(not(isDisplayed())))
    

Java

    import static androidx.test.espresso.Espresso.onView;
    import static androidx.test.espresso.assertion.ViewAssertions.matches;
    import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
    import static androidx.test.espresso.matcher.ViewMatchers.withId;
    import static org.hamcrest.Matchers.not;

    onView(withId(R.id.bottom_left))
        .check(matches(not(isDisplayed())));
    

如果相应视图仍然是层次结构的一部分,则上述方法有效。如果不是,则会出现 NoMatchingViewException,您需要使用 ViewAssertions.doesNotExist()

断言视图不存在

如果相应视图从视图层次结构中消失(当某项操作导致转换到另一个 Activity 时,可能会发生这种情况),则应使用 ViewAssertions.doesNotExist()

Kotlin

    import androidx.test.espresso.Espresso.onView
    import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
    import androidx.test.espresso.matcher.ViewMatchers.withId

    onView(withId(R.id.bottom_left))
        .check(doesNotExist())
    

Java

    import static androidx.test.espresso.Espresso.onView;
    import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist;
    import static androidx.test.espresso.matcher.ViewMatchers.withId;

    onView(withId(R.id.bottom_left))
        .check(doesNotExist());
    

断言数据项不在适配器中

要证明特定的数据项不在 AdapterView 中,您的操作方法应该略有不同。我们必须查找相关的 AdapterView 并查询其持有的数据。我们不需要使用 onData(),而是使用 onView() 来查找 AdapterView,然后使用另一个匹配器来处理视图内的数据。

首先是匹配器:

Kotlin

    private fun withAdaptedData(dataMatcher: Matcher<Any>): Matcher<View> {
        return object : TypeSafeMatcher<View>() {

            override fun describeTo(description: Description) {
                description.appendText("with class name: ")
                dataMatcher.describeTo(description)
            }

            public override fun matchesSafely(view: View) : Boolean {
                if (view !is AdapterView<*>) {
                    return false
                }

                val adapter = view.adapter
                for (i in 0 until adapter.count) {
                    if (dataMatcher.matches(adapter.getItem(i))) {
                        return true
                    }
                }

                return false
            }
        }
    }
    

Java

    private static Matcher<View> withAdaptedData(final Matcher<Object> dataMatcher) {
        return new TypeSafeMatcher<View>() {

            @Override
            public void describeTo(Description description) {
                description.appendText("with class name: ");
                dataMatcher.describeTo(description);
            }

            @Override
            public boolean matchesSafely(View view) {
                if (!(view instanceof AdapterView)) {
                    return false;
                }

                @SuppressWarnings("rawtypes")
                Adapter adapter = ((AdapterView) view).getAdapter();
                for (int i = 0; i < adapter.getCount(); i++) {
                    if (dataMatcher.matches(adapter.getItem(i))) {
                        return true;
                    }
                }

                return false;
            }
        };
    }
    

然后,我们只需要使用 onView() 来查找 AdapterView

Kotlin

    fun testDataItemNotInAdapter() {
        onView(withId(R.id.list))
              .check(matches(not(withAdaptedData(withItemContent("item: 168")))))
        }
    }
    

Java

    @SuppressWarnings("unchecked")
    public void testDataItemNotInAdapter() {
        onView(withId(R.id.list))
              .check(matches(not(withAdaptedData(withItemContent("item: 168")))));
        }
    }
    

如果包含 ID 列表的适配器视图中存在等于“item: 168”的项目,我们的断言就会失败。

如需查看完整示例,请查看 GitHub 上的 AdapterViewTest.java 类中的 testDataItemNotInAdapter() 方法。

使用自定义故障处理程序

将 Espresso 中的默认 FailureHandler 替换为自定义故障处理程序后,可以进行其他或不同的错误处理,如截取屏幕截图或传递额外的调试信息。

CustomFailureHandlerTest 示例演示了如何实现自定义故障处理程序:

Kotlin

    private class CustomFailureHandler(targetContext: Context) : FailureHandler {
        private val delegate: FailureHandler

        init {
            delegate = DefaultFailureHandler(targetContext)
        }

        override fun handle(error: Throwable, viewMatcher: Matcher<View>) {
            try {
                delegate.handle(error, viewMatcher)
            } catch (e: NoMatchingViewException) {
                throw MySpecialException(e)
            }

        }
    }
    

Java

    private static class CustomFailureHandler implements FailureHandler {
        private final FailureHandler delegate;

        public CustomFailureHandler(Context targetContext) {
            delegate = new DefaultFailureHandler(targetContext);
        }

        @Override
        public void handle(Throwable error, Matcher<View> viewMatcher) {
            try {
                delegate.handle(error, viewMatcher);
            } catch (NoMatchingViewException e) {
                throw new MySpecialException(e);
            }
        }
    }
    

此故障处理程序抛出 MySpecialException 而不是 NoMatchingViewException,并将其他所有故障的处理委托给 DefaultFailureHandler。您可以在测试的 setUp() 方法中向 Espresso 注册 CustomFailureHandler

Kotlin

    @Throws(Exception::class)
    override fun setUp() {
        super.setUp()
        getActivity()
        setFailureHandler(CustomFailureHandler(
                ApplicationProvider.getApplicationContext<Context>()))
    }
    

Java

    @Override
    public void setUp() throws Exception {
        super.setUp();
        getActivity();
        setFailureHandler(new CustomFailureHandler(
                ApplicationProvider.getApplicationContext()));
    }
    

如需了解详情,请参阅 FailureHandler 接口和 Espresso.setFailureHandler()

以非默认窗口为目标

Android 支持多个窗口。通常,这对用户和应用开发者是透明的,但在某些情况下会显示多个窗口,例如在搜索微件中的主应用窗口上绘制了自动填充窗口时。为了简化操作,默认情况下,Espresso 使用启发法来猜测您想要与哪个 Window 进行交互。这种启发法几乎总是足够好;不过,在极少数情况下,您需要指定交互应以哪个窗口为目标。为此,您可以提供自己的根窗口匹配器或 Root 匹配器:

Kotlin

    onView(withText("South China Sea"))
        .inRoot(withDecorView(not(`is`(getActivity().getWindow().getDecorView()))))
        .perform(click())
    

Java

    onView(withText("South China Sea"))
        .inRoot(withDecorView(not(is(getActivity().getWindow().getDecorView()))))
        .perform(click());
    

ViewMatchers 一样,我们提供了一组预先提供的 RootMatchers。当然,您始终可以实现自己的 Matcher 对象。

请查看 GitHub 上的 MultipleWindowTest 示例

您可以使用 addHeaderView()addFooterView() 方法将页眉和页脚添加到 ListViews。为确保 Espresso.onData() 知道要匹配哪个数据对象,请务必将预设的数据对象值作为第二个参数传递给 addHeaderView()addFooterView()。例如:

Kotlin

    const val FOOTER = "FOOTER"
    ...
    val footerView = layoutInflater.inflate(R.layout.list_item, listView, false)
    footerView.findViewById<TextView>(R.id.item_content).text = "count:"
    footerView.findViewById<TextView>(R.id.item_size).text
            = data.size.toString
    listView.addFooterView(footerView, FOOTER, true)
    

Java

    public static final String FOOTER = "FOOTER";
    ...
    View footerView = layoutInflater.inflate(R.layout.list_item, listView, false);
    footerView.findViewById<TextView>(R.id.item_content).setText("count:");
    footerView.findViewById<TextView>(R.id.item_size).setText(String.valueOf(data.size()));
    listView.addFooterView(footerView, FOOTER, true);
    

然后,您可以为页脚编写一个匹配器:

Kotlin

    import org.hamcrest.Matchers.allOf
    import org.hamcrest.Matchers.instanceOf
    import org.hamcrest.Matchers.`is`

    fun isFooter(): Matcher<Any> {
        return allOf(`is`(instanceOf(String::class.java)),
                `is`(LongListActivity.FOOTER))
    }
    

Java

    import static org.hamcrest.Matchers.allOf;
    import static org.hamcrest.Matchers.instanceOf;
    import static org.hamcrest.Matchers.is;

    @SuppressWarnings("unchecked")
    public static Matcher<Object> isFooter() {
        return allOf(is(instanceOf(String.class)), is(LongListActivity.FOOTER));
    }
    

在测试中加载视图很简单:

Kotlin

    import androidx.test.espresso.Espresso.onData
    import androidx.test.espresso.action.ViewActions.click
    import androidx.test.espresso.sample.LongListMatchers.isFooter

    fun testClickFooter() {
        onData(isFooter())
            .perform(click())

        // ...
    }
    

Java

    import static androidx.test.espresso.Espresso.onData;
    import static androidx.test.espresso.action.ViewActions.click;
    import static androidx.test.espresso.sample.LongListMatchers.isFooter;

    public void testClickFooter() {
        onData(isFooter())
            .perform(click());

        // ...
    }
    

请查看 GitHub 上的 AdapterViewTest.javatestClickFooter() 方法中提供的完整代码示例。