EspressoのonViewがViewを検出する仕組み

投稿日:  更新日:

EspressoはonViewを使って画面上のViewを検出します。

「onViewがViewを検出する仕組み」を調べてみました。ざっくりと仕組みを理解しておくと、テストで応用が効くし、ミスが減ると思いますよ!

スポンサーリンク

onViewがViewを検出する仕組み

どんなプログラムでも「入力⇒処理⇒出力」の流れで動いています。「処理」の内容は「処理」を直接解析しなくても、入力と出力からおよその考察が可能です。

onViewのソースコードを追うことは骨が折れる作業なので、今回は入力と出力からonViewがViewを検出する仕組みを考えてみようと思います。

入力:ViewMatcherでダンプ

与えられたViewをダンプするカスタムViewMatcherを作成します。クラス名とリソース名を出力するだけの簡単なものです。

KotlinJava
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を検出する動作をダンプしてみます。

KotlinJava
    @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をダンプしてみます。

KotlinJava
    @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で操作できました。

KotlinJava
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のツリーが表示されます。

KotlinJava
    @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****”(行の最後)が付きます。

KotlinJava
    @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, }

※上記のログは実際の出力と異なり、一部が省略されています。

スポンサーリンク
スポンサーリンク