Привет!
Я - Урманчеев Станислав, QA Automation Engineer на проекте «Лояльность» в Mир Plat.Form (НСПК). Хочу поделиться с читателями Хабра нашим опытом в создании и развитии фреймворка для автоматизации тестов на Appium.
Какие проблемы мы собрали по пути, к чему пришли в итоге и почему не стоит усложнять жизнь тестировщикам сложным API для тестирования – читайте под катом.
Дисклеймер: о Kotlin dsl есть подробная статья на Хабре и документация на Kotlinlang.
Какая проблема стояла перед командой тестирования?
Немного о продукте, для которого мы создали наш фреймворк:
Мобильное приложение «Привет Мир!» для iOS/Android c регулярными релизами раз в 2-4 недели. Основной функционал - взаимодействие с программой лояльности платежной системы «Мир»:
Регистрация клиента.
Добавление и удаление карт.
Просмотр и участие в акциях.
В целом функционал и клиентский путь несложный. Команде тестирования Мир Plat.Form (НСПК) нужно в сжатые сроки провести регрессионное тестирование и ПСИ.
Наша основная боль - сокращение временных ресурсов на поддержку и написание тестов. С такими вводными мы начали выбирать инструменты.
Возможные альтернативы
У нас была возможность попробовать различные связки языков и фреймворков для автоматизации.
Kotlin/Swift |
Kaspresso + XCUITest
| · Поддержка тестов внутри двух проектов становилась довольно затруднительной; · Значительная часть отведенного на регресс времени уходила на сборку проекта внутри корпоративной сети, починку тестов; |
Java | Cucumber + Appium | · Дополнительный слой абстракции в лице Gherkin больше создавал проблемы, чем их решал; · Отчеты удобно читать; |
В сторону Cucumber смотрели, но для UI-тестирования он избыточен.
Относительно небольшое количество функционала и тот факт, что автотестирование для приложения делалось с 0, позволило попробовать несколько технологий и выбрать наиболее подходящие под проект. В итоге остановились на стеке:
Kotlin
Allure
Appium
Wiremock standalone
Зачем писать свою обертку поверх Appium?
Есть отличный фреймворк Kaspresso, его стиль тестов во многом вдохновлял меня, но в определенный момент было принято решение перейти на кроссплатформенные тесты.
В поисках чего-то похожего был найден этот проект. Akow давно не поддерживают, контрибьютить в opensource не позволяла политика команды - в итоге мы решили написать свой инструмент.
Минусы решений под один проект всем широко известны, но я перечислю плюсы:
Ничего лишнего, пишем наиболее лаконичное API для текущего проекта;
Легко изменять, не надо форкать или открывать PR в сторонний проект;
Повышение вовлеченности тестировщиков - у всех есть возможность внести новый функционал.
Ближе к коду
Главные идеи:
Улучшение семантики кода, используя возможности Kotlin;
Генерация Allure-шагов для отчета;
Изолирование слоев фреймворка для упрощения дальнейшего развития.
Прежде чем приступать к написанию инструмента (пускай небольшого и для тестирования), было бы неплохо определиться с архитектурой. Сейчас нам не потребуются сложные сиквенс-диаграммы, в основе будет Appium. Именно он будет взаимодействовать с устройствами.
Начнем с общего интерфейса для всех UI-элементов в тестах:
interface IUIElement {fun click ()
fun checkDisplayed() fun checkText()
fun sendKeys()
}
Создадим контрактный интерфейс IUIElement и добавим функции, которые в дельнейшем реализуем в классе AppiumElement. Этот интерфейс поможет нам также для реализации паттерна проектирования “Стратегия”.
class AppiumElement(private val by: By, val name: String = "[название_элемента]") : IUIElement {
override fun click() = step("Клик по элементу [$name]") { screenshot()
driver.findElement(by).click()
}
override fun checkDisplayed(): Unit = step("Проверка отображения элемента [$name]") {
assertTrue("Элемент [$name] не отобразился", driver.findElement(by).isDisplayed)
}
/** More code*/
Конструктор класса принимает два параметра: val by: By
локатор для поиска элемента, val name: String
название добавления элемента для Allure-шага.
Генерация шагов при каждом обращении - функция, значительно упрощающая разбор отчетов, написание тестов и освобождающая от необходимости вручную описывать шаги. К тому же это является хорошим шагом в сторону концепции “тест-кейсы как код”, не прибегая к использованию Cucumber.
Вложенные шаги и Page object
Для получения ссылки на объект MainPage
создадим функцию main, которая принимает block: MainPage.()
функции класса MainPage
и вызывает внутри Allure-шага, название шага берется из tag: String
из конструктора класса. Таким образом в отчете все получается сгруппировано в виде вложенных шагов и помогает сразу понять экран, на котором выполнялись действия/проверки.
companion object {
fun main(block: MainPage.() -> Unit): MainPage =
MainPage().apply {
Allure.step(this.tag) { block()}
}
}
Помимо генерации шагов при обращении к UI приложения, стоит оборачивать проверки API-запросов внутри мобильного приложения.
fun verifyRequest(count: Int = 1) {
step("Проверка [по URL и query] запроса [$matcher],
был отправлен [$count раз]") {
verify(exactly(count), anyRequestedFor(urlMatching(matcher)))
}
}
Добавим немного сахара
Kotlin даёт широкие возможности для сокращения Boilerplate кода. Вы можете написать новые функции для класса из сторонней библиотеки. Добавим функцию классу By
, чтобы искать TextView
по переданной строке.
fun byTextView(text: String): By.ByXPath =
By.ByXPath("//android.widget.TextView[@text='$text']")
val profileTitle = element(byTextView("Текст, который проверяет на экране"),
"Текст для allure шага")
Без использования функции-расширения и генерации Allure.step
код выглядел бы вот так. Можно вынести в Utils-класс функцию byTextView
, но этот вариант нам нравится больше, доступ к нужному методу есть сразу из класса By
.
step("Проверка отображения элемента [$name]") { assertTrue("Элемент [$name] не отобразился",
driver.findElement(By.ByXPath("// android.widget.TextView[@text='$text’]")
.isDisplayed))
}
Еще пример, но уже для Collection:
fun Collection<IUIElement>.verifyElements() = this
.forEach { it.verifyElement() }
Infix запись функций
Удобный способ вызова метода (без точки и скобок для вызова), требует указания как получателя, так и параметра. В примере используем его для создания экземпляра AppiumElement
.
infix fun String.byClassName(name: String): AppiumElement =
AppiumElement(By.className(this))
После создание элементов приближается к декларативному стилю:
val searchButton = "Кнопка поиска" byClassName "SearchButtonClass"
val title = "текст_для_локатора" withName "описание для
allure отчета"
Объявление тех же констант, но без использования новых функций выглядит так:
val searchButton =AppiumElement(selector = By.className("SearchButtonClass"), name = "Кнопка поиска")
val title =AppiumElement(selector = By.ByXPath("// android.widget.TextView[@text='$text']")
name = "Текст заголовка")
Именованные параметры помогают лучше понимать параметры и разбирать код, особенно когда вызываются перегруженные функции или функции с большим количеством входных параметров. Но инфиксная запись делает код тестов 'чище'.
Перегрузка оператора invoke
Kotlin позволяет определить для типов ряд встроенных операторов. Для определения оператора для типа определяется функция с ключевым словом operator.
Но сейчас мы перегрузим не математические операторы, а то, к чему не так часто
обращаются тестировщики в явном виде - оператор invoke. Он является оператором вызова (функции, метода), в круглых скобках транслируется в invoke с соответствующим числом аргументов. Более подробно вы можете почитать в документации, а я продолжу.
В блоке кода ниже пример использования перегрузки оператора, функция invoke принимает block: Endpoint.() -> Unit
, которые выполняются в apply:
class Endpoint(override val pathMatcher: String = ".*") : IEndpointStub {
operator fun invoke(block: Endpoint.() -> Unit) = apply(block)
val onMatch: ForwardChainExpectation = mockServer.`when`(request().withPath(pathMatcher))
override fun jsonBody(filePath: String) { onMatch.respond(
response()
.withBody(loadText(filePath))
.withStatusCode(200)
.withHeader("Content-Type", "application/ json;charset=UTF-8"))
}
}
Также добавим object Mock:
object Mock {
operator fun invoke(block: Mock.() -> Unit) = apply(block)
val profile get() = Endpoint(".+profile")
/** More endpoint’s*/
}
Что получаем в итоге
В конечном итоге мы получаем расширяемое, но в то же время простое API для тестирования, реализацию паттерна - фасад, упрощение своей жизни и читаемость тестов путем генерации красивых Allure-отчетов.
Пример теста: мы сознательно избегаем комментариев в коде и использования Cucumber-подобных инструментов. Названия функций и структура тестов должны документировать сами себя.
@Before
fun setUp() {
Mock{ policy { jsonBody("src/test/resources/policy.json") }
}
onBoarding { skip() } inputPhoneNumber {
phoneFiled.sendKeys("1111111111") continueButton.click()
}
loginSMS { inputSMS() }
createPinCode { pinCodeTab.setPin(1111) }
}
@Test @DisplayName("Проверка UI") fun checkMainPageUITest() {
main { assert { checkUI() } }
}
Ниже в блоке кода паттерн Page Object. Здесь я хочу обратить внимание на несколько вещей:
Создание в классе отдельной функции assert и класс Asserts для выделения всех функций с проверками в отдельный класс и вызова их только в контексте метода
fun main
, которая возвращает ссылку на наш classMainPage
и принимает блок кода, который оборачивается вAllure.step
и выполняется в apply.
Таким образом все наши шаги по взаимодействию с AppiumElement становятся вложенными
class MainPage : BasePage("Главная") {
val bottomToolbar by lazyUnsafe { MainBottomToolbarElement() }
val searchButton = "Кнопка поиска" byClassName "SearchButtonClass"
val title = "Текст для локатора" withName "описание для allure отчета"
fun assert(block: MainPage.Asserts.() -> Unit): MainPage = apply { Asserts().block() }
inner class Asserts {
fun checkUI() = listOf(transferButton, returnedMoney, title).verifyElements()
}
companion object {
fun main(block: MainPage.() -> Unit): MainPage =
MainPage().apply { Allure.step(this.tag) { block() }
}
}
Как выглядит отчет
Выглядит он отлично:
Все шаги сгенерировались автоматически;
Код документирует сам себя, освобождая время тестировщика.
В дальнейшем из таких отчетов можно создавать тест-кейсы в Allure TestOps, но это уже отдельная история.
Итоги
Написание собственного инструмента не такая костыльная и страшная задача, как может показаться с первого взгляда. Для закрытия большей части задач UI-тестирования хватает 4-7 классов/интерфейсов и нескольких дней. Взамен команда тестирования получит гибкий инструмент, предоставляющий удобное под конкретные задачи API для взаимодействия с тестовым фреймворком (в нашем случае Appium).