Писать стабильные UI-тесты для Android-приложений — непростая задача. Здесь против ваших тестов работают обилие девайсов, нестабильность эмуляторов, многообразие поддерживаемых версий ОС, а ещё — ваши бэкенд-разработчики. Почти все мобильные приложения требуют для своей работы бэкенд и не могут показать даже приветственный экран без подгрузки данных. Поэтому для UI-тестов очень важно уметь приводить бэкенд в нужное для каждого тестового сценария состояние.

Сегодня мы выпускаем в open-source собственную библиотеку MockWebServer DSL. Она помогает удобно писать обработчики запросов на бэкенд в Android-тестах. Мы давно пользуемся ею внутри нашей команды мобильной разработки в Авто.ру и других сервисах Яндекс Вертикалей — Аренде, Путешествиях, Недвижимости — и теперь хотим поделиться этим инструментом с остальными.

Чем плох Staging-бэкенд в тестах

Когда вы только начинаете писать UI-тесты, велик соблазн точь-в-точь повторить то, что делают мануальные тестировщики: использовать staging-бэкенд. Однако у этого подхода есть несколько серьёзных минусов.

Во-первых, если у вас нет деления на кросс-функциональные команды, скорее всего, ваши бэкенд-разработчики не координируют с вами релизы на staging. Это не зона ответственности Android-разработчика. В итоге ваши тесты могут ломаться из-за изменений бэкенда или неполадок в инфраструктуре staging и вовсе не показывать ошибки в вашем коде. Стабильность тестов очень важна для выстраивания доверия к тестовой инфраструктуре. Только доверяя тестам, вы получите столь желанную экономию времени мануальных тестировщиков и святой грааль CI/CD — автоматическую интеграцию фича-веток в транк.

Во-вторых, staging-бэкенд максимально приближен к production. Из-за этого в тестах возникает много шагов, чтобы привести staging в исходное состояние для теста. Например, чтобы проверить публикацию объявления, нужно авторизоваться, создать черновик, заполнить несколько форм и только потом тестировать публикацию. Всё это создаёт бойлерплейт, который снижает продуктивность, делает тесты более хрупкими, а то и вовсе отбивает желание писать тесты.

Свой бэкенд с MockWebServer

Главная проблема использования staging-бэкенда в тестах в том, что он находится вне нашего контроля. И хотя тесты, взаимодействующие с реальным staging-бэкендом, всё ещё обеспечивают большее покрытие, мы считаем оправданным строительство своего mock-бэкенда и использование его в большинстве тестов для повышения стабильности. 

Для этой задачи существует популярнейшая библиотека MockWebServer, которую мы и стали использовать для тестов. Наличие собственного mock-бэкенда, который запускается вместе с тестами, позволяет взять контроль над поведением бэкенда и отдавать приложению любые ответы (или не отдавать, симулируя таймаут). 

Но здесь мы столкнулись с проблемой: диспатчеры в MockWebServer очень неудобны для использования в тестах. Для их настройки требуется много бойлерплейта, в попытках переиспользовать который мы начали выносить утилитные функции. К тому же, множество одинаковых сценариев программировалось по-разному и вообще настройка MockWebServer была не очень приятной штукой.

MockWebServer DSL  — гроза бойлерплейта

Осознав проблему, мы решили задизайнить удобный Kotlin DSL поверх диспатчеров MockWebServer. Он должен был решать следующие задачи:

  • максимально просто создавать моковые ответы из json-файлов;

  • позволять тонко настраивать мэтчинг по url, query-параметрам headers и body запросов;

  • проверять body запроса для ассертов в тестах;

  • строить более сложные сценарии, зависящие от запроса.

Сначала мы пробовали описывать моки эндпоинтов по аналогии с паттерном Adapter Delegates Ханнеса Дорфмана. Через некоторое время нам надоел бойлерплейт наследования, и мы решили задизайнить полноценный DSL. Благо описание моков эндпоинтов концептуально — это описание матчера для запроса и описание генерации ответа.

В результате у нас получился простой и расширяемый DSL, в котором из коробки уже решены многие задачи: логирование, библиотека матчеров для uri и готовые генераторы на основе строк, файлов и даже функций. 

Простой route

Каждую связку из матчера запроса и генератора ответа мы называем route. DSL спроектирован так, чтобы вы могли сделать  route функцией, которая переиспользуется во множестве тестов. Это позволит сэкономить время в дальнейшем покрытии тестами.

Допустим, мы решили покрыть автотестами один из главных экранов Авто.ру — выдачу объявлений. Мы хотим проверить, что все виды объявлений с автомобилями корректно отображаются. Для этого мы подготовили файл feed/page1.json, в котором собрали выдачу из всех возможных вариантов объявления: с безопасной сделкой, от дилеров, частников и т.д. 

Как нам в тесте подсунуть этот файл в качестве ответа ручки feed, чтобы приложение ничего не заподозрило? Пишем следующее:

// MyAwesomeTest.kt
@get:Rule
val webServerRule = WebServerRule {
   getFeed()
}

Здесь мы создаём JUnit Rule, чтобы запускать и останавливать MockWebServer вместе с тестом, и тут же настраиваем route. В данном примере мы добавили только один — getFeed(). Перейдём к его определению. 

// FeedRoutings.kt
fun Routing.getFeed() = get(
   "get feed",
   { pathContains("feed")},
   response {
       setBody(asset("feed/page_1.json"))
   }
)

Мы рекомендуем сразу выносить route в общий код, чтобы их можно было переиспользовать в других тестах. Для GET-запросов мы используем dsl-функцию get, которая принимает описание route для логирования, матчер на запрос и билдер ответа. В данном случае мы просто отвечаем содержимым файла feed/page_1.json на любой запрос с feed в его пути.

Параметризированные запросы

Но что если нам потребуется параметризовать запрос? Например, для разных страниц нужно отдавать разные json. Неужели нужно писать каждый раз новый route? 

Нет. route — это просто Kotlin-функции, поэтому мы можем в них объявлять параметры и использовать их в декларации route. 

Например, добавим параметры page и assetPath в наш route, а также новое условие для матчера — будем требовать, чтобы query-параметры содержали объявленную страницу.

fun Routing.getFeed(page: Int, assetPath: String) = get(
   "get feed, page $page",
   {
       pathContains("feed")
       query(StringContains("page=$page"))
   },
   response {
       setBody(asset(assetPath))
   }
)

Далее нужно поправить объявление rule, чтобы включить туда руты для разных страниц.

webServerRule.routing {
   getFeed(page = 1, assetPath = "feed/page_1.json")
   getFeed(page = 2, assetPath = "feed/page_2.json")
}

Готово! Теперь, когда приложение запросит вторую страницу с query-параметром page=2, мы отдадим ему новый файл feed/page_2.json. Заметьте, что для route первой страницы нам тоже нужно добавить её номер, иначе ничего не сработает.

Ассерты

Если мы хотим убедиться, что пагинация работает, и мы действительно запрашиваем вторую страницу, нам помогут встроенные ассерты. Простейший из них — проверить, что приложение делает такой реквест.

webServerRule.routing {
   getFeed(page = 1, assetPath = "feed/page_1.json")
   getFeed(page = 2, assetPath = "feed/page_2.json").assertCalled()
}

Ассерты содержат очень много настроек. Мы их редизайнили перед релизом и гордимся полученным результатом. Теперь то, что раньше было мешком утилит, получило единое логичное API. Каждый route теперь хранит историю запросов, которые он принял, а при помощи ассертов можно проверить любой запрос в истории. При этом в них можно использовать те же матчеры, что и в определении route — ничего нового изучать не нужно! 

Как попробовать

Конечно же, мы пишем эту статью не только для того, чтобы похвастаться тем, какой классный инструмент мы создали для своей команды. Мы думаем, что наша библиотека поможет многим разработчикам, поэтому не стоит прятать её от мира. К тому же после трёх лет внутреннего успешного использования она готова к тому, чтобы выложить её в open-source. 

Вы можете подключить её к себе в проект всего одной строчкой…

 androidTestImplementation("com.yandex.classifieds:mockwebserver-dsl:1.0.0")

…и наслаждаться возможностями этого DSL в своих автотестах.

Пробуйте библиотеку, ставьте нам звёзды и пишите о своих результатах и впечатлениях в issue GitHub проекта

Комментарии (0)