Ultron — самый простой фреймворк для разработки UI тестов Android

от автора

В большинстве случаев, когда разговор заходит об автоматизации тестирования на android, многие рекомендуют популярный фреймворк kaspresso. А есть ли альтернативы?

Сегодня мы поговорим о таком фреймворке как Ultron для android ui тестирования, что‑то отличное от kaspresso со своим подходом. На просторах интернета мало кто знает об этом фреймворке. Фреймворк базируется на Espresso, UI Automator и Compose UI testing framework, написан Алексеем Тюриным, который неоднократно выступал на Heisenbug. Поэтому было бы неплохо подсветить Ultron на хабре.

Каковы преимущества использования фреймворка?

  • Простой синтаксис

  • Полный контроль над любым action или assertion

  • Архитектурный подход к разработке UI‑тестов

  • Механизм подклюения preconditions и postconditions

Давайте рассмотрим данный фреймворк поближе.

Подход к Assertions and Actions

Взглянем на примеры actions и assertions над элементами:

Espresso

onView(withId(R.id.send_button)).check(isDisplayed()).perform(click()) 

Ultron

withId(R.id.send_button).isDisplayed().click() 

Как видно из примера, синтаксис ultron позволяет более лаконично описывать действия над элементами. Названия всех операций Ultron такие же, как у эспрессо. Ultron также предоставляет список дополнительных операций.

Давайте посмотрим как это будет выглядеть с использованием паттерна page object:

object SomePage : Page<SomePage>() {     private val button = withId(R.id.button1)     private val eventStatus = withId(R.id.last_event_status)      fun checkEventStatusText(expectedEventText: String) {          button.click()          eventStatus.hasText(expectedEventText)     } } 

Все выглядит предельно просто и понятно. Вам всего лишь нужно указать матчеры для ваших элементов и вызвать над ними необходимые действия.

Подход к действиям над recycler view

Espresso

onView(withId(R.id.recycler_friends))     .perform(         RecyclerViewActions             .scrollTo<RecyclerView.ViewHolder>(hasDescendant(withText("Janice")))     )     .perform(         RecyclerViewActions             .actionOnItem<RecyclerView.ViewHolder>(                 hasDescendant(withText("Janice")),                 click()             )         ) 

Ultron

withRecyclerView(R.id.recycler_friends)     .item(hasDescendant(withText("Janice")))     .click() 

Ultron предоставляет очень комфортное апи и весь код лаконично умещается в пару строк.
Взглянем на best practice с использованием page object:

object FriendsListPage : Page<FriendsListPage>() {     // param loadTimeout в мс указывает время ожидания загрузки элементов RecyclerView     val recycler = withRecyclerView(R.id.recycler_friends, loadTimeout = 10_000L)      fun someStep(){         recycler.assertEmpty()         recycler.hasContentDescription("Description")     } } 

Апи, которое предоставляет фреймворк для работы с ресайклером:

recycler.item(position = 10, autoScroll = true).click() // найти 10 айтем по позиции и проскроллить до него recycler.item(matcher = hasDescendant(withText("Janice"))).isDisplayed() recycler.firstItem().click() // взять первый айтем ресайклера recycler.lastItem().isCompletelyDisplayed()  // если невозможно указать уникальный матчер для целевого элемента val matcher = hasDescendant(withText("Friend")) recycler.itemMatched(matcher, index = 2).click() // вернуть 3 совпадающий по матчеру элемент начиная с 0 индекса recycler.firstItemMatched(matcher).isDisplayed() recycler.lastItemMatched(matcher).isDisplayed() recycler.getItemsAdapterPositionList(matcher) // вернуть все позиции элементов по матчеру 

Стоит отметить, что вам не нужно беспокоиться о скроллинге до элемента. По умолчанию автоскроллинг к элементам равен true в каждом методе по работе с элементами ресайклера.

Если у вас комплексные айтемы ресайклера (имеют вложенные вью) и вам нужно с ними поработать, то вы можете использовать следующий подход:

object FriendsListPage : Page<FriendsListPage>() {      // задаем матчер для ресайклера     val recycler = withRecyclerView(R.id.recycler_friends)      // создаем функцию для получения айтема ресайклера     fun getListItem(contactName: String): FriendRecyclerItem {         return recycler.getItem(hasDescendant(allOf(withId(R.id.tv_name), withText(contactName))))     }      // создаем класс, который мы подразумеваем как айтем ресайклера withRecyclerView(R.id.recycler_friends)     class FriendRecyclerItem : UltronRecyclerViewItem() {         val avatar by lazy { getChild(withId(R.id.avatar)) }         val name by lazy { getChild(withId(R.id.tv_name)) }         val status by lazy { getChild(withId(R.id.tv_status)) }     }      // используем getListItem(name) для доступа к status     fun assertStatus(name: String, status: String) = apply {         getListItem(name).status.hasText(status).isDisplayed()     } } 

При подходе создания класса как FriendRecyclerItem, мы можем использовать такое апи:

recycler.getItem<FriendRecyclerItem>(position = 10, autoScroll = true).status.hasText("UNAGI") recycler.getItem<FriendRecyclerItem>(matcher = hasDescendant(withText("Janice"))).status.textContains("Oh. My") recycler.getFirstItem<FriendRecyclerItem>().avatar.click() // взять первый ресайклер айтем recycler.getLastItem<FriendRecyclerItem>().isCompletelyDisplayed()  // если невозможно указать уникальный матчер для целевого элемента val matcher = hasDescendant(withText(containsString("Friend"))) recycler.getItemMatched<FriendRecyclerItem>(matcher, index = 2).name.click() // вернуть 3 совпадающий по матчеру элемент начиная с 0 индекса recycler.getFirstItemMatched<FriendRecyclerItem>(matcher).name.hasText("Friend1") recycler.getLastItemMatched<FriendRecyclerItem>(matcher).avatar.isDisplayed() 

Espresso WebView operations

Espresso

onWebView()     .withElement(findElement(Locator.ID, "text_input"))     .perform(webKeys(newTitle))     .withElement(findElement(Locator.ID, "button1"))     .perform(webClick())     .withElement(findElement(Locator.ID, "title"))     .check(webMatches(getText(), containsString(newTitle))) 

Ultron

id("text_input").webKeys(newTitle) id("button1").webClick() id("title").hasText(newTitle) 

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

UI Automator operations

Espresso

val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) device     .findObject(By.res("com.atiurin.sampleapp:id", "button1"))     .click() 

Ultron

byResId(R.id.button1).click() 

Функционал фреймворка через UI Automator описан тут.

Простые compose operations

Compose framework

composeTestRule.onNode(hasTestTag("Continue")).performClick() composeTestRule.onNodeWithText("Welcome").assertIsDisplayed() 

Ultron

hasTestTag("Continue").click() hasText("Welcome").assertIsDisplayed() 

Взаимодесйтвие с компоузом описано в wiki.

Compose list operations

Compose framework

val itemMatcher = hasText(contact.name) composeRule     .onNodeWithTag(contactsListTestTag)     .performScrollToNode(itemMatcher)     .onChildren()     .filterToOne(itemMatcher)     .assertTextContains(contact.name) 

Ultron

composeList(hasTestTag(contactsListTestTag))     .item(hasText(contact.name))     .assertTextContains(contact.name) 

Работа с LazyColumn/LazyRow описана тут.

Стабильность фреймворка

Чем обусловлена стабильность тестов во фреймворке?
Фреймворк перехватывает список указанных исключений и пытается повторить операцию в течение определенного тайм-аута (по умолчанию 5 секунд).

UltronWrapperException::class.java, UltronException::class.java, PerformException::class.java, NoMatchingViewException::class.java, AssertionFailedError::class.java, RuntimeException::class.java 

Вы можете настроить список обрабатываемых исключений, добавить новые или убрать существующие через UltronConfig, а также поменять дефолтный тайм-аут. Также можно указать пользовательский тайм-аут для любой операции — withTimeout(..) (будет рассмотрен ниже).

Давайте рассмотрим какие есть возможности во фреймворке:

Пользовательский тайм-аут для любой операции

withId(R.id.last_event_status).withTimeout(10_000).isDisplayed() 

Данная фича позволяет установить таймаут, в пределах которого будет производиться action или assertion над элементом (по умолчанию 5 секунд)

Есть 2 подхода к использованию метода withTimeout(..):
Укажите withTimeout(..) в месте объявления элемента и он будет применяться для всех операций с этим элементом

object SomePage : Page<SomePage>() {     private val eventStatus = withId(R.id.last_event_status).withTimeout(10_000) } 

Укажите withTimeout(..) внутри шага, чтобы взаимодействие с элементом длилось на протяжении указанного времени. Это значение тайм-аута будет применено только один раз для одной операции.

object SomePage : Page<SomePage>() {     fun someLongUserStep(expectedEventText: String){          longRequestButton.click()          eventStatus.withTimeout(20_000).hasText(expectedEventText)     } } 

То есть проверка hasText(expectedEventText) над элементом eventStatus будет исполняться 20 секунд, пока она не достигнет успеха. Если в течении 20 секунд операция не увенчается успехом, тест упадет с соответствующей ошибкой.

Boolean operation result

Во фреймворке присутствует метод isSuccess, который позволяет получить результат любой операции в виде boolean значения. В случае false проверка isSuccess может выполняться слишком долго (по умолчанию 5 секунд). Поэтому разумно указать пользовательский тайм-аут для некоторых операций вкупе с функцией isSuccess.

Например:

val isButtonDisplayed = withId(R.id.button).isSuccess { withTimeout(2_000).isDisplayed() } if (isButtonDisplayed) {     // какие-то необходимые проверки и действия } 

Расширение фреймворка своими кастомными ViewActions и ViewAssertions

Под капотом все операции эспрессо Ultron описаны в классе UltronEspressoInteraction. для создания своих кастомных действий и проверок вам просто нужно расширить этот класс, используя функцию расширения kotlin, например.

fun <T> UltronEspressoInteraction<T>.appendText(text: String) = apply {     executeAction(         operationBlock = getInteractionActionBlock(AppendTextAction(text)),         name = "Append text '$text' to ${getInteractionMatcher()}",         description = "${interaction!!::class.simpleName} APPEND_TEXT to ${getInteractionMatcher()} during $timeoutMs ms",     ) 

AppendTextAction — это пользовательский ViewAction

class AppendTextAction(private val value: String) : ViewAction {     override fun getConstraints() = allOf(isDisplayed(), isAssignableFrom(TextView::class.java))     override fun perform(uiController: UiController, view: View) {         (view as TextView).apply {             this.text = "$text$value"         }         uiController.loopMainThreadUntilIdle()     }     ... } 

Чтобы сделать вашу пользовательскую операцию на 100% нативной для фреймворка Ultron, необходимо добавить еще 3 строки.

//support action for all Matcher<View> fun Matcher<View>.appendText(text: String) = UltronEspressoInteraction(onView(this)).appendText(text)  //support action for all ViewInteractions fun ViewInteraction.appendText(text: String) = UltronEspressoInteraction(this).appendText(text)  //support action for all DataInteractions fun DataInteraction.appendText(text: String) =  UltronEspressoInteraction(this).appendText(text) 

Наконец, мы можем использовать нашу кастомную операцию appendText:

withId(R.id.text_input).appendText("some text to append") 

Custom operation assertions

Бывают случаи, когда операция click() не проходит по элементу и тест падает (точнее проходит визуально, но почему-то не срабатывает onClick listener или не отсылается экшен на клик) или вам нужно сделать какой-то необходимый ассершн на клик, чтобы убедиться, что клик возымел эффект. Можно использовать следующее решение:

button.withAssertion("Assert smth is displayed") {     title.isDisplayed() }.click() 

«Assert smth is displayed» — это текст, который вы увидите в случае исключения.
Но это необязательное условие и можно написать так:

button.withAssertion {     title.isDisplayed() }.click() 

По умолчанию все операции Ultron внутри assertion блока не регистрируются в logcat, но в любом случае вы увидите причину в случае исключения.

Если вы хотите, чтобы информация регистрировалась в logcat, используйте параметр isListened

button.withAssertion(isListened = true) { .. } 

Здесь также нужно сделать одно замечание, про тайм-ауты операций assertion и action.

  • withAssertion {..} может удвоить время до возникновения исключения. Это происходит потому, что action выполняется как минимум дважды. И блок assertion также выполняется дважды. Это необходимо, чтобы определить истинное исключение по которому падает action.

  • В случае, если action выполнен успешно, но assertion failed, то action повторяется. У вас может быть несколько взаимодействий action и assertion.

  • Вы можете ограничить время assertion, например:

button.withAssertion {     title.withTimeout(3_000L).isDisplayed() }.click() 

Вы также все еще можете увеличить общий таймаут на всю операцию:

button.withTimeout(10_000L).withAssertion {     title.withTimeout(2_000L).isDisplayed() }.click() 

RuleSequence + SetUps & TearDowns для тестов

  • контролировать выполнение пред- и постусловий каждого теста

  • контролировать момент запуска активити

  • нет необходимости использовать @Before и @After, можно заменить их на лямбда-выражения объектов SetUpRule или TearDownRule

  • комбинируйте условия вашего теста, используя аннотации

RuleSequence

RuleSequence правило является усовершенствованной заменой JUnit 4 RuleChain. Это позволяет контролировать порядок выполнения правил.

RuleSequence не имеет проблем при наследовании классов и в этом его изначальная идея.

Порядок выполнения правил зависит от порядка их добавления. RuleSequence содержит три списка правил с собственным приоритетом.

first — правила из этого списка будут выполняться в первую очередь
normal — правила будут добавлены в этот список по умолчанию
last — правила из этого списка будут выполняться последними

Про порядок исполнения подробнее можно почитать тут.

Рекомендуется создавать RuleSequence в BaseTest. Вы сможете добавлять правила в RuleSequence в BaseTest и в подклассах BaseTest.

abstract class BaseTest {     val setupRule = SetUpRule().add {             // предусловия для тестов или автологин в приложение         }      @get:Rule     open val ruleSequence = RuleSequence(setupRule) } 

Добавление правил лучше делать в блоке init вашего тестового класса:

class DemoTest : BaseTest() {     private val activityRule = ActivityScenarioRule(MainActivity::class.java)      init {         ruleSequence.addLast(activityRule)     } } 

При использовании RuleSequence (как это было с RuleChain) вам не нужно указывать аннотацию @get:Rule для других правил.

Фулл примеры можно чекнуть тут:

SetUpRule

Это правило позволяет указать лямбда-выражения, которые обязательно будут вызываться перед запуском теста. Более того, в сочетании с настройкой RuleSequence лямбда-выражения могут быть вызваны до запуска активности.

Предусловия для тестов

Добавляем лямбду в SetUpRule без string key в скобках и она будет выполняться перед каждым тестом в классе.

open val setupRule = SetUpRule()     .add {         Log.info("Login valid user will be executed before any test is started")         AccountManager(InstrumentationRegistry.getInstrumentation().targetContext).login(             CURRENT_USER.login, CURRENT_USER.password         )     } 

Предусловие для конкретного теста

  1. добавить лямбду со string key в (..) в SetUpRule

  2. добавить аннотацию @SetUp(string key) к нужному тесту

setupRule.add(FIRST_CONDITION){      Log.info("$FIRST_CONDITION setup, executed for test with annotation @SetUp(FIRST_CONDITION)")   }  @SetUp(FIRST_CONDITION) @Test fun someTest() {     // степы теста }  companion object {     private const val FIRST_CONDITION = "Название предусловия" } 

Не забудьте добавить правило SetUpRule в Rule Sequence.

ruleSequence.add(setupRule) 

TearDownRule

Это правило позволяет указать лямбда-выражения, которые обязательно будут вызываться после завершения теста.

Постусловие для всех тестов

Добавьте лямбду в TearDownRule без string key в скобках, и она будет выполняться после каждого теста в классе.

open val tearDownRule = TearDownRule()     .add {         AccountManager(InstrumentationRegistry.getInstrumentation().targetContext).logout()     } 

Постусловие для конкретного теста

  1. добавить лямбду со string key в (..) в TearDownRule

  2. добавить аннотацию @TearDown(string key) к нужному тесту

tearDownRule.add (LAST_CONDITION){      Log.info("$LAST_CONDITION tearDown, executed for test with annotation @TearDown(LAST_CONDITION)")   }  @TearDown(LAST_CONDITION) @Test fun someTest() {     // степы теста }  companion object {     private const val LAST_CONDITION = "Название постусловия" } 

Не забудьте добавить правило TearDownRule в Rule Sequence.

ruleSequence.addLast(tearDownRule) 

Решение нетривиальных Espresso исключений

Одна из магических ошибок, которая возникает в процессе исполнения тестов может быть Waited for the root of the view hierarchy to have window focus and not request layout for 10 seconds. В данном фреймворке данная проблема решена и вот как это выглядит:

val toolbarTitle = withId(R.id.toolbar_title)  fun assertToolbarTitleWithSuitableRoot(text: String) {     toolbarTitle.withSuitableRoot().hasText(text) } 

Функция withSuitableRoot() ищет видимый root view, где находится ваша искомая view по матчеру и назначает его для вашего ViewInteraction. Если таковой не найдется в пределах внутреннего тайм-аута Espresso, то вывалится нативная ошибка NoMatchingRootException, что никакой root view не подходит для искомой view по вашему матчеру. В данном контексте это будет значить, что во всех видимых root view нет вашей искомой view.

Минусы фреймворка

  • Нет adbServer, есть только шелл

  • Фреймворк не имеет много авторов и развивается не так активно, как хотелось бы

Заключение

В данной статье я постарался подсветить ключевые фишки фреймворка и ту базу, которая всем интересна на этапе выбора фреймворка автоматизации ui на своем проекте. Спасибо, что дочитали / долистали до конца и надеюсь, что в данной статье вы смогли найти для себя что-то интересное.

Заходите в группу, если у вас есть вопросы Ultron telegram group.


ссылка на оригинал статьи https://habr.com/ru/articles/730144/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *