Когда познакомился с Kotlin DSL, подумал: отличная штука, жалко в продуктовой разработке она не пригодится. Однако, я был неправ: он нам помог сделать очень лаконичный и элегантный способ написания End-to-end UI тестов в Android.

Про сервис, тестовые данные и почему все не так просто
Для начала немного контекста про наш сервис, чтобы вам было понятно, почему мы приняли те или иные решения.
Мы помогаем соискателям и работодателям найти друг друга:
- работодатели регистрируют свои компании и размещают вакансии
- соискатели ищут вакансии, добавляют их в избранное, подписываются на результаты поиска, создают резюме и отправляют отклики
Для того чтобы имитировать реальные пользовательские сценарии и убедиться, что на них приложение работает корректно, нам нужно создать на сервере все эти тестовые данные. Вы скажете: “Так создайте тестовых работодателей и соискателей заранее, а потом в тестах уже с ними и работайте”. Но тут есть пара проблем:
- во время тестов мы меняем данные;
- тесты запускаются параллельно.
Тестовое окружение и фикстуры
End-to-end тесты запускаются на тестовых стендах. На них практически боевое окружение, но отсутствуют реальные данные. В связи с этим при добавлении новых данных индексация происходит почти моментально.
Чтобы добавить на стенд данные, мы используем специальные методы фикстуры. Они добавляют данные прямиком в базу данных и моментально проводят индексацию:
interface TestFixtureUserApi { @POST("fx/employer/create") fun createEmployerUser(@Body employer: TestEmployer): Call<TestEmployer> }
Фикстуры доступны только из локальной сети и только для тестовых стендов. Методы вызываются из теста непосредственно перед запуском стартового Activity.
DSL
Вот мы и дошли до самого сочного. Как же задаются данные для теста?
initialisation{ applicant { resume { title = "Resume for similar Vacancy" isOptional = true resumeStatus = ResumeStatus.APPROVED } resume { title = "Some other Resume" } } employer { vacancy { title = "Resume for similar Vacancy" } vacancy { title = "Resume for similar Vacancy" description = "Working hard" } vacancy { title = "Resume for similar Vacancy" description = "Working very hard" } } }
В блоке initialisation мы заводим необходимые для теста сущности: в примере выше мы создали одного соискателя с двумя резюме, а также одного работодателя, который предоставил несколько вакансий.
Чтобы исключить ошибки, связанные с пересечением тестовых данных, мы генерируем уникальный идентификатор для теста и для каждой сущности.
Связи между сущностями
В чем основное ограничение при работе с DSL? Из-за его древовидности довольно сложно построить связи между различными ветками дерева.
К примеру, в нашем приложении для соискателей есть раздел “Подходящие вакансии для резюме”. Чтобы в этом списке появились вакансии, нам нужно задать их таким образом, чтобы они были связаны с резюме текущего пользователя.
initialisation { applicant { resume { title = "TEST_VACANCY_$uniqueTestId" } } employer { vacancy { title = "TEST_VACANCY_$uniqueTestId" } } }
Для этого используется уникальный идентификатор теста. Таким образом, при работе с приложением заданные вакансии рекомендуются для данного резюме. Кроме того, важно отметить, что никакие другие вакансии в этом списке не появятся.
Инициализация однотипных данных
А что если нужно сделать много вакансий? Это каждый блок так копировать? Разумеется нет! Делаем метод с блоком вакансий, в котором указывается необходимое число вакансий и трансформер, чтобы разнообразить их в зависимости от уникального идентификатора.
initialisation { employer { vacancyBlock { size = 10 transformer = { it.also { vacancyDsl -> vacancyDsl.description = "Some description with text ${vacancyDsl.uniqueVacancyId}" } } } } }
В блоке vacancyBlock мы указываем, сколько клонов вакансий нам нужно создать и как трансформировать их в зависимости от порядкового номера.
Работа с данными в тесте
Во время выполнения теста работа с данными становится очень простой. Нам доступны все созданные нами данные. В нашей реализации они хранятся в специальных обертках для коллекций. Из них можно получить данные как по порядковому номеру задания ( vacancies[0]), так по тэгу, который можно задать в dsl (vacancies[“my vacancy”]), и по шорткатам (vacancies.first()
class TaggedItemContainer<T>( private val items: MutableList<TaggedItem<T>> ) { operator fun get(index: Int): T { return items[index].data } operator fun get(tag: String): T { return items.first { it.tag == tag }.data } operator fun plusAssign(item: TaggedItem<T>) { items += item } fun forEach(action: (T) -> Unit) { for (item in items) action.invoke(item.data) } fun first(): T { return items[0].data } fun second(): T { return items[1].data } fun third(): T { return items[2].data } fun last(): T { return items[items.size - 1].data } }
Практически в 100% случаях при написании тестов мы используем методы first() и second(), остальные держим для гибкости. Ниже привел пример теста с инициализацией и с шагами на Kakao
initialisation { applicant { resume { title = "TEST_VACANCY_$uniqueTestId" } } }.run { mainScreen { positionField { click() } jobPositionScreen { positionEntry(vacancies.first().title) } searchButton { click() } } }
Что не помещается в DSL
Все ли данные можно уместить в DSL? Мы преследовали цель оставить DSL максимально лаконичным и простым. В нашей реализации из-за того, что порядок задания соискателей и раб��тодателей не важен, не получается уместить их взаимосвязь — отклики.
Создание откликов уже выполняется в последующем блоке операциями над уже созданными на сервере сущностями.
Реализация DSL
Как вы поняли из статьи, алгоритм задания тестовых данных и выполнения теста следующий:
- Парсится часть DSL в initialisation;
- По полученным значениям создаются тестовые данные на сервере;
- Выполняется опциональный блок transformation, в котором можно задать отклики;
- Выполняется тест с уже итоговым набором данных.
Разбор данных из блока initialisation
Что там за магия происходит? Рассмотрим, как конструируется верхнеуровневый элемент TestCaseDsl:
@TestCaseDslMarker class TestCaseDsl { val applicants = mutableListOf<ApplicantDsl>() val employers = mutableListOf<EmployerDsl>() val uniqueTestId = CommonUtils.unique fun applicant(block: ApplicantDsl.() -> Unit = {}) { val applicantDsl = ApplicantDsl( uniqueTestId, uniqueApplicantId = CommonUtils.unique applicantDsl.block() applicants += applicantDsl } fun employer(block: EmployerDsl.() -> Unit = {}) { val employerDsl = EmployerDsl( uniqueTestId = uniqueTestId, uniqueEmployerId = CommonUtils.unique employerDsl.block() employers += employerDsl } }
В методе applicant мы создаем ApplicantDsl.
@TestCaseDslMarker class ApplicantDsl( val uniqueTestId: String, val uniqueApplicantId: String, var tag: String? = null, var login: String? = null, var password: String? = null, var firstName: String? = null, var middleName: String? = null, var lastName: String? = null, var email: String? = null, var siteId: Int? = null, var areaId: Int? = null, var resumeViewLimit: Int? = null, var isMailingSubscription: Boolean? = null ) { val resumes = mutableListOf<ResumeDsl>() fun resume(block: ResumeDsl.() -> Unit = {}) { val resumeDslBuilder = ResumeDsl( uniqueTestId = uniqueTestId, uniqueApplicantId = uniqueApplicantId, uniqueResumeId = CommonUtils.unique ) resumeDslBuilder.apply(block) this.resumes += resumeDslBuilder } }
Затем мы выполняем над ним операции из блока block: ApplicantDsl.() -> Unit. Именно эта конструкция позволяет нам легко оперировать с полями ApplicantDsl в нашей DSL.
Обратите внимание, что uniqueTestId и uniqueApplicantId (уникальные идентификаторы для связи сущностей между собой) на момент выполнения блока уже заданные и мы можем к ним обращаться.
Блок initialisation изнутри устроен похожим образом:
fun initialisation(block: TestCaseDsl.() -> Unit): Initialisation { val testCaseDsl = TestCaseDsl().apply(block) val testCase = TestCaseCreator.create(testCaseDsl) return Initialisation(testCase) }
Мы создаем тест, применяем к нему действия блока, далее при помощи TestCaseCreator создаем данные на сервере и укладываем их в коллекции. Функция TestCaseCreator.create() устроена довольно просто — мы перебираем данные и создаем их на сервере.
Подводные камни и идеи
Некоторые тесты очень похожи и различаются только входящими данными и способами контроля их отображений (к примеру, когда в вакансии указана разная валюта).
В нашем случае, таких тестов оказалось немного, и мы решили не загромождать DSL специальным синтаксисом
Во времена до DSL у нас долго происходила индексация данных, и мы для экономии времени делали в одном классе много тестов и создавали все данные в статическом блоке.
Не делайте так — это сделает для вас невозможным перезапуск упавшего теста. Дело в том, что во время запуска упавшего теста мы могли поменять исходные данные на сервере. К примеру, мы могли добавить вакансию в избранное. Тогда при перезапуске теста нажатие на звездочку приведёт уже наоборот к удалению вакансии из списка избранного, а это уже поведение, которые мы не ожидаем.
Итоги
Такой способ задания тестовых данных очень упростил работу с тестами:
При написании тестов не нужно думать о том, есть ли сервер и в каком порядке нужно инициализировать данные;
Все сущности, которые можно задать на сервере, легко выпадают в подсказках IDE;
Появился единый способ инициализации и связи данных между собой.
Материалы по теме
Если Вас заинтересовал наш подход к UI-тестированию, то перед тем как начать, предлагаю ознакомиться со следующими материалами:
- Type-Safe Builders — официальная документация на сайте kotlinlang.org;
- Kotlin DSL: Теория и Практика — отличные доклад c jPoint 2018 про kotlin dsl и его расшифровка;
- Kakao — how to make UI testing great again — базовые знания про фреймворк Kakao для UI тестов;
- Как перестать бояться и начать писать UI-тесты вместе с Kakao — доклад про Kakao с AppsConf 2019. Доступны пока только слайды, потом будет видео.
Что дальше
Данная статья является первой из серии про инструменты и высокоуровневые фреймворки для написания и поддержки UI тестов в Android. По мере выхода новых частей я буду их прилинковывать к данной статье.
