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, }
※上記のログは実際の出力と異なり、一部が省略されています。