Введение: UI-тесты — не опция, а часть культуры

«А UI-тесты у вас есть?» — на собеседовании уровня middle этот вопрос звучит стабильно: после unit-тестов спрашивают про инструментальные, и Espresso здесь — стандарт де-факто.

Espresso — фреймворк от Google для тестов пользовательского интерфейса: тест запускается на устройстве или эмуляторе, управляет реальным приложением и проверяет то, что пользователь увидит на экране. В официальной документации его описывают коротко: «краткие, красивые и надёжные тесты UI». Разберём API, синхронизацию и навигацию через Espresso-Intents — с примерами из документации Android.

onView, Matchers и Actions

Вся работа с Espresso строится вокруг трёх сущностей:

  • ViewMatchers — как найти элемент на экране: withId(R.id.name_field), withText("Привет").
  • ViewActions — что с ним сделать: click(), typeText("Steve"), scrollTo().
  • ViewAssertions — что проверить: matches(isDisplayed()), doesNotExist().

Схема вызова одна: onView(matcher).perform(action) или onView(matcher).check(assertion). Канонический пример из официальной документации:

GreetingActivityTest.kt
@RunWith(AndroidJUnit4::class)
class GreetingActivityTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(GreetingActivity::class.java)

    @Test
    fun greeterSaysHello() {
        onView(withId(R.id.name_field)).perform(typeText("Steve"))
        onView(withId(R.id.greet_button)).perform(click())
        onView(withText("Hello Steve!")).check(matches(isDisplayed()))
    }
}

Тест запускает GreetingActivity через правило ActivityScenarioRule, печатает текст, жмёт кнопку и проверяет, что на экране появился текст «Hello Steve!». Обратите внимание: поле и кнопку нашли по id, а результат проверили по тексту — так и задумано, текст на экране проверяется withText, а не id.

Зависимости для инструментальных тестов кладут в блок androidTestImplementation:

app/build.gradle.kts
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")

Если matcher'у соответствуют несколько вью на экране, Espresso бросит AmbiguousViewMatcherException — поэтому матчеры комбинируют через hamcrest: allOf(withId(R.id.button), withText("OK")), anyOf(...), not(...). Проверка «элемента нет на экране» — onView(withId(R.id.progress)).check(doesNotExist()).

Работая с текстовыми полями, помните про клавиатуру: после typeText экран перекрыт IME, и следующий клик может упасть по координатам. Стандартная цепочка — ввод с закрытием клавиатуры:

LoginTest.kt
onView(withId(R.id.email_field)).perform(
    click(),
    typeText("user@example.com"),
    closeSoftKeyboard(),
)

closeSoftKeyboard() — штатное действие Espresso: после него UI снова в покое, и следующий шаг теста не зависит от того, откуда выскочила клавиатура.

Формула Espresso

onView(Matcher).perform(Action) — действие; onView(Matcher).check(Assertion) — проверка. Matchers ищут элемент, Actions взаимодействуют, Assertions утверждают. Больше ничего в базовом API нет — и это его сила.

Синхронизация: почему тесты не «спят»

Второй важный блок — синхронизация. Перед каждым действием Espresso ждёт, пока приложение придёт в состояние покоя. По документации, условия такие:

  • в очереди сообщений главного потока нет сообщений, которые Espresso должен обработать немедленно;
  • нет выполняющихся AsyncTask;
  • все зарегистрированные idling resources простаивают.

Условия синхронизации

Espresso ждёт покоя приложения перед каждым действием: пустая очередь сообщений, нет фоновых AsyncTask, idling resources свободны. Поэтому Thread.sleep() в Espresso-тестах не нужен — это дизайн, а не фича.

Для фоновых задач, которые приложение считает «работой» (например, загрузка с сервера), используют IdlingResource. Простейший вариант — CountingIdlingResource из пакета espresso-contrib: регистрируете его в приложении, инкрементируете на старте задачи, декрементируете на финише — и Espresso не тронет UI, пока счётчик не обнулится.

Практический момент для стабильности: на эмуляторе и в CI принято отключать системные анимации (масштаб анимации окон, переходов и длительности аниматора) — переходы между экранами и анимированные состояния иначе дают флейки. Сам Espresso здесь ни при чём: он ждёт покоя, а бесконечные анимации покоя не дают.

Анти-паттерн, который я вижу в коде регулярно: Thread.sleep(1000) перед проверкой. Тест «работает», пока эмулятор быстрый, а на медленном CI падает без причины. Правильный путь — idling resource или ожидание по условию, а не сон.

Espresso-Intents: проверяем навигацию

Когда приложение отправляет Intent в другое приложение — браузер, камера, системный шеринг — реальный запуск в тесте не нужен и не всегда возможен. Для этого есть Espresso-Intents: расширение, которое официальная документация описывает как «как Mockito, но для Android Intents».

Два метода:

  • intended(...) — проверить, что приложение отправило Intent, подходящий под matcher.
  • intending(...) — подменить ответ на Intent заглушкой.
ShareButtonTest.kt
@RunWith(AndroidJUnit4::class)
class ShareButtonTest {

    @get:Rule
    val intentsRule = IntentsTestRule(ShareActivity::class.java)

    @Test
    fun shareButtonStartsShareIntent() {
        onView(withId(R.id.share_button)).perform(click())

        intended(hasAction(Intent.ACTION_SEND))
    }
}

Правило IntentsTestRule инициализирует Espresso-Intents перед каждым тестом и освобождает после него. intended(hasAction(Intent.ACTION_SEND)) провалит тест, если приложение не отправило ни одного Intent с action ACTION_SEND.

Espresso-Intents подключается отдельной зависимостью:

app/build.gradle.kts
androidTestImplementation("androidx.test.espresso:espresso-intents:3.6.1")

Проверять можно и данные Intent: intended(hasData(Uri.parse("https://androidbooster.ru"))) — если в тесте важна не только навигация, но и то, что именно ушло в интенте. Кстати, intended регистрирует все исходящие Intent'ы тестируемого приложения, поэтому несколько проверок в одном тесте работают.

Сценарий с заглушкой — выбор фото из галереи: intending(hasAction(Intent.ACTION_PICK)) возвращает заранее подготовленный результат вместо настоящей галереи:

PickImageTest.kt
intending(hasAction(Intent.ACTION_PICK)).respondWith(
    Instrumentation.ActivityResult(Activity.RESULT_OK, pickedImageIntent)
)

onView(withId(R.id.pick_image_button)).perform(click())

onView(withId(R.id.preview)).check(matches(isDisplayed()))

Matcher для Intent строится на hamcrest-выражениях — hasAction, hasData(Uri.parse(...)) — это ещё один повод выучить Hamcrest: его же используют в Mockito.

Списки и RecyclerView

UI-тесты редко обходятся без списков — и здесь базовых Matchers уже мало. Для RecyclerView в пакете espresso-contrib есть отдельный класс RecyclerViewActions:

RecyclerViewTest.kt
onView(withId(R.id.items_list)).perform(
    RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(2, click())
)

onView(withText("Element 2")).check(matches(isDisplayed()))

actionOnItemAtPosition(position, action) находит элемент списка по позиции и выполняет действие; есть вариант по item-матчеру — actionOnItem(hasDescendant(withText("Element 2")), click()), когда позиция неизвестна. Для старых AdapterView (ListView, Spinner) существует отдельный вход — Espresso.onData(matcher), который работает с данными адаптера.

Почему это спрашивают: в реальном проекте половина экранов — списки, и тест, который умеет только кликать по кнопке, не покрывает ничего. На мок-собеседовании достаточно уверенно показать RecyclerViewActions — дальше уже детали.

Ошибки и советы

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

  • Забывают @RunWith(AndroidJUnit4::class) — тест падает с непонятной ошибкой ещё до запуска.
  • Проверяют текст по id: withId(R.id.hello_text) вместо withText("Hello Steve!") — и тест не замечает, что текст изменился.
  • Используют Thread.sleep вместо синхронизации — флейки на CI.
  • Не используют Espresso-Intents: проверяют навигацию запуском реальных внешних активностей.
  • Не знают RecyclerViewActions: тесты покрывают только экраны без списков — а списки это половина приложения.
  • Тестируют только happy path: нет проверок на пустой список, ошибку сети и disabled-состояния.

Классика на собеседовании: кандидат уверенно пишет onView(...).perform(click()), но не может объяснить, как Espresso узнаёт, что приложение готово, и чем intended отличается от intending. Первое — синхронизация, второе — «проверить» против «подменить».

В разборах кода я чаще вижу обратную картину: UI-тесты либо отсутствуют, либо написаны «для галочки» — один тест на экран с тремя кликами и сном. Инженер уровня middle должен уметь объяснить, что именно покрывают инструментальные тесты и почему их меньше, чем unit-тестов — это вопрос архитектуры тестирования, а не количества строк. На мок-собеседовании я даю кандидату написать тест на экран логина: по коду сразу видно, понимает человек синхронизацию или заучил шаблон.

Хотите понять, что реально мешает вашему коду?

Бесплатная диагностика: разберу ваши пробелы и составлю план роста за 30–40 минут. Без обязательств — если менторство вам не подойдёт, честно скажу.

Итоги

Espresso — инструментальные тесты, которые живут на устройстве и проверяют реальный UI.

  • Формула: onView(matcher).perform(action) и onView(matcher).check(assertion) — Matchers, Actions, Assertions.
  • Синхронизация: очередь сообщений, AsyncTask, idling resources — вместо снов и таймаутов.
  • Навигация: intended() проверяет отправку Intent, intending() подменяет ответ заглушкой.
  • Списки: RecyclerViewActions для RecyclerView, onData для AdapterView.

Главное

Формула из трёх сущностей + честная синхронизация + Espresso-Intents для навигации — этого достаточно, чтобы уверенно ответить на тему целиком.

Если UI-тесты в вашем проекте «есть, но не запускаются» — это не стыдно, это задача, и решается она методично. Напишите в Telegram, разберём вашу ситуацию и составим план, что закрыть в первую очередь.