聲明:本系列文章是對(duì) Android Testing Support Library官方文檔的翻譯,水平有限,歡迎批評(píng)指正。
1. Espresso 概覽
2. Espresso 設(shè)置說明
3. Espresso 基礎(chǔ)
4. Espresso 備忘錄
5. Espresso 意圖
6. Espresso 高級(jí)示例
7. Espresso Web
8. AndroidJUnitRunner
9. ATSL 中的 JUnit4 規(guī)則
10. UI Automator
11. 可訪問性檢查
視圖匹配器
匹配緊靠一個(gè)視圖的另一個(gè)視圖
一個(gè)布局可以包含多個(gè)本身并不唯一的視圖(如聯(lián)系人列表中的重播按鈕,它們?cè)谝晥D結(jié)構(gòu)中擁有相同的 R.id 值,相同的文字和屬性)。
比如,在此 activity 中,包含文字“7”的視圖在多行中重復(fù)出現(xiàn):

此類非唯一的視圖通常會(huì)與緊靠它的帶有唯一標(biāo)簽的視圖配對(duì)(如撥號(hào)按鈕旁的姓名)。這種情況下,你可以使用 hasSibling 匹配器縮小選擇范圍:
onView(allOf(withText("7"), hasSibling(withText("item 0")))).perform(click());
用 onData 和自定義 ViewMatcher 匹配數(shù)據(jù)
如下 activity 包含一個(gè) ListView,它基于一個(gè)為每一行提供一個(gè) ?Map<String, Object>? 類型的 SimpleAdapter?。每個(gè) map 都一個(gè)鍵為 “STR” ,的元素(值為 String 類型,“item: x”)和一個(gè)鍵為 “LEN” 的元素(值為 Integer 類型,字符串的長(zhǎng)度)。

點(diǎn)擊 “item: 50” 條目的代碼如下:
onData(allOf(is(instanceOf(Map.class)), hasEntry(equalTo("STR"), is("item: 50))).perform(click());
我們先來看 ?onData? 的一部分:
is(instanceOf(Map.class))
限制搜索 AdapterView 中任意條目的條件為一個(gè) Map。
在此例子中,ListView 的所有行都滿足條件。但我們想要點(diǎn)擊指定的條目 “item: 50”,所以我們需要繼續(xù)縮小范圍:
hasEntry(equalTo("STR"), is("item: 50))
這個(gè) Matcher<String, Object> 會(huì)匹配所有包含任意鍵,值=“item: 50” 的 Map 。鑒于查找此條目的代碼較長(zhǎng),而且我們希望在其他地方重用它,我們可以寫一個(gè)自定義的 matcher “withItemContent”:
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? 作為基類,因?yàn)槲覀兿M軌蛑黄ヅ?Map 類型的對(duì)象。我們覆寫了 matchesSafely 方法,將之前創(chuàng)建的匹配器帶入,將其通過 ?Matcher<String>? 傳入進(jìn)行匹配。此方式將允許我們使用 ?withItemContent(equalTo(“foo”))?。為了代碼簡(jiǎn)潔性,我們創(chuàng)建了另一個(gè)已經(jīng)做了 equalTo 操作并接收一個(gè) String 的匹配器:
public static Matcher<Object> withItemContent(String expectedText) {
checkNotNull(expectedText);
return withItemContent(equalTo(expectedText));
}
現(xiàn)在點(diǎn)擊該條目的代碼很簡(jiǎn)單了:
onData(withItemContent("item: 50")).perform(click());
此測(cè)試的完整代碼請(qǐng)查看 AdapterViewText#testClickOnItem50 和自定義匹配器。
匹配一個(gè)視圖的指定子視圖
上述示例陳述了在 ListView 中點(diǎn)擊一行的中間位置。但我們?cè)撊绾尾僮饕恍兄兄付ǖ淖右晥D?例如,我們我們想要點(diǎn)擊 LongListActivity 其中一行的第二列,該列展示第一列的 ?String.lenth?(具象而言,你可以想象一下 G+ 應(yīng)用中的評(píng)論,在每一條評(píng)論旁有一個(gè) +1 按鈕):

在 ?DataInteraction? 中添加一個(gè) ?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 加載它們,確保使用預(yù)置的值來設(shè)置數(shù)據(jù)對(duì)象(第二個(gè)參數(shù))。
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);
然后,你可以寫一個(gè)匹配器來匹配此對(duì)象:
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));
}
在測(cè)試中很輕易就能加載該視圖:
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());
...
}
請(qǐng)?jiān)?AdapterViewtest#testClickFooter 中查看完整示例代碼。
匹配 ActionBar 中的視圖
?ActionBarActivity? 有兩個(gè)不同的模式:普通的 ActionBar 和從選項(xiàng)菜單中創(chuàng)建的上下文 ActionBar。它們都有一個(gè)條目是一直可見的,而另外兩個(gè)只在懸浮菜單中課件。當(dāng)點(diǎn)擊一個(gè)條目時(shí),它會(huì)將一個(gè) TextView 的內(nèi)容改為點(diǎn)擊條目的內(nèi)容。
匹配兩種 ActionBar 中的可見圖標(biāo)都很簡(jiǎn)單:
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")));
}

針對(duì)上下文 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")));
}

點(diǎn)擊普通 ActionBar 懸浮菜單中的條目有點(diǎn)棘手,由于某些設(shè)備帶有硬件懸浮菜單按鈕(它們會(huì)打開選項(xiàng)菜單的懸浮條目),而某些設(shè)備帶有軟件懸浮菜單按鈕(它們會(huì)打開一個(gè)普通懸浮菜單)。幸運(yùn)的是,Espresso 為我們解決了此問題。
對(duì)于普通的 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")));
}

如下是在帶硬件懸浮菜單按鈕設(shè)備的顯示:

對(duì)于上下文 ActionBar 而言也很簡(jiǎn)單:
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
斷言視圖沒有顯示
執(zhí)行過一系列操作之后,你會(huì)想要斷言待測(cè) UI 的狀態(tài)。有時(shí)可是會(huì)是一個(gè)負(fù)面情況(例如,有些操作沒有成功)。請(qǐng)記住,你可以通過 ViewAssertions.matches 將任意的 hamcrest 視圖匹配器轉(zhuǎn)換為一個(gè) ViewAssertion。
在下面的例子中,我們使用了 isDisplayed 匹配器,并使用標(biāo)準(zhǔn)的 “not” 匹配器反轉(zhuǎn)匹配結(jié)果:
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())));
如果視圖仍然是整個(gè)結(jié)構(gòu)的一部分,上述方式可用。否則,你將得到一個(gè) ?NoMatchingViewException?,此時(shí)你需要使用 ?ViewAssertions.doesNotExit?(見下面)。
斷言一個(gè)視圖不存在
如果視圖不在視圖結(jié)構(gòu)中(如,界面切換到了另一個(gè) activity),你應(yīng)該使用 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());
斷言一個(gè)數(shù)據(jù)條目不在指定適配器中
為了證明一個(gè)指定的數(shù)據(jù)條目不在指定的 AdapterView 中,你需要做些其他操作。我們需要找到指定的 AdapterView 并得到它持有的數(shù)據(jù)。此處不需要使用 onData(),而是使用 onView 查找 AdapterView,然后使用另一個(gè)匹配器處理該視圖中的數(shù)據(jù)。
首先創(chuàng)建匹配器:
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;
}
};
}
接下來我們只需要使用一個(gè) onView 查找指定的 AdapterView:
@SuppressWarnings("unchecked")
public void testDataItemNotInAdapter(){
onView(withId(R.id.list))
.check(matches(not(withAdaptedData(withItemContent("item: 168")))));
}
如果 id 為 list 的 AdapterView 中有一個(gè)條目與 “item: 168” 相同,則斷言失敗。
參考網(wǎng)址示例代碼:AdapterViewTest#testDataItemNotInAdapter。
閑置資源
使用 registerIdlingResource 與自定義資源同步
Espresso 的核心是它可以與待測(cè)應(yīng)用無縫同步測(cè)試操作的能力。默認(rèn)情況下,Espresso 會(huì)等待當(dāng)前消息隊(duì)列中的 UI 事件執(zhí)行(默認(rèn)是 AsyncTask)完畢再進(jìn)行下一個(gè)測(cè)試操作。這應(yīng)該能解決大部分應(yīng)用與測(cè)試同步的問題。
然而,應(yīng)用中有一些執(zhí)行后臺(tái)操作的對(duì)象(比如與網(wǎng)絡(luò)服務(wù)交互)通過非標(biāo)準(zhǔn)方式實(shí)現(xiàn);例如:直接創(chuàng)建和管理線程,以及使用自定義服務(wù)。
此種情況,我們建議你首先提出可測(cè)試性的概念,然后詢問使用非標(biāo)準(zhǔn)后臺(tái)操作是否必要。某些情況下,可能是由于對(duì) Android 理解太少造成的,并且應(yīng)用也會(huì)受益于重構(gòu)(例如,將自定義創(chuàng)建的線程改為 AsyncTask)。然而,某些時(shí)候重構(gòu)并不現(xiàn)實(shí)。慶幸的是 Espresso 仍然可以同步測(cè)試操作與你的自定義資源。
以下是我們需要完成的:
- 實(shí)現(xiàn)
?IdlingResource? 接口并暴露給測(cè)試。 - 通過在 setUp 中調(diào)用
?Espresso.registerIdlingResource? 注冊(cè)一個(gè)或多個(gè) IdlingResource 給 Espresso。
參考 ?AdvancedSynchronizationTest? 和 CountingIdlingResource 類以了解 IdlingResource 如何能使用。
需要注意的是 IdlingResource 接口是在待測(cè)應(yīng)用中實(shí)現(xiàn)的,所以你需要謹(jǐn)慎的添加依賴:
// 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'
定制
使用自定義失敗處理器
使用一個(gè)自定義的失敗處理器來替換 Espresso 默認(rèn)的 FailureHandler 以允許增強(qiáng)(或區(qū)分)錯(cuò)誤的處理。比如:截圖或轉(zhuǎn)儲(chǔ)特別的調(diào)試信息。
示例 CustomFailureHandlerTest 演示了如何實(shí)現(xiàn)一個(gè)自定義的失敗處理器:
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 測(cè)試的 setUp() 中注冊(cè):
@Override
public void setUp() throws Exception {
super.setUp();
getActivity();
setFailureHandler(new CustomFailureHandler(getInstrumentation().getTargetContext()));
}
參考 FailureHandler 接口和 Espresso.setFailureHandler 獲取更多信息。
inRoot
使用 inRoot 來指定非默認(rèn)窗口
很驚奇吧,但這是真的—— Android 支持多窗口。通常這對(duì)于用戶和 Android 開發(fā)者來說是透明的(transparent 雙關(guān)意),在某些情況下多窗口是可見的(例如:在搜索控件中自動(dòng)補(bǔ)全窗口繪制在主窗口之上)。為了方便,Espresso 默認(rèn)使用一個(gè)啟發(fā)模式來推測(cè)你想要與哪個(gè)窗口交互。你可以通過自己提供根窗口(亦稱為 Root 匹配器)來決定要與哪個(gè)窗口交互。
onView(withText("South China Sea"))
.inRoot(withDecorView(not(is(getActivity().getWindow().getDecorView()))))
.perform(click());
和 ViewMatchers 的情況類似,我們提供了一組封裝好的 RootMatchers。當(dāng)然,你依然可以實(shí)現(xiàn)自己的匹配器。
更多信息請(qǐng)查看 示例 或者 GitHub 上的示例。