Kotlin DSL, Fixtures и элегантные UI тесты в Android

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

image

Для начала немного контекста про наш сервис, чтобы вам было понятно, почему мы приняли те или иные решения.
Мы помогаем соискателям и работодателям найти друг друга:


  • работодатели регистрируют свои компании и размещают вакансии;
  • соискатели ищут вакансии, добавляют их в избранное, подписываются на результаты поиска, создают резюме и отправляют отклики.

Для того чтобы имитировать реальные пользовательские сценарии и убедиться, что на них приложение работает корректно, нам нужно создать на сервере все эти тестовые данные. Вы скажете: «Так создайте тестовых работодателей и соискателей заранее, а потом в тестах уже с ними и работайте». Но тут есть пара проблем:
во время тестов мы меняем данные;
тесты запускаются параллельно.

End-to-end тесты запускаются на тестовых стендах. На них практически боевое окружение, но отсутствуют реальные данные. В связи с этим при добавлении новых данных индексация происходит почти моментально.
Чтобы добавить на стенд данные, мы используем специальные методы фикстуры. Они добавляют данные прямиком в базу данных и моментально проводят индексацию:

interface TestFixtureUserApi {
    @POST("fx/employer/create")
    fun createEmployerUser(@Body employer: TestEmployer): Call
}

Фикстуры доступны только из локальной сети и только для тестовых стендов. Методы вызываются из теста непосредственно перед запуском стартового Activity.

Вот мы и дошли до самого сочного. Как же задаются данные для теста?

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 ()


TaggedItemContainer
class TaggedItemContainer(
       private val items: MutableList>
) {

   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) {
       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 в initialisation;
  • По полученным значениям создаются тестовые данные на сервере;
  • Выполняется опциональный блок transformation, в котором можно задать отклики;
  • Выполняется тест с уже итоговым набором данных.

Разбор данных из блока initialisation
Что там за магия происходит? Рассмотрим, как конструируется верхнеуровневый элемент TestCaseDsl:

@TestCaseDslMarker
class TestCaseDsl {

    val applicants = mutableListOf()
    val employers = mutableListOf()
    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.


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()

   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-тестированию, то перед тем как начать, предлагаю ознакомиться со следующими материалами:

Что дальше
Данная статья является первой из серии про инструменты и высокоуровневые фреймворки для написания и поддержки UI тестов в Android. По мере выхода новых частей я буду их прилинковывать к данной статье.

© Habrahabr.ru