Android Espresso: Testing Preferences (multiple ListViews)

1

I'm trying to test my SettingsActivity class, but I keep getting a AmbiguousViewMatcherException.

Here's my testcase:

@Test
public void whenHospitalSettingEmpty_shouldDisplaySummary() throws Exception {
    Resources res = getInstrumentation().getTargetContext().getResources();

    mPreferenceEditor.putString(
            res.getString(R.string.activity_settings_hospital_key),
            "");

    mActivityTestRule.launchActivity(null);

    String expected = res.getString(R.string.activity_settings_hospital_summary);

    onData(allOf(
            is(instanceOf(Preference.class)),
            withKey(res.getString(R.string.activity_settings_hospital_key)),
            withSummary(R.string.activity_settings_hospital_summary),
            withTitle(R.string.activity_settings_hospital_title)))
            .onChildView(withText(expected))
            .check(matches(isDisplayed()));

}

Here's the log output:

android.support.test.espresso.AmbiguousViewMatcherException: 'is assignable from class: class android.widget.AdapterView' matches multiple views in the hierarchy.
Problem views are marked with '****MATCHES****' below.

View Hierarchy:
...
+------->LinearLayout{id=16909142, res-name=headers, visibility=VISIBLE, width=600, height=887, has-focus=false, has-focusable=false, has-window-focus=true, is-clickable=false, is-enabled=true, is-focused=false, is-focusable=false, is-layout-requested=false, is-selected=false, root-is-layout-requested=false, has-input-connection=false, x=0.0, y=0.0, child-count=2}
|
+-------->ListView{id=16908298, res-name=list, visibility=VISIBLE, width=536, height=823, has-focus=false, has-focusable=false, has-window-focus=true, is-clickable=true, is-enabled=true, is-focused=false, is-focusable=false, is-layout-requested=false, is-selected=false, root-is-layout-requested=false, has-input-connection=false, x=32.0, y=32.0, child-count=0} ****MATCHES******
|
+-------->FrameLayout{id=16909143, res-name=list_footer, visibility=VISIBLE, width=536, height=0, has-focus=false, has-focusable=false, has-window-focus=true, is-clickable=false, is-enabled=true, is-focused=false, is-focusable=false, is-layout-requested=false, is-selected=false, root-is-layout-requested=false, has-input-connection=false, x=32.0, y=855.0, child-count=0}
|
+------>RelativeLayout{id=16909146, res-name=button_bar, visibility=GONE, width=0, height=0, has-focus=false, has-focusable=false, has-window-focus=true, is-clickable=false, is-enabled=true, is-focused=false, is-focusable=false, is-layout-requested=true, is-selected=false, root-is-layout-requested=false, has-input-connection=false, x=0.0, y=0.0, child-count=2}
|
+------->AppCompatButton{id=16909147, res-name=back_button, visibility=VISIBLE, width=0, height=0, has-focus=false, has-focusable=true, has-window-focus=true, is-clickable=true, is-enabled=true, is-focused=false, is-focusable=true, is-layout-requested=true, is-selected=false, root-is-layout-requested=false, has-input-connection=false, x=0.0, y=0.0, text=Tilbage, input-type=0, ime-target=false, has-links=false}
|
+------->LinearLayout{id=-1, visibility=VISIBLE, width=0, height=0, has-focus=false, has-focusable=true, has-window-focus=true, is-clickable=false, is-enabled=true, is-focused=false, is-focusable=false, is-layout-requested=true, is-selected=false, root-is-layout-requested=false, has-input-connection=false, x=0.0, y=0.0, child-count=2}
|
+-------->AppCompatButton{id=16909148, res-name=skip_button, visibility=GONE, width=0, height=0, has-focus=false, has-focusable=false, has-window-focus=true, is-clickable=true, is-enabled=true, is-focused=false, is-focusable=true, is-layout-requested=true, is-selected=false, root-is-layout-requested=false, has-input-connection=false, x=0.0, y=0.0, text=Spring over, input-type=0, ime-target=false, has-links=false}
|
+-------->AppCompatButton{id=16909149, res-name=next_button, visibility=VISIBLE, width=0, height=0, has-focus=false, has-focusable=true, has-window-focus=true, is-clickable=true, is-enabled=true, is-focused=false, is-focusable=true, is-layout-requested=true, is-selected=false, root-is-layout-requested=false, has-input-connection=false, x=0.0, y=0.0, text=Næste, input-type=0, ime-target=false, has-links=false}
|
+----->LinearLayout{id=-1, visibility=VISIBLE, width=600, height=887, has-focus=true, has-focusable=true, has-window-focus=true, is-clickable=false, is-enabled=true, is-focused=false, is-focusable=false, is-layout-requested=false, is-selected=false, root-is-layout-requested=false, has-input-connection=false, x=0.0, y=0.0, child-count=3}
|
+------>ListView{id=16908298, res-name=list, visibility=VISIBLE, width=600, height=887, has-focus=true, has-focusable=true, has-window-focus=true, is-clickable=true, is-enabled=true, is-focused=true, is-focusable=true, is-layout-requested=false, is-selected=false, root-is-layout-requested=false, has-input-connection=false, x=0.0, y=0.0, child-count=12} ****MATCHES******
|
+------->AppCompatTextView{id=16908310, res-name=title, visibility=VISIBLE, width=584, height=35, has-focus=false, has-focusable=false, has-window-focus=true, is-clickable=false, is-enabled=true, is-focused=false, is-focusable=false, is-layout-requested=false, is-selected=false, root-is-layout-requested=false, has-input-connection=false, x=8.0, y=0.0, text=Synkronisering, input-type=0, ime-target=false, has-links=false}
...

For some reason the onData() matches two ListViews, but I just can't figure out how to prevent this, because I don't understand where the first ListView comes from.

Here's my preference.xml:

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
    <PreferenceCategory
        android:title="@string/activity_settings_sync_category_title"
        android:key="@string/activity_settings_sync_category_key">

        <SwitchPreference
            android:key="@string/activity_settings_sync_key"
            android:summary="@string/activity_settings_sync_summary"
            android:title="@string/activity_settings_sync_title"
            android:defaultValue="true"/>

    </PreferenceCategory>

    <PreferenceCategory
        android:title="@string/activity_settings_site_category_title"
        android:key="@string/activity_settings_site_category_key"
        android:summary="@string/activity_settings_site_category_summary">


        <EditTextPreference
            android:key="@string/activity_settings_hospital_key"
            android:title="@string/activity_settings_hospital_title"
            android:summary="@string/activity_settings_hospital_summary"
            android:dialogTitle="@string/activity_settings_hospital_dialog_title"
            android:dialogMessage="@string/activity_settings_hospital_dialog_message"
            android:inputType="textCapWords" />

        <EditTextPreference
            android:key="@string/activity_settings_department_key"
            android:title="@string/activity_settings_department_title"
            android:summary="@string/activity_settings_department_summary"
            android:dialogTitle="@string/activity_settings_department_dialog_title"
            android:dialogMessage="@string/activity_settings_department_dialog_message"
            android:inputType="textCapWords" />

    </PreferenceCategory>

    <PreferenceCategory
        android:title="@string/activity_settings_notification_ringtone_category_title"
        android:key="@string/activity_settings_notification_ringtone_category_key">


        <RingtonePreference
            android:key="@string/activity_settings_notification_ringtone_key"
            android:title="@string/activity_settings_notification_ringtone_title"
            android:summary="@string/activity_settings_notification_ringtone_summary"
            android:dialogTitle="@string/activity_settings_notification_ringtone_dialog_title"
            android:dialogMessage="@string/activity_settings_notification_ringtone_dialog_message"/>

    </PreferenceCategory>

    <PreferenceCategory
        android:title="@string/activity_settings_dicom_category_title"
        android:key="@string/activity_settings_dicom_category_key">

        <SwitchPreference
                android:key="@string/activity_settings_dicom_worklist_key"
                android:title="@string/activity_settings_dicom_worklist_title"
                android:summary="@string/activity_settings_dicom_worklist_summary" />

            <!--<EditTextPreference-->
                <!--android:key="dcmwl_ae_title_preference"-->
                <!--android:title="@string/pref_dicom_settings_worklist_ae_title"-->
                <!--android:dialogMessage="@string/pref_dicom_settings_worklist_ip_dialog_text"-->
                <!--android:dialogTitle="@string/pref_dicom_settings_worklist_ip_dialog_title"-->
                <!--android:dependency="dcmwl_enable"/>-->
            <!--<EditTextPreference-->
                <!--android:defaultValue="10.0.0.2"-->
                <!--android:key="dcmwl_ip_preference"-->
                <!--android:title="@string/pref_dicom_settings_worklist_ip"-->
                <!--android:dependency="dcmwl_enable" />-->
            <!--<EditTextPreference-->
                <!--android:key="dcmwl_port_preference"-->
                <!--android:title="@string/pref_dicom_settings_worklist_port"-->
                <!--android:dependency="dcmwl_enable"/>-->

        <SwitchPreference
            android:key="@string/activity_settings_dicom_pacs_key"
            android:title="@string/activity_settings_dicom_pacs_title"
            android:summary="@string/activity_settings_dicom_pacs_summary"/>

            <!--<EditTextPreference-->
                <!--android:key="pacs_ae_title_preference"-->
                <!--android:title="@string/pref_dicom_settings_pacs_ae_title"-->
                <!--android:dependency="pacs_enable"/>-->
            <!--<EditTextPreference-->
                <!--android:defaultValue="10.0.0.2"-->
                <!--android:key="pacs_ip_preference"-->
                <!--android:title="@string/pref_dicom_settings_pacs_ip"-->
                <!--android:dependency="pacs_enable"/>-->
            <!--<EditTextPreference-->
                <!--android:key="pacs_port_preference"-->
                <!--android:title="@string/pref_dicom_settings_pacs_port"-->
                <!--android:dependency="pacs_enable"/>-->

    </PreferenceCategory>

    <PreferenceCategory
        android:title="@string/activity_settings_debug_category_title"
        android:key="@string/activity_settings_debug_category_key">

        <SwitchPreference
            android:key="@string/activity_settings_debug_key"
            android:summary="@string/activity_settings_debug_summary"
            android:title="@string/activity_settings_debug_title"
            android:defaultValue="false"/>

    </PreferenceCategory>

</PreferenceScreen>

Update: Tried to modify the proposed solution from first answer like this:

private static Matcher<View> withResName(final String resName) {

        return new TypeSafeMatcher<View>() {
            @Override
            public void describeTo(Description description) {
                description.appendText("with res-name: " + resName);
                Timber.d("Resourcename: ", resName);
            }

            @Override
            public boolean matchesSafely(View view) {
                Timber.d("View ID: ", view.getId());
                String matchableResName = view.getResources().getResourceEntryName(view.getId());
                return !TextUtils.isEmpty(matchableResName) && matchableResName.equals(resName);
            }
        };
    }

The test looks like this now:

@Test
    public void whenHospitalSettingEmpty_shouldDisplaySummary() throws Exception {
        Resources res = getInstrumentation().getTargetContext().getResources();

        mPreferenceEditor.putString(
                res.getString(R.string.activity_settings_hospital_key),
                "").commit();

        mActivityTestRule.launchActivity(null);

        String expected = res.getString(R.string.activity_settings_hospital_summary);

//        onView(withText(expected)).check(matches(isDisplayed()));

        onData(allOf(
                is(instanceOf(Preference.class)),
                withKey(res.getString(R.string.activity_settings_hospital_key)),
                withSummary(R.string.activity_settings_hospital_summary),
                withTitle(R.string.activity_settings_hospital_title)))
                .onChildView(withText(expected))
                .inAdapterView(withParent(not(withResName("headers"))))
                .check(matches(isDisplayed()));


    }

But it still doesn't work. The log output looks like this:

...
I/TestRunner: android.content.res.Resources$NotFoundException:
 Unable to find resource ID #0xffffffff
    at android.content.res.Resources.getResourceEntryName(Resources.java:2127)
    at com.conhea.smartgfr.preferences.SettingsActivityTest$2.matchesSafely(SettingsActivityTest.java:153)
    at com.conhea.smartgfr.preferences.SettingsActivityTest$2.matchesSafely(SettingsActivityTest.java:143)
    at org.hamcrest.TypeSafeMatcher.matches(TypeSafeMatcher.java:65)
    at org.hamcrest.core.IsNot.matches(IsNot.java:25)
    at android.support.test.espresso.matcher.ViewMatchers$26.matchesSafely(ViewMatchers.java:892)
    at android.support.test.espresso.matcher.ViewMatchers$26.matchesSafely(ViewMatchers.java:883)
    at org.hamcrest.TypeSafeMatcher.matches(TypeSafeMatcher.java:65)
...
android
android-espresso
android-testing
android-instrumentation
asked on Stack Overflow May 29, 2017 by Bohsen • edited Jul 9, 2018 by Bohsen

2 Answers

1

I suppose PreferenceCategory will internally create ListView. Hence, you'd end up having multiple AdapterViews in your hierarchy, thus AmbiguousViewMatcherException happens.

As long as you do not possess with the id of those ListViews, you cannot perform matching with ViewMatcher.withId(). Thus, you have to create a custom matcher based on something significant between those ListViews.

Unfortunately, those ListViews have same attributes, thus you have to perform matching on the parent level. The parent of those ListViews is a LinearLayout. As you may notice, one of them (actually the one that you are not interested in) has an attribute res-name=headers.

This will give you a hint to perform matching on a ListView, that has a parent, which resource name is not "headers".

Let's try to do that in Espresso language. Firsts, let's write a matcher, that matches a View with a specific resource name:



    class ResourceNameMatcher extends TypeSafeMatcher {

      private final String resName;

      public ResourceNameMatcher(String resName) {
        this.resName = resName;
      }

      @Override protected boolean matchesSafely(View item) {
        Resources resources = item.getResources();
        String matchableResName = resources.getResourceName(item.getId());
        return !TextUtils.isEmpty(matchableResName) && matchableResName.equals(resName);
      }

      @Override public void describeTo(Description description) {
        description.appendText("with res-name: " + resName);
      }
    }


Now, let's perform matching on a View, that has not particular resource name:



      onData(allOf(
                   Matchers.is(instanceOf(Preference.class),
                   ... // other matchers here )))
          .onChildView(withText(expected))
          .inAdapterView(withParent(not(new ResourceNameMatcher("headers"))))
          .check(matches(isDisplayed()));


Note, this is just a sketch code, which may not work, as long as I have not tested it. Nevertheless, the concept should work.

answered on Stack Overflow May 29, 2017 by azizbekian • edited May 29, 2017 by azizbekian
1

Here's my solution for testing my SettingsActivity.

The test:

@Test
public void whenHospitalSettingEmpty_shouldDisplaySummary() throws Exception {
    Resources res = getInstrumentation().getTargetContext().getResources();

    // Set the hospital preferencevalue to empty
    mPreferenceEditor.putString(
            res.getString(R.string.activity_settings_hospital_key),
            "").commit();

    // Launch activity
    mActivityTestRule.launchActivity(null);

    // When hospital value is empty we want to display the summary from our string ressources
    // instead of our preferencevalue
    onPreferenceRow(allOf(
                withKey(res.getString(R.string.activity_settings_hospital_key)),
                withSummary(R.string.activity_settings_hospital_summary)))
            .check(matches(isDisplayed()));
}

My helper methods:

...

private static Matcher<View> withResName(final String resName) {

    return new TypeSafeMatcher<View>() {
        @Override
        public void describeTo(Description description) {
            description.appendText("with res-name: " + resName);
        }

        @Override
        public boolean matchesSafely(View view) {
            int identifier = view.getResources().getIdentifier(resName, "id", "android");
            return !TextUtils.isEmpty(resName) && (view.getId() == identifier);
        }
    };
}

private static DataInteraction onPreferenceRow(Matcher<? extends Object> datamatcher) {

    DataInteraction interaction = onData(datamatcher);

    return interaction
        .inAdapterView(allOf(
            withId(android.R.id.list),
            not(withParent(withResName("headers")))));
}
answered on Stack Overflow May 30, 2017 by Bohsen

User contributions licensed under CC BY-SA 3.0