Espresso 高级示例
声明:本系列文章是对 Android Testing Support Library官方文档的翻译,水平有限,欢迎批评指正。
视图匹配器
匹配紧靠一个视图的另一个视图
一个布局可以包含多个本身并不唯一的视图(如联系人列表中的重播按钮,它们在视图结构中拥有相同的 R.id 值,相同的文字和属性)。
比如,在此 activity 中,包含文字“7”的视图在多行中重复出现:
此类非唯一的视图通常会与紧靠它的带有唯一标签的视图配对(如拨号按钮旁的姓名)。这种情况下,你可以使用 hasSibling 匹配器缩小选择范围:
onView(allOf(withText("7"), hasSibling(withText("item 0")))).perform(click());
用 onData 和自定义 ViewMatcher 匹配数据
如下 activity 包含一个 ListView,它基于一个为每一行提供一个 Map<String, Object>
类型的 SimpleAdapter
。每个 map 都一个键为 “STR” ,的元素(值为 String 类型,“item: x”)和一个键为 “LEN” 的元素(值为 Integer 类型,字符串的长度)。
点击 “item: 50” 条目的代码如下:
onData(allOf(is(instanceOf(Map.class)), hasEntry(equalTo("STR"), is("item: 50))).perform(click());
我们先来看 onData
的一部分:
is(instanceOf(Map.class))
限制搜索 AdapterView 中任意条目的条件为一个 Map。
在此例子中,ListView 的所有行都满足条件。但我们想要点击指定的条目 “item: 50”,所以我们需要继续缩小范围:
hasEntry(equalTo("STR"), is("item: 50))
这个 Matcher\
return new BoundedMatcher<Object, Map>(Map.class) {
@Override
public boolean matchesSafely(Map map) {
return hasEntry(equalTo("STR"), itemTextMatcher).matches(map);
}
@Override
public void describeTo(Description description) {
description.appendText("with item content: ");
itemTextMatcher.describeTo(description);
}
};
}
我们使用 BoundedMatcher
作为基类,因为我们希望能够只匹配 Map 类型的对象。我们覆写了 matchesSafely 方法,将之前创建的匹配器带入,将其通过 Matcher<String>
传入进行匹配。此方式将允许我们使用 withItemContent(equalTo(“foo”))
。为了代码简洁性,我们创建了另一个已经做了 equalTo 操作并接收一个 String 的匹配器:
public static Matcher<Object> withItemContent(String expectedText) {
checkNotNull(expectedText);
return withItemContent(equalTo(expectedText));
}
现在点击该条目的代码很简单了:
onData(withItemContent("item: 50")).perform(click());
此测试的完整代码请查看 AdapterViewText#testClickOnItem50 和自定义匹配器。
匹配一个视图的指定子视图
上述示例陈述了在 ListView 中点击一行的中间位置。但我们该如何操作一行中指定的子视图?例如,我们我们想要点击 LongListActivity 其中一行的第二列,该列展示第一列的 String.lenth
(具象而言,你可以想象一下 G+ 应用中的评论,在每一条评论旁有一个 +1 按钮):
在 DataInteraction
中添加一个 onChildView
说明:
onData(withItemContent("item: 60"))
.onChildView(withId(R.id.item_size))
.perform(click());
注意:此示例使用了上例中的 withItemContent
匹配器!参考 ApdaterViewTest#testClickOnSpecificChildOfRow60!
匹配 ListView 的 footer/header 视图
header 和 footer 通过 addHeaderView/addFooterView API 添加到 ListView 中。为了能够使用 Espresso.onData 加载它们,确保使用预置的值来设置数据对象(第二个参数)。
public static final String FOOTER = "FOOTER";
...
View footerView = layoutInflater.inflate(R.layout.list_item, listView, false);
((TextView) footerView.findViewById(R.id.item_content)).setText("count:");
((TextView) footerView.findViewById(R.id.item_size)).setText(String.valueOf(data.size()));
listView.addFooterView(footerView, FOOTER, true);
然后,你可以写一个匹配器来匹配此对象:
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));
}
在测试中很轻易就能加载该视图:
import static com.google.android.apps.common.testing.ui.espresso.Espresso.onData;
import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click;
import static com.google.android.apps.common.testing.ui.espresso.sample.LongListMatchers.isFooter;
public void testClickFooter() {
onData(isFooter())
.perform(click());
...
}
请在 AdapterViewtest#testClickFooter 中查看完整示例代码。
匹配 ActionBar 中的视图
ActionBarActivity
有两个不同的模式:普通的 ActionBar 和从选项菜单中创建的上下文 ActionBar。它们都有一个条目是一直可见的,而另外两个只在悬浮菜单中课件。当点击一个条目时,它会将一个 TextView 的内容改为点击条目的内容。
匹配两种 ActionBar 中的可见图标都很简单:
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")));
}
针对上下文 ActionBar 的代码与之类似:
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")));
}
点击普通 ActionBar 悬浮菜单中的条目有点棘手,由于某些设备带有硬件悬浮菜单按钮(它们会打开选项菜单的悬浮条目),而某些设备带有软件悬浮菜单按钮(它们会打开一个普通悬浮菜单)。幸运的是,Espresso 为我们解决了此问题。
对于普通的 ActionBar:
public void testActionBarOverflow() {
// Make sure we hide the contextual action bar.
onView(withId(R.id.hide_contextual_action_bar))
.perform(click());
// Open the overflow menu OR open the options menu,
// depending on if the device has a hardware or software overflow menu button.
openActionBarOverflowOrOptionsMenu(getInstrumentation().getTargetContext());
// 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")));
}
如下是在带硬件悬浮菜单按钮设备的显示:
对于上下文 ActionBar 而言也很简单:
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")));
}
查看完整示例代码: ActionBarTest.java。
ViewAssertion
断言视图没有显示
执行过一系列操作之后,你会想要断言待测 UI 的状态。有时可是会是一个负面情况(例如,有些操作没有成功)。请记住,你可以通过 ViewAssertions.matches 将任意的 hamcrest 视图匹配器转换为一个 ViewAssertion。
在下面的例子中,我们使用了 isDisplayed 匹配器,并使用标准的 “not” 匹配器反转匹配结果:
import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView;
import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches;
import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed;
import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId;
import static org.hamcrest.Matchers.not;
onView(withId(R.id.bottom_left))
.check(matches(not(isDisplayed())));
如果视图仍然是整个结构的一部分,上述方式可用。否则,你将得到一个 NoMatchingViewException
,此时你需要使用 ViewAssertions.doesNotExit
(见下面)。
断言一个视图不存在
如果视图不在视图结构中(如,界面切换到了另一个 activity),你应该使用 ViewAssertions.doesNotExist
:
import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView;
import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.doesNotExist;
import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId;
onView(withId(R.id.bottom_left))
.check(doesNotExist());
断言一个数据条目不在指定适配器中
为了证明一个指定的数据条目不在指定的 AdapterView 中,你需要做些其他操作。我们需要找到指定的 AdapterView 并得到它持有的数据。此处不需要使用 onData(),而是使用 onView 查找 AdapterView,然后使用另一个匹配器处理该视图中的数据。
首先创建匹配器:
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:
@SuppressWarnings("unchecked")
public void testDataItemNotInAdapter(){
onView(withId(R.id.list))
.check(matches(not(withAdaptedData(withItemContent("item: 168")))));
}
如果 id 为 list 的 AdapterView 中有一个条目与 “item: 168” 相同,则断言失败。
参考网址示例代码:AdapterViewTest#testDataItemNotInAdapter。
闲置资源
使用 registerIdlingResource 与自定义资源同步
Espresso 的核心是它可以与待测应用无缝同步测试操作的能力。默认情况下,Espresso 会等待当前消息队列中的 UI 事件执行(默认是 AsyncTask)完毕再进行下一个测试操作。这应该能解决大部分应用与测试同步的问题。
然而,应用中有一些执行后台操作的对象(比如与网络服务交互)通过非标准方式实现;例如:直接创建和管理线程,以及使用自定义服务。
此种情况,我们建议你首先提出可测试性的概念,然后询问使用非标准后台操作是否必要。某些情况下,可能是由于对 Android 理解太少造成的,并且应用也会受益于重构(例如,将自定义创建的线程改为 AsyncTask)。然而,某些时候重构并不现实。庆幸的是 Espresso 仍然可以同步测试操作与你的自定义资源。
以下是我们需要完成的:
- 实现
IdlingResource
接口并暴露给测试。 - 通过在 setUp 中调用
Espresso.registerIdlingResource
注册一个或多个 IdlingResource 给 Espresso。
参考 AdvancedSynchronizationTest 和 CountingIdlingResource 类以了解 IdlingResource 如何能使用。
需要注意的是 IdlingResource 接口是在待测应用中实现的,所以你需要谨慎的添加依赖:
// IdlingResource is used in the app under test
compile 'com.android.support.test.espresso:espresso-idling-resource:2.2.2'
// For CountingIdlingResource:
compile 'com.android.support.test.espresso:espresso-contrib:2.2.2'
定制
使用自定义失败处理器
使用一个自定义的失败处理器来替换 Espresso 默认的 FailureHandler 以允许增强(或区分)错误的处理。比如:截图或转储特别的调试信息。
示例 CustomFailureHandlerTest 演示了如何实现一个自定义的失败处理器:
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。CustomFailureHandler 可以在 Espresso 测试的 setUp() 中注册:
@Override
public void setUp() throws Exception {
super.setUp();
getActivity();
setFailureHandler(new CustomFailureHandler(getInstrumentation().getTargetContext()));
}
参考 FailureHandler 接口和 Espresso.setFailureHandler 获取更多信息。
inRoot
使用 inRoot 来指定非默认窗口
很惊奇吧,但这是真的—— Android 支持多窗口。通常这对于用户和 Android 开发者来说是透明的(transparent 双关意),在某些情况下多窗口是可见的(例如:在搜索控件中自动补全窗口绘制在主窗口之上)。为了方便,Espresso 默认使用一个启发模式来推测你想要与哪个窗口交互。你可以通过自己提供根窗口(亦称为 Root 匹配器)来决定要与哪个窗口交互。
onView(withText("South China Sea"))
.inRoot(withDecorView(not(is(getActivity().getWindow().getDecorView()))))
.perform(click());
和 ViewMatchers 的情况类似,我们提供了一组封装好的 RootMatchers。当然,你依然可以实现自己的匹配器。
更多信息请查看 示例 或者 GitHub 上的示例。