如何在浓缩咖啡回收视图中断言?

我正在使用 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")))
)
);

但是,因为 回收行动是,当然,期待一个动作,它说错误的第二个参数类型。没有 RecyclerViewAssertions的浓缩咖啡贡献。

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

62267 次浏览

You should check out Danny Roa's solution Custom RecyclerView Actions And use it like this:

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

Pretty easy. No extra library is needed. Do:

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

if your ViewHolder uses ViewGroup, wrap withText() with a hasDescendant() like:

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

with method you may put into your 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);
}
};
}

If your item may be not visible on the screen at first, then scroll to it before:

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

Just to enhance riwnodennyk's answer, if your ViewHolder is a ViewGroup instead of a direct View object like TextView, then you can add hasDescendant matcher to match the TextView object in the ViewGroup. Example:

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

Danny Roa's solution is awesome, but it didn't work for the off-screen items I had just scrolled to. The fix is replacing this:

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

with this:

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

... in the TestUtils class.

In my case, it was necessary to merge Danny Roa's and riwnodennyk's solution:

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

and the method :

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);
}
};
}

I have been struggling on the same issue where I have a recycler view with 6 items and then I have to choose the one at position 5 and the 5th one is not visible on the view.
The above solution also works. Also, I know its a bit late but thought its worth sharing.

I did this with a simple solution as below:

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

RecyclerViewActions.actionOnItem - Performs a ViewAction on a view matched by viewHolderMatcher.

  1. Scroll RecyclerView to the view matched by itemViewMatcher
  2. Perform an action on the matched view

More details can be found at : RecyclerViewActions.actionOnItem

This thread is pretty old but I recently extended the RecyclerViewAction support to be usable for assertions as well. This allows you to test off-screen elements without specifying a position while still using view matchers.

Check out this article > Better Android RecyclerView Testing!

For testing all items (include off-screen) use code bellow

Sample:

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")
}
}

}

Solution based of this article > Better Android RecyclerView Testing!

I combined a few answers above into a reusable method for testing other recycler views as the project grows. Leaving the code here in the event it helps anyone else out...

Declare a function that may be called on a RecyclerView Resource ID:

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

Then call it on your recycler view to check the text displayed of multiple adapter items in the list at a given position by doing:

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)
}

The solutions above are great, I'm contributing one more in Kotlin.

Espresso needs to scroll to the position and then check it. To scroll to the position, I'm using RecyclerViewActions.scrollToPosition. Then use findViewHolderForAdapterPosition to perform the check. Instead of calling findViewHolderForAdapterPosition directly, I am using a nicer convenience function childOfViewAtPositionWithMatcher.

In the example code below, Espresso scrolls to row 43 of the RecyclerView and the checks that the row's ViewHolder has two TextViews and that those TextViews contain the text "hello text 1 at line 43" and "hello text 2 at line 43" respectively:

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")
)
)
)
)

Here are my build.gradle dependencies (newer versions may exist):

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

Modified matcher solution for when you check if your item(Text in TextView) is first in 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?) {}
}
}

Usage: (returns true if child textview in the item has the same text as we expect/search)

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

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

For Kotlin users: I know I am very late, and I am bemused why people are having to customize RecycleView Actions. I have been meaning to achieve exactly the same desired outcome: here is my solution, hope it works and helps newcomers. Also be easy on me, as I might get blocked.

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

//Edited 18/12/21: After investigating Bjorn's comment, please see a better approach below:

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

Here is my solution using kotlin with proper "not found" descriptions and it should apply to most usecases:

If you only want to check that something exists within the entry at the given index, you can match using withPositionInRecyclerView with position and recyclerview id to find the row and use hasDescendant(...) combined with whatever matcher you like:

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

Please note that you might have to scroll on the recyclerView using actionOnItemAtPosition first so that the item is displayed.

If you want to be more precise and make sure that a specific view within the entry at the given index matches, just add the view id as a third parameter:

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")))

So here is the helper method you will need to include somewhere in your test code:

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;
}
}
}

I have written a function that can check every view of view holder in recyclerview. Here is my function :

recyclerViewId is your recyclerview id

position is the position of your recyclerview

itemMatcher is your matcher function

subViewId is the id of recyclerview sub view (view of viewHolder)

    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);
}
};
}

And this is the examaple of using :

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);

Here is my take in Kotlin, based on some already published solutions. Objectives:

  • Helpful error message in case RecyclerView or target view can't be found (e.g. no NullPointerException if position is out-of-bounds)
  • Helpful error message in case target view does not match expectations (all solutions starting with onView(withId(R.id.recycler_view)) just print details about RecyclerView itself in this case)
  • Checks item at exact position, not just any visible child that matches
  • Can be used to match either item's root view or its specific descendant
  • Can be used to perform actions on matched view
  • Tests real view displayed on the screen, not artificially created one
  • Avoids unnecessary work
  • Minimal boilerplate, concise, but readable

Unfortunately, Matcher can't scroll RecyclerView automatically (matchesSafely runs on UI thread and can't wait until it's idle), so it should be done separately. Usage:

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())

Code:

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
}
})

I've merged onView and matcher class directly into it, because I only use it this way, and onRecyclerViewItem(...) looks more readable then onView(withPositionInRecyclerView(...)). Matcher function and/or class can be extracted if you prefer so. The same code "in the wild", with imports, KDoc and my rewrite of scrollToPosition can be found here.