11

I want to test the text contained in each ViewHolder of my RecyclerView:

@RunWith(AndroidJUnit4.class)
public class EspressoTest {

    private Activity mMainActivity;
    private RecyclerView mRecyclerView;
    private int res_ID = R.id.recycler_view_ingredients;
    private int itemCount = 0;

    //TODO: What is the purpose of this rule as it relates to the Test below?
    @Rule
    public ActivityTestRule<MainActivity> firstRule = new ActivityTestRule<>(MainActivity.class);


    //TODO: Very confused about Espresso testing and the dependencies required; it appears Recyclerview
    //TODO: Requires additional dependencies other than those mentioned in the Android documentation?
    //TODO: What would be best method of testing all views of RecyclerView? What is there is a dynamic number of Views that are populated in RecyclerView?


    //TODO: Instruction from StackOverflow Post: https://stackoverflow.com/questions/51678563/how-to-test-recyclerview-viewholder-text-with-espresso/51698252?noredirect=1#comment90433415_51698252
    //TODO: Is this necessary?
    @Before
    public void setupTest() {
        this.mMainActivity = this.firstRule.getActivity();
        this.mRecyclerView = this.mMainActivity.findViewById(this.res_ID);
        this.itemCount = this.mRecyclerView.getAdapter().getItemCount();

    }

    @Test
    public void testRecyclerViewClick() {
        Espresso.onView(ViewMatchers.withId(R.id.recycler_view_ingredients)).perform(RecyclerViewActions.actionOnItemAtPosition(1, ViewActions.click()));
    }

    //CANNOT CALL THIS METHOD, THE DEPENDENCIES ARE INCORRECT
    @Test
    public void testRecyclerViewText() {
        // Check item at position 3 has "Some content"
        onView(withRecyclerView(R.id.scroll_view).atPosition(3))
                .check(matches(hasDescendant(withText("Some content"))));


        }
     }
}

Below is my gradle as well, I never understood what separate dependencies are required for RecyclerView testing:

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation 'com.android.support:recyclerview-v7:27.1.1'
    implementation 'com.google.android.exoplayer:exoplayer:2.6.1'
    implementation 'com.android.support:appcompat-v7:27.1.1'
    implementation 'com.android.support.constraint:constraint-layout:1.1.2'
    implementation 'com.android.support:support-v4:27.1.1'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:testing-support-lib:0.1'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:2.0'
    androidTestImplementation('com.android.support.test.espresso:espresso-contrib:2.0') {
        exclude group: 'com.android.support', module: 'appcompat'
        exclude group: 'com.android.support', module: 'support-v4'
        exclude module: 'recyclerview-v7'
    }
    implementation 'com.android.support:support-annotations:27.1.1'
    implementation 'com.squareup.okhttp3:okhttp:3.10.0'
    implementation 'com.google.code.gson:gson:2.8.2'
    implementation 'com.squareup.retrofit2:converter-gson:2.4.0'
    implementation 'com.squareup.okhttp3:logging-interceptor:3.10.0'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
    implementation 'com.android.support:cardview-v7:27.1.1'
    implementation 'com.google.android.exoplayer:exoplayer:2.6.0'
}

Also, what if the RecyclerView populates data dynamically? Then you simply could not hard code the position you wanted to test....

Martin Zeitler
  • 1
  • 19
  • 155
  • 216
tccpg288
  • 3,242
  • 5
  • 35
  • 80

3 Answers3

13

Espresso package espresso-contrib is necessary, because it provides those RecyclerViewActions, which do not support assertions.

import android.support.test.espresso.contrib.RecyclerViewActions;
import android.support.test.rule.ActivityTestRule;
import android.support.test.runner.AndroidJUnit4;

import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

import static android.support.test.espresso.Espresso.onView;
import static android.support.test.espresso.action.ViewActions.click;
import static android.support.test.espresso.assertion.ViewAssertions.matches;
import static android.support.test.espresso.matcher.ViewMatchers.withId;
import static android.support.test.espresso.matcher.ViewMatchers.withText;

@RunWith(AndroidJUnit4.class)
public class TestIngredients {

    /** the Activity of the Target application */
    private IngredientsActivity mActivity;

    /** the {@link RecyclerView}'s resource id */
    private int resId = R.id.recyclerview_ingredients;

    /** the {@link RecyclerView} */
    private IngredientsLinearView mRecyclerView;

    /** and it's item count */
    private int itemCount = 0;

    /**
     * such a {@link ActivityTestRule} can be used eg. for Intent.putExtra(),
     * alike one would pass command-line arguments to regular run configurations.
     * this code runs before the {@link FragmentActivity} is being started.
     * there also would be an {@link IntentsTestRule}, but not required here.
    **/
    @Rule
    public ActivityTestRule<IngredientsActivity> mActivityRule = new ActivityTestRule<IngredientsActivity>(IngredientsActivity.class) {

        @Override
        protected Intent getActivityIntent() {
            Intent intent = new Intent();
            Bundle extras = new Bundle();
            intent.putExtras(extras);
            return intent;
        }
    };

    @Before
    public void setUpTest() {

        /* obtaining the Activity from the ActivityTestRule */
        this.mActivity = this.mActivityRule.getActivity();

        /* obtaining handles to the Ui of the Activity */
        this.mRecyclerView = this.mActivity.findViewById(this.resId);
        this.itemCount = this.mRecyclerView.getAdapter().getItemCount();
    }

    @Test
    public void RecyclerViewTest() {
        if(this.itemCount > 0) {
            for(int i=0; i < this.itemCount; i++) {

                /* clicking the item */
                onView(withId(this.resId))
                  .perform(RecyclerViewActions.actionOnItemAtPosition(i, click()));

                /* check if the ViewHolder is being displayed */
                onView(new RecyclerViewMatcher(this.resId)
                  .atPositionOnView(i, R.id.cardview))
                  .check(matches(isDisplayed()));

                /* checking for the text of the first one item */
                if(i == 0) {
                    onView(new RecyclerViewMatcher(this.resId)
                      .atPositionOnView(i, R.id.ingredientName))
                      .check(matches(withText("Farbstoffe")));
                }

            }
        }
    }
}

Instead one can use a RecyclerViewMatcher for that:

public class RecyclerViewMatcher {

    private final int recyclerViewId;

    public RecyclerViewMatcher(int recyclerViewId) {
        this.recyclerViewId = recyclerViewId;
    }

    public Matcher<View> atPosition(final int position) {
        return atPositionOnView(position, -1);
    }

    public Matcher<View> atPositionOnView(final int position, final int targetViewId) {
        return new TypeSafeMatcher<View>() {
            Resources resources = null;
            View childView;
            public void describeTo(Description description) {
                String idDescription = Integer.toString(recyclerViewId);
                if(this.resources != null) {
                    try {
                        idDescription = this.resources.getResourceName(recyclerViewId);
                    } catch (Resources.NotFoundException var4) {
                        idDescription = String.format("%s (resource name not found)",
                        new Object[] {Integer.valueOf(recyclerViewId) });
                    }
                }
                description.appendText("with id: " + idDescription);
            }

            public boolean matchesSafely(View view) {
                this.resources = view.getResources();
                if (childView == null) {
                    RecyclerView recyclerView = (RecyclerView) view.getRootView().findViewById(recyclerViewId);
                    if (recyclerView != null && recyclerView.getId() == recyclerViewId) {
                        childView = recyclerView.findViewHolderForAdapterPosition(position).itemView;
                    } else {
                        return false;
                    }
                }
                if (targetViewId == -1) {
                    return view == childView;
                } else {
                    View targetView = childView.findViewById(targetViewId);
                    return view == targetView;
                }
            }
        };
    }
}

screen recorder

Martin Zeitler
  • 1
  • 19
  • 155
  • 216
  • Thanks Bro really appreciate your explicit response – tccpg288 Aug 07 '18 at 22:34
  • @tccpg288 also can use the code; have noticed that a `longClick()` on items, with a context-menu appear to be more tricky, because one has to obtain a handle to the menu's adapter then; and also just closing that menu seems to be more difficult than one would imagine. – Martin Zeitler Aug 07 '18 at 22:41
  • where does IngredientsLinearView come from? Not understanding that ViewType – tccpg288 Aug 09 '18 at 01:29
  • it's just a `RecyclerView`, with a `LinearLayoutManager` (eg. in order to tell it apart from the `IngredientsGridView`), which can be substituted with just any `RecyclerView`, with whatever `RecyclerView.LayoutManager`... the `Javadoc` markup above the declaration tells, that it is supposed to be a `RecyclerView` – Martin Zeitler Aug 09 '18 at 10:56
  • I updated my question, not sure if you're logic still applies. I cannot access RecyclerViewMatcher – tccpg288 Aug 11 '18 at 19:57
  • @tccpg288 the answer had the link to that class; now I've even added it here. and please accept the answer, because it a) clicks each single item and b) is able to access the text of each single item. – Martin Zeitler Aug 11 '18 at 20:24
  • @tccpg288 now even added a GIF. and it does not matter if this is populated statically from an array resource or dynamically from a database... comparing the text of the `TextView` might be pointless, because one can probably assume, that the same query (or the same array index) might deliver the same result. accessibility is rather relevant (eg. bottom item not being covered by a toolbar). – Martin Zeitler Aug 13 '18 at 06:51
  • So you are creating a custom class? RecylcerViewMatcher? – tccpg288 Aug 15 '18 at 00:21
  • Also, very confused, are you still using the dependency for rules and annotations? it does not appear to be included in the GitHub repo – tccpg288 Aug 15 '18 at 00:28
  • @tccpg288 obviously the `RecylcerViewMatcher` class is not only being declared, but being used to get a handle to the item views. the GitHub link goes straight to that class, which also is also included here and the other dependency is `espresso-contrib`, as already stated in the answer. – Martin Zeitler Aug 15 '18 at 01:05
  • It tried the custom solution. It appears there are additional dependencies needed that were not included in github. I saw references to hamcrest dependencies that were not listed in the build.gradle of the github? – tccpg288 Aug 15 '18 at 01:18
  • I was not able to import Matcher and TypeSafeMatcher – tccpg288 Aug 15 '18 at 01:21
  • @tccpg288 they're in package `org.hamcrest`. the IDE ordinary should suggest most of these; eg. when clicking onto red text, then hitting ` + `. – Martin Zeitler Aug 15 '18 at 05:43
  • Yes I am typically able to import, but in this case it did not happen – tccpg288 Aug 15 '18 at 12:06
  • 1
    @tccpg288 added the relevant dependencies to the code. you may want to remove the `com.android.support.test:testing-support-lib` and update espresso to `3.0.2`, and the test-rules & test-runner to `1.0.2`. – Martin Zeitler Aug 15 '18 at 14:07
  • appreciate your help, can you post your full gradle dependencies? I am confused why I am calling Espresso.onView() whereas you are simply calling onView() – tccpg288 Aug 18 '18 at 18:33
  • @tccpg288 I've already added those dependencies, see those `import static`. – Martin Zeitler Aug 19 '18 at 00:03
3

RecyclerViewMatcher from @Martin Zeitler's answer with more informative error reporting.

import android.view.View;

import android.content.res.Resources;
import androidx.recyclerview.widget.RecyclerView;

import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;

import static com.google.common.base.Preconditions.checkState;

public class RecyclerViewMatcher {

    public static final int UNSPECIFIED = -1;
    private final int recyclerId;

    public RecyclerViewMatcher(int recyclerViewId) {
        this.recyclerId = recyclerViewId;
    }

    public Matcher<View> atPosition(final int position) {
        return atPositionOnView(position, UNSPECIFIED);
    }

    public Matcher<View> atPositionOnView(final int position, final int targetViewId) {
        return new TypeSafeMatcher<View>() {
            Resources resources;
            RecyclerView recycler;
            RecyclerView.ViewHolder holder;

            @Override
            public void describeTo(Description description) {
                checkState(resources != null, "resource should be init by matchesSafely()");

                if (recycler == null) {
                    description.appendText("RecyclerView with " + getResourceName(recyclerId));
                    return;
                }

                if (holder == null) {
                    description.appendText(String.format(
                            "in RecyclerView (%s) at position %s",
                            getResourceName(recyclerId), position));
                    return;
                }

                if (targetViewId == UNSPECIFIED) {
                    description.appendText(
                            String.format("in RecyclerView (%s) at position %s",
                            getResourceName(recyclerId), position));
                    return;
                }

                description.appendText(
                        String.format("in RecyclerView (%s) at position %s and with %s",
                                getResourceName(recyclerId),
                                position,
                                getResourceName(targetViewId)));
            }

            private String getResourceName(int id) {
                try {
                    return "R.id." + resources.getResourceEntryName(id);
                } catch (Resources.NotFoundException ex) {
                    return String.format("resource id %s - name not found", id);
                }
            }

            @Override
            public boolean matchesSafely(View view) {
                resources = view.getResources();
                recycler = view.getRootView().findViewById(recyclerId);
                if (recycler == null)
                    return false;
                holder = recycler.findViewHolderForAdapterPosition(position);
                if (holder == null)
                    return false;

                if (targetViewId == UNSPECIFIED) {
                    return view == holder.itemView;
                } else {
                    return view == holder.itemView.findViewById(targetViewId);
                }
            }
        };
    }
}
Ivo Mori
  • 2,177
  • 5
  • 24
  • 35
GrzegDev
  • 135
  • 1
  • 10
0

You can easily access to the viewHolder witht the recyclerView template onView(withId(R.id.your_list_id)).perform(actionOnItem<RecyclerView.ViewHolder>(withText(the_text_you_want), click()))