Введение: 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). Канонический пример из официальной документации:
@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:
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, и следующий клик может упасть по координатам. Стандартная цепочка — ввод с закрытием клавиатуры:
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 заглушкой.
@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 подключается отдельной зависимостью:
androidTestImplementation("androidx.test.espresso:espresso-intents:3.6.1")Проверять можно и данные Intent: intended(hasData(Uri.parse("https://androidbooster.ru"))) — если в тесте важна не только навигация, но и то, что именно ушло в интенте. Кстати, intended регистрирует все исходящие Intent'ы тестируемого приложения, поэтому несколько проверок в одном тесте работают.
Сценарий с заглушкой — выбор фото из галереи: intending(hasAction(Intent.ACTION_PICK)) возвращает заранее подготовленный результат вместо настоящей галереи:
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:
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, разберём вашу ситуацию и составим план, что закрыть в первую очередь.