Рецепты эспрессо

В этом документе описывается, как настроить различные распространенные тесты Espresso.

Как сопоставить представление с другим представлением

Макет может содержать определенные представления, которые сами по себе не являются уникальными. Например, у повторяющейся кнопки вызова в таблице контактов может быть тот же идентификатор R.id, тот же текст и те же свойства, что и у других кнопок вызова в иерархии View.

Например, в этом действии представление с текстом "7" повторяется в нескольких строках:

Список, в котором показаны три копии одного и того же элемента представления внутри списка из трех элементов.

Часто неуникальный элемент интерфейса связан с уникальным ярлыком, который находится рядом с ним, например имя контакта рядом с кнопкой вызова. В этом случае вы можете использовать сопоставитель hasSibling(), чтобы сузить выборку:

Kotlin

onView(allOf(withText("7"), hasSibling(withText("item: 0"))))
    .perform(click())

Java

onView(allOf(withText("7"), hasSibling(withText("item: 0"))))
    .perform(click());

Сопоставить представление, которое находится внутри панели действий

В ActionBarTestActivity есть две панели действий: обычная и контекстная, которая создается из меню параметров. На обеих панелях действий есть один элемент, который виден всегда, и два элемента, которые видны только в дополнительном меню. При нажатии на элемент TextView меняется на содержимое нажатого элемента.

Сопоставить видимые значки на обеих панелях действий несложно, как показано в следующем фрагменте кода:

Kotlin

fun testClickActionBarItem() {
    // We make sure the contextual action bar is hidden.
    onView(withId(R.id.hide_contextual_action_bar))
        .perform(click())

    // Click on the icon - we can find it by the r.Id.
    onView(withId(R.id.action_save))
        .perform(click())

    // Verify that we have really clicked on the icon
    // by checking the TextView content.
    onView(withId(R.id.text_action_bar_result))
        .check(matches(withText("Save")))
}

Java

public void testClickActionBarItem() {
    // We make sure the contextual action bar is hidden.
    onView(withId(R.id.hide_contextual_action_bar))
        .perform(click());

    // Click on the icon - we can find it by the r.Id.
    onView(withId(R.id.action_save))
        .perform(click());

    // Verify that we have really clicked on the icon
    // by checking the TextView content.
    onView(withId(R.id.text_action_bar_result))
        .check(matches(withText("Save")));
}

Кнопка сохранения находится на панели действий в верхней части экрана.

Код для контекстной панели действий выглядит так же:

Kotlin

fun testClickActionModeItem() {
    // Make sure we show the contextual action bar.
    onView(withId(R.id.show_contextual_action_bar))
        .perform(click())

    // Click on the icon.
    onView((withId(R.id.action_lock)))
        .perform(click())

    // Verify that we have really clicked on the icon
    // by checking the TextView content.
    onView(withId(R.id.text_action_bar_result))
        .check(matches(withText("Lock")))
}

Java

public void testClickActionModeItem() {
    // Make sure we show the contextual action bar.
    onView(withId(R.id.show_contextual_action_bar))
        .perform(click());

    // Click on the icon.
    onView((withId(R.id.action_lock)))
        .perform(click());

    // Verify that we have really clicked on the icon
    // by checking the TextView content.
    onView(withId(R.id.text_action_bar_result))
        .check(matches(withText("Lock")));
}

Кнопка блокировки находится на панели действий в верхней части экрана.

Нажатие на элементы в дополнительном меню немного сложнее для обычной панели действий, поскольку на некоторых устройствах есть аппаратная кнопка дополнительного меню, которая открывает дополнительные элементы в меню параметров, а на некоторых устройствах есть программная кнопка дополнительного меню, которая открывает обычное дополнительное меню. К счастью, Espresso делает это за нас.

Для обычной панели действий:

Kotlin

fun testActionBarOverflow() {
    // Make sure we hide the contextual action bar.
    onView(withId(R.id.hide_contextual_action_bar))
        .perform(click())

    // Open the options menu OR open the overflow menu, depending on whether
    // the device has a hardware or software overflow menu button.
    openActionBarOverflowOrOptionsMenu(
            ApplicationProvider.getApplicationContext<Context>())

    // Click the item.
    onView(withText("World"))
        .perform(click())

    // Verify that we have really clicked on the icon by checking
    // the TextView content.
    onView(withId(R.id.text_action_bar_result))
        .check(matches(withText("World")))
}

Java

public void testActionBarOverflow() {
    // Make sure we hide the contextual action bar.
    onView(withId(R.id.hide_contextual_action_bar))
        .perform(click());

    // Open the options menu OR open the overflow menu, depending on whether
    // the device has a hardware or software overflow menu button.
    openActionBarOverflowOrOptionsMenu(
            ApplicationProvider.getApplicationContext());

    // Click the item.
    onView(withText("World"))
        .perform(click());

    // Verify that we have really clicked on the icon by checking
    // the TextView content.
    onView(withId(R.id.text_action_bar_result))
        .check(matches(withText("World")));
}

Кнопка дополнительного меню видна, а под панелью действий в верхней части экрана появляется список.

Вот как это выглядит на устройствах с аппаратной кнопкой дополнительного меню:

Кнопка дополнительного меню отсутствует, а список появляется в нижней части экрана.

Для контекстной панели действий это снова очень просто:

Kotlin

fun testActionModeOverflow() {
    // Show the contextual action bar.
    onView(withId(R.id.show_contextual_action_bar))
        .perform(click())

    // Open the overflow menu from contextual action mode.
    openContextualActionModeOverflowMenu()

    // Click on the item.
    onView(withText("Key"))
        .perform(click())

    // Verify that we have really clicked on the icon by
    // checking the TextView content.
    onView(withId(R.id.text_action_bar_result))
        .check(matches(withText("Key")))
    }
}

Java

public void testActionModeOverflow() {
    // Show the contextual action bar.
    onView(withId(R.id.show_contextual_action_bar))
        .perform(click());

    // Open the overflow menu from contextual action mode.
    openContextualActionModeOverflowMenu();

    // Click on the item.
    onView(withText("Key"))
        .perform(click());

    // Verify that we have really clicked on the icon by
    // checking the TextView content.
    onView(withId(R.id.text_action_bar_result))
        .check(matches(withText("Key")));
    }
}

Кнопка дополнительного меню находится на панели действий, а список вариантов – под ней, в верхней части экрана.

Полный код этих примеров можно посмотреть в примере ActionBarTest.java на GitHub.

Проверка того, что представление не отображается

После выполнения серии действий вы, скорее всего, захотите проверить состояние тестируемого интерфейса. Иногда это может быть отрицательный случай, например, когда что-то не происходит. Помните, что вы можете превратить любой сопоставитель представлений Hamcrest в ViewAssertion, используя ViewAssertions.matches().

В примере ниже мы берем сопоставитель isDisplayed() и инвертируем его с помощью стандартного сопоставителя not():

Kotlin

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import org.hamcrest.Matchers.not

onView(withId(R.id.bottom_left))
    .check(matches(not(isDisplayed())))

Java

import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static org.hamcrest.Matchers.not;

onView(withId(R.id.bottom_left))
    .check(matches(not(isDisplayed())));

Описанный выше подход работает, если представление по-прежнему является частью иерархии. Если это не так, вы получите ошибку NoMatchingViewException и вам нужно будет использовать ViewAssertions.doesNotExist().

Проверка отсутствия представления

Если представление удалено из иерархии View (это может произойти, когда действие вызвало переход к другому Activity), используйте ViewAssertions.doesNotExist():

Kotlin

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.matcher.ViewMatchers.withId

onView(withId(R.id.bottom_left))
    .check(doesNotExist())

Java

import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist;
import static androidx.test.espresso.matcher.ViewMatchers.withId;

onView(withId(R.id.bottom_left))
    .check(doesNotExist());

Проверка того, что элемент данных не находится в адаптере

Чтобы доказать, что определенный элемент данных не находится в AdapterView, вам нужно сделать кое-что другое. Нам нужно найти интересующий нас AdapterView и изучить содержащиеся в нем данные. Нам не нужно использовать onData(). Вместо этого мы используем onView(), чтобы найти AdapterView, а затем используем другой сопоставитель для работы с данными внутри представления.

Сначала сопоставитель:

Kotlin

private fun withAdaptedData(dataMatcher: Matcher<Any>): Matcher<View> {
    return object : TypeSafeMatcher<View>() {

        override fun describeTo(description: Description) {
            description.appendText("with class name: ")
            dataMatcher.describeTo(description)
        }

        public override fun matchesSafely(view: View) : Boolean {
            if (view !is AdapterView<*>) {
                return false
            }

            val adapter = view.adapter
            for (i in 0 until adapter.count) {
                if (dataMatcher.matches(adapter.getItem(i))) {
                    return true
                }
            }

            return false
        }
    }
}

Java

private static Matcher<View> withAdaptedData(final Matcher<Object> dataMatcher) {
    return new TypeSafeMatcher<View>() {

        @Override
        public void describeTo(Description description) {
            description.appendText("with class name: ");
            dataMatcher.describeTo(description);
        }

        @Override
        public boolean matchesSafely(View view) {
            if (!(view instanceof AdapterView)) {
                return false;
            }

            @SuppressWarnings("rawtypes")
            Adapter adapter = ((AdapterView) view).getAdapter();
            for (int i = 0; i < adapter.getCount(); i++) {
                if (dataMatcher.matches(adapter.getItem(i))) {
                    return true;
                }
            }

            return false;
        }
    };
}

Затем нам нужно найти AdapterView, используя onView():

Kotlin

fun testDataItemNotInAdapter() {
    onView(withId(R.id.list))
          .check(matches(not(withAdaptedData(withItemContent("item: 168")))))
    }
}

Java

@SuppressWarnings("unchecked")
public void testDataItemNotInAdapter() {
    onView(withId(R.id.list))
          .check(matches(not(withAdaptedData(withItemContent("item: 168")))));
    }
}

И у нас есть утверждение, которое не сработает, если элемент, равный "item: 168", существует в представлении адаптера со списком идентификаторов.

Полный пример вы можете посмотреть в методе testDataItemNotInAdapter() класса AdapterViewTest.java на GitHub.

Как использовать собственный обработчик ошибок

Замена стандартного FailureHandler в Espresso на пользовательский позволяет дополнительно или по-другому обрабатывать ошибки, например делать скриншот или передавать дополнительные сведения для отладки.

В примере CustomFailureHandlerTest показано, как реализовать собственный обработчик ошибок:

Kotlin

private class CustomFailureHandler(targetContext: Context) : FailureHandler {
    private val delegate: FailureHandler

    init {
        delegate = DefaultFailureHandler(targetContext)
    }

    override fun handle(error: Throwable, viewMatcher: Matcher<View>) {
        try {
            delegate.handle(error, viewMatcher)
        } catch (e: NoMatchingViewException) {
            throw MySpecialException(e)
        }

    }
}

Java

private static class CustomFailureHandler implements FailureHandler {
    private final FailureHandler delegate;

    public CustomFailureHandler(Context targetContext) {
        delegate = new DefaultFailureHandler(targetContext);
    }

    @Override
    public void handle(Throwable error, Matcher<View> viewMatcher) {
        try {
            delegate.handle(error, viewMatcher);
        } catch (NoMatchingViewException e) {
            throw new MySpecialException(e);
        }
    }
}

Этот обработчик ошибок выдает MySpecialException вместо NoMatchingViewException и делегирует все остальные ошибки DefaultFailureHandler. CustomFailureHandler можно зарегистрировать в Espresso с помощью метода setUp():

Kotlin

@Throws(Exception::class)
override fun setUp() {
    super.setUp()
    getActivity()
    setFailureHandler(CustomFailureHandler(
            ApplicationProvider.getApplicationContext<Context>()))
}

Java

@Override
public void setUp() throws Exception {
    super.setUp();
    getActivity();
    setFailureHandler(new CustomFailureHandler(
            ApplicationProvider.getApplicationContext()));
}

Дополнительную информацию вы найдете в описании интерфейса FailureHandler и метода Espresso.setFailureHandler().

Как настроить таргетинг на окна, не используемые по умолчанию

Android поддерживает несколько окон. Обычно это незаметно для пользователей и разработчиков приложений, но в некоторых случаях видно несколько окон, например когда окно автозаполнения появляется поверх основного окна приложения в виджете поиска. Чтобы упростить задачу, Espresso по умолчанию использует эвристику, чтобы угадать, с каким Window вы хотите взаимодействовать. Такой эвристический подход почти всегда работает хорошо, но в редких случаях вам потребуется указать, на какое окно должно быть направлено взаимодействие. Для этого можно предоставить собственный сопоставитель корневого окна или сопоставитель Root:

Kotlin

onView(withText("South China Sea"))
    .inRoot(withDecorView(not(`is`(getActivity().getWindow().getDecorView()))))
    .perform(click())

Java

onView(withText("South China Sea"))
    .inRoot(withDecorView(not(is(getActivity().getWindow().getDecorView()))))
    .perform(click());

Как и в случае с ViewMatchers, мы предоставляем набор готовых RootMatchers. Конечно, вы всегда можете реализовать собственный объект Matcher.

Ознакомьтесь с примером MultipleWindowTest на GitHub.

Верхние и нижние колонтитулы добавляются в ListViews с помощью методов addHeaderView() и addFooterView(). Чтобы Espresso.onData() знал, какой объект данных нужно сопоставить, передайте значение заданного объекта данных в качестве второго параметра в addHeaderView() и addFooterView(). Пример:

Kotlin

const val FOOTER = "FOOTER"
...
val footerView = layoutInflater.inflate(R.layout.list_item, listView, false)
footerView.findViewById<TextView>(R.id.item_content).text = "count:"
footerView.findViewById<TextView>(R.id.item_size).text
        = data.size.toString
listView.addFooterView(footerView, FOOTER, true)

Java

public static final String FOOTER = "FOOTER";
...
View footerView = layoutInflater.inflate(R.layout.list_item, listView, false);
footerView.findViewById<TextView>(R.id.item_content).setText("count:");
footerView.findViewById<TextView>(R.id.item_size).setText(String.valueOf(data.size()));
listView.addFooterView(footerView, FOOTER, true);

Затем вы можете написать сопоставитель для нижнего колонтитула:

Kotlin

import org.hamcrest.Matchers.allOf
import org.hamcrest.Matchers.instanceOf
import org.hamcrest.Matchers.`is`

fun isFooter(): Matcher<Any> {
    return allOf(`is`(instanceOf(String::class.java)),
            `is`(LongListActivity.FOOTER))
}

Java

import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.is;

@SuppressWarnings("unchecked")
public static Matcher<Object> isFooter() {
    return allOf(is(instanceOf(String.class)), is(LongListActivity.FOOTER));
}

Загрузить представление в тесте очень просто:

Kotlin

import androidx.test.espresso.Espresso.onData
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.sample.LongListMatchers.isFooter

fun testClickFooter() {
    onData(isFooter())
        .perform(click())

    // ...
}

Java

import static androidx.test.espresso.Espresso.onData;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.sample.LongListMatchers.isFooter;

public void testClickFooter() {
    onData(isFooter())
        .perform(click());

    // ...
}

Полный пример кода можно найти в методе testClickFooter() класса AdapterViewTest.java на GitHub.