company_banner

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

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


    image


    Про сервис, тестовые данные и почему все не так просто


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


    Мы помогаем соискателям и работодателям найти друг друга:


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

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


    1. во время тестов мы меняем данные;
    2. тесты запускаются параллельно.

    Тестовое окружение и фикстуры


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


    TaggedItemContainer
    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.


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



    Что дальше


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

    HeadHunter
    164,20
    HR Digital
    Поделиться публикацией

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

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое