やりたいこと
以下のような RecyclerView で作ったリストに対して Espresso の UI テストを行うことを考えます。
赤枠が1つのリストアイテムです。このリストアイテムの中にある青枠のボタンをクリックする操作を UI テストに組み込んでみます。
 
UI テストで RecyclerView を操作する
espresso-contrib パッケージの RecyclerViewActions を使うと ViewHolder が保持している一番外側のビュー(赤枠)を操作でるようになります。
下記の例は RecyclerView の中からポジション 3 のリストアイテムをクリックする操作です。
onView(withId(R.id.recycler_view)).perform(
    RecyclerViewActions.actionOnItemAtPosition<MyViewHolder>(
        3,
        click()
    )
)
しかし、今回の例で言えば青枠のボタンをクリックしたいので、このままでは機能しません。
このように実際のアプリではリストアイテムの一番外側を操作するこよりも、その中にあるビューを操作することの方が多いかと思います。
依存関係の追加
それでは青枠のボタンを押す方法を見ていきます。
まずは espresso-contrib を依存関係に追加します(今回2つ目に紹介する方法で使用します)。
dependencies {
    ...
    androidTestImplementation ('com.android.support.test.espresso:espresso-contrib:3.0.2') {
        exclude group: 'com.android.support', module: 'appcompat-v7'
        exclude group: 'com.android.support', module: 'support-v4'
        exclude module: 'recyclerview-v7'
    }
}
developers を見ると以下のように書かれていましたが、これだと自分の環境ではテスト実行時に NoClassDefFoundError が発生してしまったため、いろいろ調べた結果上記のようにしています。
// これだとテスト実行時にエラーになる
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0'
その他、UI テストに必要な依存関係は追加されているものとします。
① リストアイテム内のビューを直接マッチさせる Matcher を作る
現状ではリストアイテム(赤枠)の中にある要素を直接マッチさせることができません。実現するために独自の Matcher または ViewAction を作る必要があります。
最初にカスタム Matcher を作ってみます。
カスタム Matcher は TypeSafeMatcher クラスを継承して作ることができます。下記のメソッドは引数に ① RecyclerView の ID、② 見つけたいビューの ID、③ リストアイテムの位置 を指定することで、リストアイテムの中にあるビューを直接見つけてくれます。
fun withDescendantViewAtPosition(@IdRes recyclerViewId: Int, @IdRes targetViewId: Int, position: Int) =
    object : TypeSafeMatcher<View>() {
        override fun describeTo(description: Description) {
            description.appendText("指定された位置のリストアイテム内に、指定された ID のビューが存在すること")
        }
        override fun matchesSafely(view: View): Boolean {
            val root = view.rootView
            // 画面内から RecyclerView を見つける
            val recyclerView = root.findViewById<RecyclerView>(recyclerViewId) ?: return false
            // 指定された位置の ViewHolder を取得する
            val viewHolder = recyclerView.findViewHolderForAdapterPosition(position) ?: return false
            // ViewHolder の中から指定された ID のビューを見つける
            val targetView = viewHolder.itemView.findViewById<View>(targetViewId) ?: return false
            // 引数 view が指定されたビューと一致するか検証する
            return view == targetView
        }
    }
describeTo(Description) はカスタム Matcher を説明しているだけなので動作には影響ありません。重要となるのは matchesSafely(View) です。指定された RecyclerView の中から指定された位置の ViewHolder を取得し、さらにその中から指定された ID を持つビューを探しています。見つけ出したビューが引数 view と一致する場合 true を返します(つまりマッチしたと判定されます)。
(詳しく調べていないので想像ですが、matchesSafely(view: View) の引数 view には、画面に表示されているビューが入ってきて、マッチするかどうかを検証しているのだと思います。)
使い方は次のとおりです。
val position = 5
// 対象となるリストアイテムが表示されている必要があるのでスクロールする
onView(withId(R.id.recycler_view))
    .perform(RecyclerViewActions.scrollToPosition(position))
onView(withDescendantViewAtPosition(
    R.id.recycler_view, // RecyclerView の ID
    R.id.add_button,    // 見つけたいビューの ID(ここでは追加ボタン)
    position            // リストアイテムの位置
)).perform(click())
これで最初に示した青枠のボタンを押すことができました。このやり方であれば、マッチしたビューに対してクリックだけでなくスワイプやスクロールも実行できます。
② リストアイテム内のビューをクリックする ViewAction を作る
先ほどはリストアイテム内のビューを直接マッチさせて、そのビューに対して perform(click()) を行うことでボタンをクリックしました。
今回は独自の ViewAction を作ることで、リストアイテム内のビューをクリックできるようにします。
fun clickDescendantViewWithId(@IdRes id: Int) = object : ViewAction {
    override fun getConstraints(): Matcher<View> {
        // 指定された ID のビューを子孫に持っていることを条件にする
        return hasDescendant(withId(id))
    }
    override fun getDescription(): String {
        return "RecyclerView のリストアイテム内にあるビューをクリックする"
    }
    override fun perform(uiController: UiController, view: View) {
        // 指定された ID のビューを探してクリックする
        view?.findViewById<View>(id)?.also {
            it.performClick()
        }
    }
}
getConstraints() ではアクションを実行する条件を設定することができます。ここでは対象となるビューを子孫に持っていることを条件にしています。
実際にクリックする処理は perform(UiController, View) で定義します。第2引数の view は RecyclerView のリストアイテムを想定しています。リストアイテムから指定のビューを探して performClick() を実行します。
使い方は以下の通りです。
onView(withId(R.id.recycler_view)).perform(
    RecyclerViewActions.actionOnItemAtPosition<MyViewHolder>(
        0, clickDescendantViewWithId(R.id.add_button)
    )
)
このやり方でも青枠のボタンを押すことができます。
なお、今回は View クラスに実装されている performClick() を使用しましたが、例えばダブルクリックやスワイプなどが行えるメソッドは View クラスに実装されていません。その場合は GeneralClickAction や GeneralSwipeAction などを使います。
fun clickDescendantViewWithId(@IdRes id: Int) = object : ViewAction {
    ...
    override fun perform(uiController: UiController, view: View) {
        val target = view.findViewById<View>(id) ?: return
        // ダブルクリックするための GeneralClickAction
        val action = GeneralClickAction(
            Tap.SINGLE, GeneralLocation.VISIBLE_CENTER, Press.FINGER,
            InputDevice.SOURCE_UNKNOWN, MotionEvent.BUTTON_PRIMARY
        )
        action.perform(uiController, target)
    }
}
今回は以上となります。
カスタム Matcher やカスタム ViewAction の作り方を理解すると、一気に UI テストの幅が広がりそうです!

