Привет!

Я - Урманчеев Станислав, 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, которая возвращает ссылку на наш class MainPage и принимает блок кода, который оборачивается в 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).

Комментарии (4)


  1. amedvedjev
    30.08.2022 20:07
    +1

    Молодцы!

    У нас примерно тоже только Appium + Java + TestNG + Allure.

    Из плюсов на Java можно писать умопомрачительные локаторы

    @iOSXCUITFindBy(id = "PT5PlanSelectorView")
    @iOSXCUITFindAll(value = {
      @iOSXCUITBy(id = "bottomButton"),
      @iOSXCUITBy(id = "otherButton")}, priority = 1)
    @AndroidFindBy(id = "aftv_button_text")
    private WebElement choosePlanButton;
    // or
    @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeButton' AND (label == 'Next' OR label == 'View your spending')")
    @AndroidFindAll(value = {
         @AndroidBy(id = "a193_continue_button"), // old
         @AndroidBy(id = "pb_mm1") // new
    })
    private WebElement viewSpendingButton;

    пример теста:

            login(getCustomer())
                    .tapBurgerButton().isBurgerMenuPageLoaded()
                    .tapMyPlanButton()
                    .isMyPlanPageLoaded();
    
            // check plan monthly fee
            resultDB = myApp().myPlanPage().getPlanMonthlyFee();
            expectedBD = PlanFees.getMonthly(tier, false);
            getSoftAssert().assertEquals(resultDB, expectedBD, "MyPlan(): Monthly fee NOT correct");
    
            // check free withdrawal amount left
            resultDB = myApp().myPlanPage().getFreeATMAndTopUps(getCustomer());
            expectedBD = AtmAndCashTopUps.valueOf(tier.name()).getFreeAtmAndTopUp();
            getSoftAssert().assertEquals(resultDB, expectedBD, "Free withdrawal amount NOT correct'");

    Видео всех тестов вставляем в отчет тоже.

    Поделитесь для сравнения количеством тестов и скоростью выполнения. Спасибо.


    1. lokkli Автор
      31.08.2022 17:37
      +1

      На проекте 350~ тестов, по времени тест на Appium идет 1-3 минуты. В целом довольно медленно, но много работы со списками, мы решаем проблему с помощью параллельного запуска. Так же есть довольно значительное количество нативных тестов на espresso/allure kotlin/junit4, они кратно быстрее и стабильнее.


      1. amedvedjev
        31.08.2022 22:44

        iOS медленнее соглашусь, Android скорость такая что пытается тапать кнопку на экране, пока она еще появляется. Решается отрубанием анимации. Это кстати и для iOS помогает немного.

        У нас 900+ тестов, 25 тел (12 iOS + 13 Андроид). По ночам бегают около 700 (1400 на две платформы) при релизах все. У нас много долгих тестов (например 2 платежа в тесте с заполнением кучи полей). Вообщем вполне сносно все 1800 (900 на обе платформы) бегут за 3.5 часа Андроид и немного дольше iOS.

        C фермами типа browserstack заморачиваться не стали (наша ферма окупается за 3 месяца если выбирать планы с 20+ параллельными тел против самой дешевой фермы).


  1. your_rubicon
    31.08.2022 18:37

    По поводу автоматизации, а тесты ios как запускаете, учитывая что для запуска ios приложения нужно устройство Apple? Руками или используете какое-то автоматическое решение?