В большинстве случаев, когда разговор заходит об автоматизации тестирования на 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.

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


  1. Veygard
    21.04.2023 19:31
    +1

    Спасибо за отличную статью! Интересно будет попробовать ультрон.