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