EspressoのonDataはAdapterViewが管理するアイテムのViewを検出します。
「onDataがViewを検出する仕組み」を調べてみました。ざっくりと仕組みを理解しておくと、テストで応用が効くし、ミスが減ると思いますよ!
目次
onDataがViewを検出する仕組み
どんなプログラムでも「入力⇒処理⇒出力」の流れで動いています。「処理」の内容は「処理」を直接解析しなくても、入力と出力からおよその考察が可能です。
onDataのソースコードを追うことは骨が折れる作業なので、今回は入力と出力からonDataがViewを検出する仕組みを考えてみようと思います。
入力:ObjectMatcherでダンプ
与えられたObjectをダンプするカスタムObjectMatcherを作成します。クラス名とtoString()を出力するだけの簡単なものです。
class DumpObject : BaseMatcher<Any>() { var count = 0 override fun matches(item: Any): Boolean { count++ Log.i("DumpObject", "%2d Class = %s ToString = %s".format( count, item.javaClass.simpleName, item.toString() )) return false // 全てのObjectにマッチしない } override fun describeTo(description: Description) { } }
public class DumpObject extends BaseMatcher<Object> { private int mCount = 0; @Override public boolean matches(Object item) { mCount++; Log.i("DumpObject", String.format( "%2d Class = %s ToString = %s", mCount, item.getClass().getSimpleName(), item.toString() )); return false; // 全てのObjectにマッチしない } @Override public void describeTo(Description description) { } }
このDumpObject(ObjectをダンプするカスタムObjectMatcher)をonData()の引数に指定し、アイテムのViewを検出する動作をダンプしてみます。
@Test fun dump_test() { onData(DumpObject()).perform(click()) }
@Test public void dump_test() throws Exception { onData(new DumpObject_j()).perform(click()); }
ダンプを行ったサンプルアプリは次のような画面構成です。
(LinearLayoutの中に、TextView、ListViewが縦積み)
<androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".OnDataActivity"> <LinearLayout android:id="@+id/layGroup" android:layout_width="200dp" android:layout_height="wrap_content" android:orientation="vertical"> <TextView android:id="@+id/textView" android:text="Hello World!" /> <ListView android:id="@+id/listView" android:layout_height="80dp" /> </LinearLayout> </androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout android:id="@+id/list_item" android:orientation="horizontal"> <TextView android:id="@+id/textNum" android:text="No." /> <TextView android:id="@+id/textWeek" android:text="WeekName" /> </LinearLayout>
val listData = listOf( // データはMapのリスト mapOf("Num" to 0, "Name" to "Mon."), // アイテム mapOf("Num" to 1, "Name" to "Tue."), // アイテム mapOf("Num" to 2, "Name" to "Wed."), // アイテム mapOf("Num" to 3, "Name" to "Thur."), // アイテム mapOf("Num" to 4, "Name" to "Fri."), // アイテム mapOf("Num" to 5, "Name" to "Sat."), // アイテム mapOf("Num" to 6, "Name" to "Sun.") // アイテム ) val simpleAdapter = SimpleAdapter( this, listData, R.layout.list_item, arrayOf("Num", "Name"), intArrayOf(R.id.textNum, R.id.textWeek) )
List<Map<String, Object>> _ListData = new ArrayList<>(); // データはMapのリスト Map<String, Object> _Map0 = new HashMap<>(); // アイテム _Map0.put("Num", 0);_Map0.put("Name", "Mon."); _ListData.add(_Map0); Map<String, Object> _Map1 = new HashMap<>(); // アイテム _Map1.put("Num", 1);_Map1.put("Name", "Tue."); _ListData.add(_Map1); Map<String, Object> _Map2 = new HashMap<>(); // アイテム _Map2.put("Num", 2);_Map2.put("Name", "Wed."); _ListData.add(_Map2); Map<String, Object> _Map3 = new HashMap<>(); // アイテム _Map3.put("Num", 3);_Map3.put("Name", "Thur."); _ListData.add(_Map3); Map<String, Object> _Map4 = new HashMap<>(); // アイテム _Map4.put("Num", 4);_Map4.put("Name", "Fri."); _ListData.add(_Map4); Map<String, Object> _Map5 = new HashMap<>(); // アイテム _Map5.put("Num", 5);_Map5.put("Name", "Sat."); _ListData.add(_Map5); Map<String, Object> _Map6 = new HashMap<>(); // アイテム _Map6.put("Num", 6);_Map6.put("Name", "Sun."); _ListData.add(_Map6); SimpleAdapter _SimpleAdapter = new SimpleAdapter( this, _ListData, R.layout.list_item, new String[]{"Num", "Name"}, new int[]{R.id.textNum, R.id.textWeek} );
ダンプの結果はこのようになりました。
1 Class = HashMap ToString = {Num=0, Name=Mon.} 2 Class = HashMap ToString = {Num=1, Name=Tue.} 3 Class = HashMap ToString = {Num=2, Name=Wed.} 4 Class = HashMap ToString = {Num=3, Name=Thur.} 5 Class = HashMap ToString = {Num=4, Name=Fri.} 6 Class = HashMap ToString = {Num=5, Name=Sat.} 7 Class = HashMap ToString = {Num=6, Name=Sun.}
出力:検出されたアイテムのView
onDataは検出したアイテムのViewを後段(.checkまたは.perform)へ渡します。
DumpView(ViewをダンプするカスタムViewMatcher)を作成して、check(matches())の引数に指定し、受け渡されたViewをダンプしてみます。
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) {} }
@Test fun result_test() { onData(hasEntry("Name", "Wed.")).check(matches(DumpView())) }
@Test public void result_test() { onData(hasEntry("Name", "Wed.")).check(matches(new DumpView())); }
ダンプの結果はこのようになりました。
1 Class = LinearLayout Id = list_item
注意しなければならないのはアイテムのViewだということです。
アイテムのViewは再利用されるので、どのアイテムも同じViewになります。ただ、アイテムの要素は異なります。
また、表示範囲外のアイテムのView(隠れているアイテム)も検出可能です。onDataにより自動的にスクロールが行われます。
リスト構造をサーチして検出
サンプルアプリのAdapterに登録されたデータのリスト構造は次のようになっています。
DumpObject(ObjectをダンプするカスタムObjectMatcher)でダンプを行った結果より、onDataはデータのリスト構造の先頭からアイテムを取り出していき、アイテムごとに、そのアイテムを引数にObjectMatcherを呼び出しているようです。
ObjectMatcherは自分に課せられた一致確認を行って、結果(一致:true、不一致:false)を返していきます。
調査で使用したDumpObjectは必ずfalseを返すようになっているので、onDataは失敗します。どこか一つのアイテムでtrueを返せば、Viewの検出は成功するはずです。
強引ですが、「count == 7」の時にtrueを返すようにしたところ、7番目のViewの検出が成功し、performで操作できました。
class DumpObject : BaseMatcher<Any>() { var count = 0 override fun matches(item: Any): Boolean { count++ ... return (count == 7) // 7番目のObjectでマッチ } override fun describeTo(description: Description) { } }
public class DumpObject extends BaseMatcher<Object> { private int mCount = 0; @Override public boolean matches(Object item) { mCount++; ... return (mCount == 7); // 7番目のObjectでマッチ } @Override public void describeTo(Description description) { } }
onDataがViewを検出できない時
Viewを検出できない時は次の2つの状態だと考えられます。
- ObjectMatcherが1つもマッチしない
- ObjectMatcherが2つ以上マッチする
どちらの場合もテストのログに検出できなかった理由が出力されます。
ObjectMatcherが1つもマッチしない
ログにDumpObject(ObjectをダンプするカスタムObjectMatcher)でダンプをした場合と同様なアイテムのデータが表示されます。
@Test fun match_test() { onData(hasEntry("Name", "水")).check(matches(DumpView())) }
@Test public void match_test() { onData(hasEntry("Name", "水")).check(matches(new DumpView_j())); }
java.lang.RuntimeException: No data found matching: map containing ["Name"->"水"] contained values: <[ Data: {Num=0, Name=Mon.} (class: java.util.HashMap) token: 0, Data: {Num=1, Name=Tue.} (class: java.util.HashMap) token: 1, Data: {Num=2, Name=Wed.} (class: java.util.HashMap) token: 2, Data: {Num=3, Name=Thur.} (class: java.util.HashMap) token: 3, Data: {Num=4, Name=Fri.} (class: java.util.HashMap) token: 4, Data: {Num=5, Name=Sat.} (class: java.util.HashMap) token: 5, Data: {Num=6, Name=Sun.} (class: java.util.HashMap) token: 6 ]>
ObjectMatcherが2つ以上マッチする
ログにObjectMatcherでマッチしたアイテムのデータが複数表示されます。
@Test fun match_test() { onData(anyOf( hasEntry("Name", "Wed."), hasEntry("Name", "Sun.") )) .perform(click()) }
@Test public void match_test() { onData(anyOf( hasEntry("Name", "Wed."), hasEntry("Name", "Sun.") )) .perform(click()); }
java.lang.RuntimeException: Multiple data elements matched: (map containing ["Name"->"Wed."] or map containing ["Name"->"Sun."]). Elements: [ Data: {Num=2, Name=Wed.} (class: java.util.HashMap) token: 2, Data: {Num=6, Name=Sun.} (class: java.util.HashMap) token: 6 ]