EspressoはonViewを使って画面上のViewを検出します。
「onViewがViewを検出する仕組み」を調べてみました。ざっくりと仕組みを理解しておくと、テストで応用が効くし、ミスが減ると思いますよ!
目次
onViewがViewを検出する仕組み
どんなプログラムでも「入力⇒処理⇒出力」の流れで動いています。「処理」の内容は「処理」を直接解析しなくても、入力と出力からおよその考察が可能です。
onViewのソースコードを追うことは骨が折れる作業なので、今回は入力と出力からonViewがViewを検出する仕組みを考えてみようと思います。
入力:ViewMatcherでダンプ
与えられたViewをダンプするカスタムViewMatcherを作成します。クラス名とリソース名を出力するだけの簡単なものです。
class DumpView : TypeSafeMatcher<View>() {
var count = 0
override fun matchesSafely(item: View): Boolean {
count++
val _Resources = item.context.resources
if (item.id > 0) { // IDを持つ場合
Log.i("DumpView", "%2d Class = %-22s Id = %s".format(
count,
item.javaClass.simpleName,
_Resources.getResourceEntryName(item.id)
))
}
else { // IDを持たない場合
Log.i("DumpView", "%2d Class = %s".format(
count,
item.javaClass.simpleName
))
}
return false // 全てのViewにマッチしない
}
override fun describeTo(description: Description) {}
}
public class DumpView extends TypeSafeMatcher<View> {
private int mCount = 0;
@Override
protected boolean matchesSafely(View item) {
mCount++;
Resources _Resources = item.getContext().getResources();
if(item.getId() > 0) { // IDを持つ場合
Log.i("DumpView", String.format("%2d Class = %-22s Id = %s",
mCount,
item.getClass().getSimpleName(),
_Resources.getResourceEntryName(item.getId())
));
}
else { // IDを持たない場合
Log.i("DumpView", String.format("%2d Class = %s",
mCount,
item.getClass().getSimpleName()
));
}
return false; // 全てのViewにマッチしない
}
@Override
public void describeTo(Description description) {}
}
このDumpView(ViewをダンプするカスタムViewMatcher)をonView()の引数に指定し、Viewを検出する動作をダンプしてみます。
@Test
fun dump_test() {
onView(DumpView()).perform(click())
}
@Test
public void dump_test() {
onView(new DumpView()).perform(click());
}
ダンプを行ったサンプルアプリは次のような画面構成です。
(LinearLayoutの中に、TextView ×2、EditText、Buttonが縦積み)
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".OnViewActivity">
<LinearLayout
android:id="@+id/layGroup"
android:layout_width="200dp"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/textView_A"
android:text="Hello World!" />
<TextView
android:id="@+id/textView_B"
android:text="Hello World!" />
<EditText
android:id="@+id/editText"
android:text="Name" />
<Button
android:id="@+id/button"
android:text="Button" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
ダンプの結果はこのようになりました。
1 Class = DecorView 2 Class = LinearLayout 3 Class = View Id = statusBarBackground 4 Class = ViewStub Id = action_mode_bar_stub 5 Class = FrameLayout 6 Class = ActionBarOverlayLayout Id = decor_content_parent 7 Class = ContentFrameLayout Id = content 8 Class = ActionBarContainer Id = action_bar_container 9 Class = ConstraintLayout 10 Class = Toolbar Id = action_bar 11 Class = ActionBarContextView Id = action_context_bar 12 Class = LinearLayout Id = layGroup 13 Class = AppCompatTextView 14 Class = ActionMenuView 15 Class = AppCompatTextView Id = textView_A 16 Class = AppCompatTextView Id = textView_B 17 Class = AppCompatEditText Id = editText 18 Class = AppCompatButton Id = button
出力:検出されたView
onViewは検出したViewを後段(.checkまたは.perform)へ渡します。
先のDumpView(ViewをダンプするカスタムViewMatcher)をcheck(matches())の引数に指定し、受け渡されたViewをダンプしてみます。
@Test
fun result_test() {
onView(withId(R.id.textView_A)).check(matches(DumpView()))
}
@Test
public void result_test() {
onView(withId(R.id.textView_A)).check(matches(new DumpView()));
}
ダンプの結果はこのようになりました。
1 Class = AppCompatTextView Id = textView_A
onViewの引数で指定したViewMatcherにマッチしたViewがcheckへ受け渡されていることが分かります。
ツリー構造をサーチして検出
サンプルアプリの画面のツリー構造は次のようになっています。

DumpView(ViewをダンプするカスタムViewMatcher)でダンプを行った結果より、onViewはツリー構造を根本からたどって行き、Viewと出合うごとに、そのViewを引数にしてViewMatcherを呼び出しているようです。
ViewMatcherは自分に課せられた一致確認を行って、結果(一致:true、不一致:false)を返していきます。
調査で使用したDumpViewは必ずfalseを返すようになっているので、onViewは失敗します。どこか一つのViewでtrueを返せば、Viewの検出は成功するはずです。
強引ですが、「count == 18」の時にtrueを返すようにしたところ、Viewの検出が成功し、performで操作できました。
class DumpView : TypeSafeMatcher<View>() {
var count = 0
override fun matchesSafely(item: View): Boolean {
count++
...
return (count == 18) // 18番目のViewでマッチ
}
override fun describeTo(description: Description) {}
}
public class DumpView extends TypeSafeMatcher<View> {
private int mCount = 0;
@Override
protected boolean matchesSafely(View item) {
mCount++;
...
return (mCount == 18); // 18番目のViewでマッチ
}
@Override
public void describeTo(Description description) {}
}
onViewがViewを検出できない時
Viewを検出できない時は次の2つの状態だと考えられます。
- ViewMatcherが1つもマッチしない
- ViewMatcherが2つ以上マッチする
どちらの場合もテストのログに検出できなかった理由が出力されます。
ViewMatcherが1つもマッチしない
ログにDumpView(ViewをダンプするカスタムViewMatcher)でダンプをした場合と同様なViewのツリーが表示されます。
@Test
fun match_test() {
onView(withText("Hello Japan!")).perform(click())
// onView(withText("Hello World!")).perform(click())
}
@Test
public void match_test() {
onView(withText("Hello Japan!")).perform(click());
// onView(withText("Hello World!")).perform(click());
}
androidx.test.espresso.NoMatchingViewException: No views in hierarchy found matching: with text: is "Hello Japan!"
View Hierarchy:
+>DecorView{id=-1, v=VISIBLE, child=2}
+->LinearLayout{id=-1, v=VISIBLE, child=2}
+-->ViewStub{id=16909288, res=action_mode_bar_stub, v=GONE, }
+-->FrameLayout{id=-1, v=VISIBLE, child=1}
+--->ActionBarOverlayLayout{id=2131165262, res=decor_content_parent, v=VISIBLE, child=2}
+---->ContentFrameLayout{id=16908290, res=content, v=VISIBLE, child=1}
+----->ConstraintLayout{id=-1, v=VISIBLE, child=1}
+------>LinearLayout{id=2131165286, res=layGroup, v=VISIBLE, child=4}
+------->AppCompatTextView{id=2131165364, res=textView_A, v=VISIBLE, text=Hello World!}
+------->AppCompatTextView{id=2131165365, res=textView_B, v=VISIBLE, text=Hello World!}
+------->AppCompatEditText{id=2131165268, res=editText, v=VISIBLE, text=Name}
+------->AppCompatButton{id=2131165250, res=button, v=VISIBLE, text=Button}
+---->ActionBarContainer{id=2131165225, res=action_bar_container, v=VISIBLE, child=2}
+----->Toolbar{id=2131165223, res=action_bar, v=VISIBLE, child=2}
+------>AppCompatTextView{id=-1, v=VISIBLE, text=Espresso}
+------>ActionMenuView{id=-1, v=VISIBLE, child=0}
+----->ActionBarContextView{id=2131165231, res=action_context_bar, v=GONE, child=0}
+->View{id=16908335, res=statusBarBackground, v=VISIBLE, }
※上記のログは実際の出力と異なり、一部が省略されています。
ViewMatcherが2つ以上マッチする
ログにDumpView(ViewをダンプするカスタムViewMatcher)でダンプをした場合と同様なViewのツリーが表示されます。
マッチしたViewに”****MATCHES****”(行の最後)が付きます。
@Test
fun match_test() {
// onView(withText("Hello Japan!")).perform(click())
onView(withText("Hello World!")).perform(click())
}
@Test
public void match_test() {
// onView(withText("Hello Japan!")).perform(click());
onView(withText("Hello World!")).perform(click());
}
androidx.test.espresso.AmbiguousViewMatcherException: 'with text: is "Hello World!"' matches multiple views in the hierarchy.
Problem views are marked with '****MATCHES****' below.
View Hierarchy:
+>DecorView{id=-1, v=VISIBLE, child=2}
+->LinearLayout{id=-1, v=VISIBLE, child=2}
+-->ViewStub{id=16909288, res=action_mode_bar_stub, v=GONE, }
+-->FrameLayout{id=-1, v=VISIBLE, child=1}
+--->ActionBarOverlayLayout{id=2131165262, res=decor_content_parent, v=VISIBLE, child=2}
+---->ContentFrameLayout{id=16908290, res=content, v=VISIBLE, child=1}
+----->ConstraintLayout{id=-1, v=VISIBLE, child=1}
+------>LinearLayout{id=2131165286, res=layGroup, v=VISIBLE, child=4}
+------->AppCompatTextView{id=2131165364, res=textView_A, v=VISIBLE, text=Hello World!} ****MATCHES****
+------->AppCompatTextView{id=2131165365, res=textView_B, v=VISIBLE, text=Hello World!} ****MATCHES****
+------->AppCompatEditText{id=2131165268, res=editText, v=VISIBLE, text=Name}
+------->AppCompatButton{id=2131165250, res=button, v=VISIBLE, text=Button}
+---->ActionBarContainer{id=2131165225, res=action_bar_container, v=VISIBLE, child=2}
+----->Toolbar{id=2131165223, res=action_bar, v=VISIBLE, child=2}
+------>AppCompatTextView{id=-1, v=VISIBLE, text=Espresso}
+------>ActionMenuView{id=-1, v=VISIBLE, child=0}
+----->ActionBarContextView{id=2131165231, res=action_context_bar, v=GONE, child=0}
+->View{id=16908335, res=statusBarBackground, v=VISIBLE, }
※上記のログは実際の出力と異なり、一部が省略されています。
