Когда познакомился с 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. По мере выхода новых частей я буду их прилинковывать к данной статье.