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

投稿日:  更新日:

EspressoのonDataはAdapterViewが管理するアイテムのViewを検出します。

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

スポンサーリンク

onDataがViewを検出する仕組み

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

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

入力:ObjectMatcherでダンプ

与えられたObjectをダンプするカスタムObjectMatcherを作成します。クラス名とtoString()を出力するだけの簡単なものです。

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

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

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

KotlinJava
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)でダンプをした場合と同様なアイテムのデータが表示されます。

KotlinJava
    @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でマッチしたアイテムのデータが複数表示されます。

KotlinJava
    @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
    ]
スポンサーリンク
スポンサーリンク