В большинстве случаев, когда разговор заходит об автоматизации тестирования на 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 ) }
Предусловие для конкретного теста
-
добавить лямбду со string key в (..) в SetUpRule
-
добавить аннотацию @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() }
Постусловие для конкретного теста
-
добавить лямбду со string key в (..) в TearDownRule
-
добавить аннотацию @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/
Добавить комментарий