如何在 Espresso 的 RecyclerView 中断言?

How to assert inside a RecyclerView in Espresso?

我正在使用 espresso-contrib 在 RecyclerView 上执行操作,它可以正常工作,例如:

//click on first item
onView(withId(R.id.recycler_view))
    .perform(RecyclerViewActions.actionOnItemAtPosition(0, click()));

我需要对其执行断言。像这样:

onView(withId(R.id.recycler_view))
    .perform(
        RecyclerViewActions.actionOnItemAtPosition(
            0, check(matches(withText("Test Text")))
        )
    );

但是,因为 RecyclerViewActions 当然是期待一个动作,它说第二个参数类型错误。 espresso-contrib 上没有 RecyclerViewAssertions

有什么方法可以对 RecyclerView 执行断言吗?

您应该查看 Danny Roa 的解决方案 Custom RecyclerView Actions 并像这样使用它:

onView(withRecyclerView(R.id.recycler_view)
    .atPositionOnView(1, R.id.ofElementYouWantToCheck))
    .check(matches(withText("Test text")));

很简单。不需要额外的库。做:

    onView(withId(R.id.recycler_view))
            .check(matches(atPosition(0, withText("Test Text"))));

如果您的 ViewHolder 使用 ViewGroup,请用 hasDescendant() 包裹 withText(),例如:

onView(withId(R.id.recycler_view))
                .check(matches(atPosition(0, hasDescendant(withText("Test Text")))));

使用您可以放入 Utils class.

的方法
public static Matcher<View> atPosition(final int position, @NonNull final Matcher<View> itemMatcher) {
    checkNotNull(itemMatcher);
    return new BoundedMatcher<View, RecyclerView>(RecyclerView.class) {
        @Override
        public void describeTo(Description description) {
            description.appendText("has item at position " + position + ": ");
            itemMatcher.describeTo(description);
        }

        @Override
        protected boolean matchesSafely(final RecyclerView view) {
            RecyclerView.ViewHolder viewHolder = view.findViewHolderForAdapterPosition(position);
            if (viewHolder == null) {
                // has no item on such position
                return false;
            }
            return itemMatcher.matches(viewHolder.itemView);
        }
    };
}

如果您的项目一开始可能在屏幕上不可见,请先滚动到它:

    onView(withId(R.id.recycler_view))
            .perform(scrollToPosition(87))
            .check(matches(atPosition(87, withText("Test Text"))));

只是为了加强 riwnodenyk 的回答,如果你的 ViewHolder 是一个 ViewGroup 而不是像 TextView 这样的直接 View 对象,那么你可以添加 hasDescendant 匹配器来匹配 ViewGroup 中的 TextView 对象。示例:

onView(withId(R.id.recycler_view))
    .check(matches(atPosition(0, hasDescendant(withText("First Element")))));

Danny Roa 的解决方案很棒,但它不适用于我刚刚滚动到的屏幕外项目。修复正在替换这个:

View targetView = recyclerView.getChildAt(this.position)
                              .findViewById(this.viewId);

有了这个:

View targetView = recyclerView.findViewHolderForAdapterPosition(this.position)
                              .itemView.findViewById(this.viewId);

...在 TestUtils class.

就我而言,有必要合并 Danny Roa 和 riwnodenyk 的解决方案:

onView(withId(R.id.recyclerview))
.perform(RecyclerViewActions.scrollToPosition(80))
.check(matches(atPositionOnView(80, withText("Test Test"), R.id.targetview)));

和方法:

public static Matcher<View> atPositionOnView(final int position, final Matcher<View> itemMatcher,
        final int targetViewId) {

    return new BoundedMatcher<View, RecyclerView>(RecyclerView.class) {
        @Override
        public void describeTo(Description description) {
            description.appendText("has view id " + itemMatcher + " at position " + position);
        }

        @Override
        public boolean matchesSafely(final RecyclerView recyclerView) {
            RecyclerView.ViewHolder viewHolder = recyclerView.findViewHolderForAdapterPosition(position);
            View targetView = viewHolder.itemView.findViewById(targetViewId);
            return itemMatcher.matches(targetView);
        }
    };
}

我一直在为同一个问题苦苦挣扎,我有一个包含 6 个项目的回收视图,然后我必须选择位置 5 的那个,而第 5 个在视图中不可见。
上述解决方案也有效。另外,我知道它有点晚,但认为它值得分享。

我用下面的简单解决方案做到了这一点:

onView(withId(R.id.recylcer_view))
    .perform(RecyclerViewActions.actionOnItem(
         hasDescendant(withText("Text of item you want to scroll to")), 
         click()));

RecyclerViewActions.actionOnItem - 对 viewHolderMatcher.

匹配的视图执行 ViewAction
  1. 滚动 RecyclerViewitemViewMatcher
  2. 匹配的视图
  3. 对匹配的视图执行操作

可以在以下位置找到更多详细信息:RecyclerViewActions.actionOnItem

这个线程很旧,但我最近扩展了 RecyclerViewAction 支持,使其也可用于断言。这允许您在不指定位置的情况下测试屏幕外元素,同时仍然使用视图匹配器。

查看这篇文章 > Better Android RecyclerView Testing

要测试所有项目(包括屏幕外),请使用下面的代码

样本:

fun checkThatRedDotIsGone(itemPosition: Int) {
    onView(
            withId(R.id.recycler_view)).
            check(itemViewMatches(itemPosition, R.id.inside_item_view,
                    withEffectiveVisibility(Visibility.GONE)))
}

Class:

object RecyclerViewAssertions {

fun itemViewMatches(position: Int, viewMatcher: Matcher<View>): ViewAssertion {
    return itemViewMatches(position, -1, viewMatcher)
}

/**
 * Provides a RecyclerView assertion based on a view matcher. This allows you to
 * validate whether a RecyclerView contains a row in memory without scrolling the list.
 *
 * @param viewMatcher - an Espresso ViewMatcher for a descendant of any row in the recycler.
 * @return an Espresso ViewAssertion to check against a RecyclerView.
 */
fun itemViewMatches(position: Int, @IdRes resId: Int, viewMatcher: Matcher<View>): ViewAssertion {
    assertNotNull(viewMatcher)

    return ViewAssertion { view, noViewException ->
        if (noViewException != null) {
            throw noViewException
        }

        assertTrue("View is RecyclerView", view is RecyclerView)

        val recyclerView = view as RecyclerView
        val adapter = recyclerView.adapter
        val itemType = adapter!!.getItemViewType(position)
        val viewHolder = adapter.createViewHolder(recyclerView, itemType)
        adapter.bindViewHolder(viewHolder, position)

        val targetView = if (resId == -1) {
            viewHolder.itemView
        } else {
            viewHolder.itemView.findViewById(resId)
        }

        if (viewMatcher.matches(targetView)) {
            return@ViewAssertion  // Found a matching view
        }

        fail("No match found")
    }
}

}

基于本文的解决方案 > Better Android RecyclerView Testing!

我将上面的一些答案组合成一个可重用的方法,用于随着项目的发展测试其他回收器视图。将代码留在此处,以防对其他人有所帮助...

声明一个可以在 RecyclerView 资源 ID 上调用的函数:

fun Int.shouldHaveTextAtPosition(text:String, position: Int) {
    onView(withId(this))
        .perform(scrollToPosition<RecyclerView.ViewHolder>(position))
        .check(matches(atPosition(position, hasDescendant(withText(text)))))
}

然后在您的回收站视图上调用它来检查列表中给定位置的多个适配器项目显示的文本,方法是:

with(R.id.recycler_view_id) {
    shouldHaveTextAtPosition("Some Text at position 0", 0)
    shouldHaveTextAtPosition("Some Text at position 1", 1)
    shouldHaveTextAtPosition("Some Text at position 2", 2)
    shouldHaveTextAtPosition("Some Text at position 3", 3)
}

上面的解决方案很棒,我在 Kotlin 中再贡献一个。

Espresso 需要滚动到该位置,然后检查它。要滚动到该位置,我使用 RecyclerViewActions.scrollToPosition. Then use findViewHolderForAdapterPosition to perform the check. Instead of calling findViewHolderForAdapterPosition directly, I am using a nicer convenience function childOfViewAtPositionWithMatcher.

在下面的示例代码中,Espresso 滚动到 RecyclerView 的第 43 行,并检查该行的 ViewHolder 是否有两个 TextView,并且这些 TextView 是否包含文本“hello text 1 at line 43" 和 "hello text 2 at line 43" 分别为:

import androidx.test.espresso.contrib.RecyclerViewActions
import it.xabaras.android.espresso.recyclerviewchildactions.RecyclerViewChildActions.Companion.childOfViewAtPositionWithMatcher

// ...

        val index = 42 // index is 0 based, corresponds to row 43 from user's perspective

        onView(withId(R.id.myRecyclerView))
            .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(43))
            .check(
                matches(
                    allOf(
                        childOfViewAtPositionWithMatcher(
                            R.id.myTextView1,
                            index,
                            withText("hello text 1 at line 43")
                        ),
                        childOfViewAtPositionWithMatcherMy(
                            R.id.myTextView2,
                            index,
                            withText("hello text 2 at line 43")
                        )
                    )
                )
            )

这是我的 build.gradle 依赖项(可能存在较新的版本):

androidTestImplementation "androidx.test.espresso:espresso-contrib:3.2.0"
androidTestImplementation "it.xabaras.android.espresso:recyclerview-child-actions:1.0"

当您检查您的项目(TextView 中的文本)是否位于 RecyclerView 中的第一个时,修改了匹配器解决方案。

// True if first item in the RV and input text
fun <T> customMatcherForFirstItem(name: String, matcher: Matcher<T>): Matcher<T> {
    return object: BaseMatcher<T> () {
        var isFirst = true
        override fun matches(item: Any): Boolean {
            if (isFirst && (item as TextView).text == name) {
                isFirst = false
                return true
            }
            return false
        }
        override fun describeTo(description: Description?) {}
    }
}

用法:(returns 如果项目中的子文本视图具有与我们相同的文本,则为真 expect/search)

    onView(withText("TEXT_VIEW_TEXT")).check(matches(
        customMatcherForFirstItem("TEXT_VIEW_TEXT", withParent(withId(R.id.recycler_view)))))

基于 -> https://proandroiddev.com/working-with-recycler-views-in-espresso-tests-6da21495182c

对于 Kotlin 用户: 我知道我来晚了,我很困惑为什么人们必须自定义 RecycleView Actions。我一直想达到完全相同的预期结果:这是我的解决方案,希望它有效并帮助新手。也对我放轻松,因为我可能会被阻止。

onView(withId(R.id.recycler_view))
.perform(actionOnItemAtPosition<ViewHolder>(46, scrollTo()))
.check(matches(hasDescendant(withText("TextToMatch"))))

//21 年 12 月 18 日编辑:在调查 Bjorn 的评论后,请参阅下面的更好方法:

onView(withId(R.id.recyclerView))
.perform(RecyclerViewActions.scrollToPosition<ViewHolder>(12))
.check(matches(hasDescendant(withText("TexttoMatch"))))

这是我使用 kotlin 的解决方案,带有适当的“未找到”描述,它应该适用于大多数用例:

如果您只想检查给定索引处的条目中是否存在某些内容,您可以使用 withPositionInRecyclerViewpositionrecyclerview 进行匹配id 查找行并使用 hasDescendant(...) 与您喜欢的任何匹配器结合使用:

onView(withPositionInRecyclerView(positionToLookAt, R.id.my_recycler_view))
  .check(matches(hasDescendant(withText("text that should be somewhere at given position"))))

请注意,您可能必须先使用 actionOnItemAtPosition 在 recyclerView 上滚动才能显示项目。

如果您想更精确并确保给定索引处的条目中的特定视图匹配,只需添加视图 ID 作为第三个参数:

onView(withPositionInRecyclerView(positionToLookAt, R.id.my_recycler_view, R.id.my_view))
  .check(matches(withText("text that should be in view with id R.id.my_view at given position")))

下面是您需要在测试代码中包含的辅助方法:

fun withPositionInRecyclerView(position: Int, recyclerViewId: Int, childViewId: Int = -1): Matcher<View> =
    object : TypeSafeMatcher<View>() {

        private var resources: Resources? = null
        private var recyclerView: RecyclerView? = null
        override fun describeTo(description: Description?) {
            description?.appendText("with position $position in recyclerView")
            if (resources != null) {
                try {
                    description?.appendText(" ${resources?.getResourceName(recyclerViewId)}")
                } catch (exception: Resources.NotFoundException) {
                    description?.appendText(" (with id $recyclerViewId not found)")
                }
            }
            if (childViewId != -1) {
                description?.appendText(" with child view ")
                try {
                    description?.appendText(" ${resources?.getResourceName(childViewId)}")
                } catch (exception: Resources.NotFoundException) {
                    description?.appendText(" (with id $childViewId not found)")
                }
            }
        }

        override fun matchesSafely(view: View?): Boolean {
            if (view == null) return false
            if (resources == null) {
                resources = view.resources;
            }
            if (recyclerView == null) {
                recyclerView = view.rootView.findViewById(recyclerViewId)
            }
            return if (childViewId != -1) {
                view == recyclerView?.findViewHolderForAdapterPosition(position)?.itemView?.findViewById(childViewId)
            } else {
                view == recyclerView?.findViewHolderForAdapterPosition(position)?.itemView;
            }
        }
    }

我写了一个函数,可以检查 recyclerview 中视图持有者的每个视图。这是我的功能:

recyclerViewId 是你的 recyclerview id

position是你的recyclerview的位置

itemMatcher 是你的匹配函数

subViewId是recyclerview子view(viewHolder的view)的id

    public static void checkRecyclerSubViews(int recyclerViewId, int position, Matcher<View> itemMatcher, int subViewId)
{
    onView(withId(recyclerViewId)).perform(RecyclerViewActions.scrollToPosition(position))
                                  .check(matches(atPositionOnView(position, itemMatcher, subViewId)));
}


    public static Matcher<View> atPositionOnView(
        final int position, final Matcher<View> itemMatcher, @NonNull final int targetViewId)
{

    return new BoundedMatcher<View, RecyclerView>(RecyclerView.class)
    {
        @Override
        public void describeTo(Description description)
        {
            description.appendText("has view id " + itemMatcher + " at position " + position);
        }

        @Override
        public boolean matchesSafely(final RecyclerView recyclerView)
        {
            RecyclerView.ViewHolder viewHolder = recyclerView.findViewHolderForAdapterPosition(position);
            View targetView = viewHolder.itemView.findViewById(targetViewId);
            return itemMatcher.matches(targetView);
        }
    };
}

这是使用的例子:

checkRecyclerSubViews(R.id.recycler_view, 5, withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE), R.id.text_view_plan_tittle);

checkRecyclerSubViews(R.id.recycler_view, 5, withText("test"), R.id.text_view_delete);

这是我对 Kotlin 的看法,基于一些 。目标:

  • RecyclerView 或找不到目标视图时的有用错误消息(例如,如果位置是 out-of-bounds,则没有 NullPointerException
  • 在目标视图与预期不符的情况下提供有用的错误消息(在这种情况下,所有以 onView(withId(R.id.recycler_view)) 开头的解决方案仅打印有关 RecyclerView 本身的详细信息)
  • 在确切位置检查项目,而不仅仅是
  • 可用于匹配项的根视图或其特定后代
  • 可用于对匹配的视图执行操作
  • 测试屏幕上显示的真实视图,而不是
  • 避免不必要的工作
  • 最小样板,简洁,但可读

不幸的是,Matcher不能自动滚动RecyclerViewmatchesSafely在UI线程上运行,不能等到空闲),所以应该是单独完成。用法:

onView(withId(R.id.recycler_view))
    .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(42))
onRecyclerViewItem(R.id.recycler_view, 42)
    .check(matches(isAssignableFrom(ConstraintLayout::class.java)))
onRecyclerViewItem(R.id.recycler_view, 42, R.id.text)
    .check(matches(withText("Item 42")))
onRecyclerViewItem(R.id.recycler_view, 42, R.id.button).perform(click())

代码:

fun onRecyclerViewItem(
    recyclerViewId: Int,
    position: Int,
    targetViewId: Int? = null
): ViewInteraction = onView(object : TypeSafeMatcher<View>() {

    private lateinit var resources: Resources
    private var recyclerView: RecyclerView? = null
    private var holder: RecyclerView.ViewHolder? = null
    private var targetView: View? = null

    override fun describeTo(description: Description) {
        fun Int.name(): String = try {
            "R.id.${resources.getResourceEntryName(this)}"
        } catch (e: Resources.NotFoundException) {
            "unknown id $this"
        }

        val text = when {
            recyclerView == null -> "RecyclerView (${recyclerViewId.name()})"
            holder == null || targetViewId == null -> "in RecyclerView (${recyclerViewId.name()}) at position $position"
            else -> "in RecyclerView (${recyclerViewId.name()}) at position $position and with ${targetViewId.name()}"
        }
        description.appendText(text)
    }

    override fun matchesSafely(view: View): Boolean {
        // matchesSafely will be called for each view in the hierarchy (until found),
        // it makes no sense to perform lookup over and over again
        if (!::resources.isInitialized) {
            resources = view.resources
            recyclerView = view.rootView.findViewById(recyclerViewId) ?: return false
            holder = recyclerView?.findViewHolderForAdapterPosition(position) ?: return false
            targetView = holder?.itemView?.let {
                if (targetViewId != null) it.findViewById(targetViewId) else it
            }
        }
        return view === targetView
    }
})

我已经将 onView 和匹配器 class 直接合并到其中,因为我只这样使用它,并且 onRecyclerViewItem(...) 看起来比 onView(withPositionInRecyclerView(...)) 更具可读性。如果您愿意,可以提取匹配器函数 and/or class。相同的代码“在野外”,带有导入、KDoc 和我重写的 scrollToPosition can be found here.