Как сделать тесты на Espresso более читабельными и стабильными
Если вы писали тесты на Espresso — тестовом фреймворке от Google с открытым исходным кодом, — то вы знаете, что они не всегда стабильны и легко читаемы. Меня зовут Ксения Никитина, я являюсь Android-разработчиком в мобильной команде «Лаборатории Касперского». В этой статье я предложу вам способ, как сделать так, чтобы ваши автотесты отвечали всем ключевым качествам: были хорошо читаемы, стабильны, логируемы, давали возможность делать скриншоты, работали с AndroidOS и, наконец, имели продуманную и понятную архитектуру.
Итак, когда появляется необходимость в написании автотестов на Android, мы чаще всего обращаемся к фреймворку Espresso от Google (если вдруг вы не знакомы с Espresso, подробности можно найти в официальной документации). Espresso позволяет работать с элементами приложения нативно и методом белого ящика. Нужные элементы можно сначала находить на экране с помощью matcher’ов, а затем выполнять с ними различные действия или проверки.
Рассмотрим работу автотестов на конкретном примере. Запустить приложение и воспроизвести все описанные далее шаги можно самостоятельно, скачав исходники на нашем гитхабе и запустив проект Tutorial.
При открытии приложения мы видим экран, содержащий кнопку Internet Availability, при нажатии на которую мы переходим на экран проверки состояния Wifi.
Кнопка Check Wifi status кликабельна. После клика отображается текущий статус состояния Wifi.
Нам также важно, чтобы при повороте экрана текст, указывающий статус Wifi, не менялся.
Итого, для проверки корректности работы приложения нам необходимо пройти следующие шаги:
- Перед началом прохождения теста установить на устройстве книжную ориентацию (landscape mode), включить Wifi и запустить главный экран приложения.
- Проверить, что кнопка Internet Availability видна и кликабельна.
- Проверить, что заголовок, свидетельствующий о статусе Wifi, не содержит текст.
- Кликнуть по кнопке Check Wifi status.
- Проверить, что текст в заголовке стал enabled.
- Отключить Wifi.
- Кликнуть по кнопке Check Wifi status.
- Проверить, что текст в заголовке стал disabled.
- Перевернуть устройство.
- Проверить, что текст в заголовке сохранился disabled.
Давайте попробуем автоматизировать этот текстовый тест-кейс и на его примере обсудим имеющиеся недостатки Espresso.
Пример теста на Espresso
Наш тестовый класс с использованием фреймворка Espresso:
package com.kaspersky.kaspresso.tutorial
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isClickable
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.activityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class WifiSampleEspressoTest {
@get:Rule
val activityRule = activityScenarioRule()
@Test
fun test() {
// launch target screen
onView(withId(R.id.wifi_activity_btn)).check(matches(isDisplayed()))
onView(withId(R.id.wifi_activity_btn)).check(matches(isClickable()))
onView(withId(R.id.wifi_activity_btn)).perform(ViewActions.click())
// set portrait orientation
val uiAutomation = InstrumentationRegistry.getInstrumentation().uiAutomation
uiAutomation.executeShellCommand(SHELL_COMMAND_AUTO_ROTATE_DISABLE)
uiAutomation.executeShellCommand(SHELL_COMMAND_PORTRAIT_ORIENTATION)
// test
onView(withId(R.id.wifi_status)).check(matches(withText("")))
onView(withId(R.id.check_wifi_btn)).check(matches(isDisplayed()))
onView(withId(R.id.check_wifi_btn)).check(matches(isClickable()))
onView(withId(R.id.check_wifi_btn)).perform(ViewActions.click())
onView(withId(R.id.wifi_status)).check(matches(withText(R.string.enabled_status)))
// Turning off wifi
uiAutomation.executeShellCommand(SHELL_COMMAND_TURN_OFF_WIFI)
// wait for switching wifi
Thread.sleep(3000)
// test
onView(withId(R.id.check_wifi_btn)).perform(ViewActions.click())
onView(withId(R.id.wifi_status)).check(matches(withText(R.string.disabled_status)))
//rotate
uiAutomation.executeShellCommand(SHELL_COMMAND_AUTO_ROTATE_DISABLE)
uiAutomation.executeShellCommand(SHELL_COMMAND_LANDSCAPE_ORIENTATION)
// wait for rotation
Thread.sleep(3000)
// test
onView(withId(R.id.wifi_status)).check(matches(withText(R.string.disabled_status)))
}
private companion object {
const val SHELL_COMMAND_AUTO_ROTATE_DISABLE = "content insert --uri content://settings/system --bind name:s:accelerometer_rotation --bind value:i:0"
const val SHELL_COMMAND_PORTRAIT_ORIENTATION = "content insert --uri content://settings/system --bind name:s:user_rotation --bind value:i:0"
const val SHELL_COMMAND_LANDSCAPE_ORIENTATION = "content insert --uri content://settings/system --bind name:s:user_rotation --bind value:i:1"
const val SHELL_COMMAND_TURN_OFF_WIFI = "svc wifi disable"
}
}
В этом примере мы столкнулись с тем, что невозможно реализовать тест только средствами Espresso, поэтому нам пришлось воспользоваться библиотекой UiAutomator. Тест запускается и позволяет проверить корректность работы приложения, но, к сожалению, написанный код имеет несколько недостатков.
Что нам хотелось бы улучшить в написанном коде: разделить логику описания элементов и последовательность действий и проверок, использовать декларативный стиль написания тестов, избавиться от большого количества повторяющихся вызовов onView и иерархической вложенности. К сожалению, покрыть все потребности в автотестировании Android только с помощью Espresso невозможно из-за отсутствия определенных фич.
Улучшение кода
Попробуем переписать код нашего теста, прибегнув к помощи open-source-фреймворка Kaspresso, который использует декларативный подход к написанию тестов. Давайте рассмотрим на конкретном участке кода.
Было так:
onView(withId(R.id.wifi_activity_btn)).check(matches(isDisplayed()))
onView(withId(R.id.wifi_activity_btn)).check(matches(isClickable()))
onView(withId(R.id.wifi_activity_btn)).perform(ViewActions.click())
А станет вот так:
wifiActivityButton {
isVisible()
isClickable()
click()
}
Kaspresso использует под капотом Kakao — Kotlin-DSL-обертку над Espresso. От Kakao open-source-фреймворк Kaspresso унаследовал две основные концепции. Первая — KView — особое представление элементов интерфейса, с которыми будет происходить взаимодействие в тесте. Использование KView избавляет нас от постоянного вызова метода onView, теперь достаточно всего один раз положить matcher«ы в конструктор KView.
Вторая концепция — класс Screen (реализация паттерна Page Object), где описываются все элементы, с которыми будет происходить взаимодействие во время теста. Концепция пришла из веб-разработки, она заключается в создании описания экрана, видимого пользователю. Этот объект не содержит никакой логики. Это позволяет в отдельном файле описать скрины (Screens) и их элементы и взаимодействовать с ними из кода класса теста в декларативном стиле. Таким образом, Page Object«ы являются абсолютно независимыми, за счет чего достигается максимальная переиспользуемость.
Давайте перепишем наш тестовый класс, используя фреймворк Kaspresso. Если у вас возникнут сложности с одним из последующих шагов, вы можете найти готовый вариант автотеста в ветке tutorial_results.
Первым делом надо создать Page Object MainScreen:
package com.kaspersky.kaspresso.tutorial.screen
import com.kaspersky.kaspresso.screens.KScreen
import com.kaspersky.kaspresso.tutorial.R
import io.github.kakaocup.kakao.text.KButton
object MainScreen : KScreen() {
override val layoutId: Int? = null
override val viewClass: Class<*>? = null
val wifiActivityButton = KButton { withId(R.id.wifi_activity_btn) }
}
Здесь мы встречаемся с необходимостью определить id макета (layoutId), который установлен на экране, и название класса (viewClass). Это требуется для связывания теста с конкретным файлом верстки и классом activity или fragment. Такое связывание сделает дальнейшую поддержку и доработку теста более удобной, но пока перед нами стоит задача рассмотреть демонстрационный тестовый класс, поэтому оставим значение null.
Далее создадим WifiScreen:
package com.kaspersky.kaspresso.tutorial.screen
import com.kaspersky.kaspresso.screens.KScreen
import com.kaspersky.kaspresso.tutorial.R
import io.github.kakaocup.kakao.text.KButton
import io.github.kakaocup.kakao.text.KTextView
object WifiScreen : KScreen() {
override val layoutId: Int? = null
override val viewClass: Class<*>? = null
val checkWifiButton = KButton { withId(R.id.check_wifi_btn) }
val wifiStatus = KTextView { withId(R.id.wifi_status) }
}
Теперь займемся тест-кейсами. Класс тестов должен быть унаследован от класса TestCase. Создаем такой класс WifiSampleTest и добавляем метод, помеченный аннотацией Test (import org.junit.Test), в котором будем проверять работу приложения.
package com.kaspersky.kaspresso.tutorial
import androidx.test.ext.junit.rules.activityScenarioRule
import com.kaspersky.kaspresso.device.exploit.Exploit
import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
import com.kaspersky.kaspresso.tutorial.screen.MainScreen
import com.kaspersky.kaspresso.tutorial.screen.WifiScreen
import org.junit.Rule
import org.junit.Test
class WifiSampleTest : TestCase() {
@get:Rule
val activityRule = activityScenarioRule()
@Test
fun test() {
MainScreen {
wifiActivityButton {
isVisible()
isClickable()
click()
}
}
WifiScreen { device.exploit.setOrientation(Exploit.DeviceOrientation.Portrait)
wifiStatus.hasEmptyText()
checkWifiButton {
isVisible()
isClickable()
click()
}
wifiStatus.hasText(R.string.enabled_status)
device.network.toggleWiFi(false)
checkWifiButton.click()
wifiStatus.hasText(R.string.disabled_status)
device.exploit.rotate()
wifiStatus.hasText(R.string.disabled_status)
}
}
}
Ранее мы упоминали, что Espresso не умеет работать с Android OS, вследствие чего пользователю приходится использовать библиотеку UiAutomator. Обратите внимание, что в коде теста используется экземпляр класса Device, являющегося частью фреймворка Kaspresso. У этого объекта есть множество полезных методов, подходящих для взаимодействия с Android OS. Подробнее про них вы можете почитать здесь.
Что мы получили?
- Разграничение описания экранов и проверки логики их работы — тест-кейсы теперь могут быть описаны независимо от экранов, а сами классы скринов могут быть переиспользованы в разных тестах.
- Код стал гораздо более читаемым, и появилась возможность писать тесты в декларативном стиле — просто указываем, на каком скрине какие действия и проверки мы хотим выполнить.
Разделение кода в соответствии с шагами тест-кейсов
Код тестового класса уже стал выглядеть лучше, но в нем все еще присутствуют проблемные места. Обычно любые тесты (в том числе ручные) выполняются по тест-кейсам. То есть у тестировщика есть последовательность шагов, которые он выполняет для проверки работоспособности экрана. Теперь представим, что наш тестовый класс содержит в несколько раз больше шагов и, соответственно, строк кода. Разобраться и добавить необходимые строчки в таком случае будет крайне трудно, непонятно, где завершается один шаг и начинается другой. Мы можем решить эту проблему при помощи комментариев. Добавим комментарии к каждому шагу в нашем тестовом классе:
package com.kaspersky.kaspresso.tutorial
import androidx.test.ext.junit.rules.activityScenarioRule
import com.kaspersky.kaspresso.device.exploit.Exploit
import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
import com.kaspersky.kaspresso.tutorial.screen.MainScreen
import com.kaspersky.kaspresso.tutorial.screen.WifiScreen
import org.junit.Rule
import org.junit.Test
class WifiSampleWithStepsTest : TestCase() {
@get:Rule
val activityRule = activityScenarioRule()
@Test
fun test() {
// Step 1. Open target screen
MainScreen {
wifiActivityButton {
isVisible()
isClickable()
click()
}
}
WifiScreen {
// Step 2. Check correct wifi status
device.exploit.setOrientation(Exploit.DeviceOrientation.Portrait)
wifiStatus.hasEmptyText()
checkWifiButton {
isVisible()
isClickable()
click()
}
wifiStatus.hasText(R.string.enabled_status)
device.network.toggleWiFi(false)
checkWifiButton.click()
wifiStatus.hasText(R.string.disabled_status)
// Step 3. Rotate device and check wifi status
device.exploit.rotate()
wifiStatus.hasText(R.string.disabled_status)
}
}
}
Это немного улучшит читаемость кода, но всех проблем не решит. Например, после падения одного из тестов, как мы узнаем, на каком шаге это произошло? Espresso в таком случае просто даст дамп в лог, придется исследовать записи, пытаясь понять, что пошло не так. Было бы гораздо лучше, если бы в логах отображались сведения о начале и завершении каждого шага.
Мы также можем обернуть некоторые участки кода в блок try-catch, чтобы фиксировать падение теста. Если мы это сделаем, наш тест будет выглядеть следующим образом:
package com.kaspersky.kaspresso.tutorial
import android.util.Log
import androidx.test.core.app.takeScreenshot
import androidx.test.ext.junit.rules.activityScenarioRule
import com.kaspersky.kaspresso.device.exploit.Exploit
import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
import com.kaspersky.kaspresso.tutorial.screen.MainScreen
import com.kaspersky.kaspresso.tutorial.screen.WifiScreen
import org.junit.Rule
import org.junit.Test
class WifiSampleWithStepsTest : TestCase() {
@get:Rule
val activityRule = activityScenarioRule()
@Test
fun test() {
try {
Log.i("KASPRESSO", "Step 1. Open target screen -> started")
MainScreen {
wifiActivityButton {
isVisible()
isClickable()
click()
}
}
Log.i("KASPRESSO", "Step 1. Open target screen -> succeed")
} catch (e: Throwable) {
Log.i("KASPRESSO", "Step 1. Open target screen -> failed")
takeScreenshot()
}
WifiScreen {
try {
Log.i("KASPRESSO", "Step 2. Check correct wifi status -> started")
device.exploit.setOrientation(Exploit.DeviceOrientation.Portrait)
wifiStatus.hasEmptyText()
checkWifiButton {
isVisible()
isClickable()
click()
}
wifiStatus.hasText(R.string.enabled_status)
device.network.toggleWiFi(false)
checkWifiButton.click()
wifiStatus.hasText(R.string.disabled_status)
Log.i("KASPRESSO", "Step 2. Check correct wifi status -> succeed")
} catch (e: Throwable) {
Log.i("KASPRESSO", "Step 2. Check correct wifi status -> failed")
}
try {
Log.i("KASPRESSO", "Step 3. Rotate device and check wifi status -> started")
device.exploit.rotate()
wifiStatus.hasText(R.string.disabled_status)
Log.i("KASPRESSO", "Step 3. Rotate device and check wifi status -> succeed")
} catch (e: Throwable) {
Log.i("KASPRESSO", "Step 3. Rotate device and check wifi status -> failed")
takeScreenshot()
}
}
}
}
В некоторых блоках catch мы создаем скриншоты, которые в будущем могли бы помочь анализировать падения. Один из способов сделать скриншот — вызвать метод takeScreenshot (), но использовать его напрямую не рекомендуется. С более удобным и гибким инструментом для создания скриншотов в Kaspresso можно познакомиться в этом уроке.
Open-source-фреймворк Kaspresso предлагает полезную и удобную абстракцию — Step. У нее внутри реализовано все то, что мы сейчас написали вручную.
Чтобы использовать step«ы, необходимо вызвать метод run {} и в фигурных скобках перечислить все шаги, которые будут выполнены во время теста. Каждый шаг нужно вызывать внутри блока step. Тогда код теста будет выглядеть следующим образом:
package com.kaspersky.kaspresso.tutorial
import androidx.test.ext.junit.rules.activityScenarioRule
import com.kaspersky.kaspresso.device.exploit.Exploit
import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
import com.kaspersky.kaspresso.tutorial.screen.MainScreen
import com.kaspersky.kaspresso.tutorial.screen.WifiScreen
import org.junit.Rule
import org.junit.Test
class WifiSampleWithStepsTest : TestCase() {
@get:Rule
val activityRule = activityScenarioRule()
@Test
fun test() {
run {
step("Open target screen") {
MainScreen {
wifiActivityButton {
isVisible()
isClickable()
click()
}
}
}
step("Check correct wifi status") {
WifiScreen {
device.exploit.setOrientation(Exploit.DeviceOrientation.Portrait)
wifiStatus.hasEmptyText()
checkWifiButton {
isVisible()
isClickable()
click()
}
wifiStatus.hasText(R.string.enabled_status)
device.network.toggleWiFi(false)
checkWifiButton.click()
wifiStatus.hasText(R.string.disabled_status)
}
}
step("Rotate device and check wifi status") {
WifiScreen {
device.exploit.rotate()
wifiStatus.hasText(R.string.disabled_status)
}
}
}
}
}
Давайте посмотрим в логи. Там мы увидим как логи конкретных действий и проверок, производимых во время теста, так и метаинформацию о шагах — название, вызывающий класс, время прохождения, сообщение об успехе или лог ошибки, если тест упал.
Каждый пользователь фреймворка Kaspresso может добавить к функционалу step«ов необходимые ему улучшения: запись шагов в allure-отчет, скриншоты на месте падения шага, вывод иерархии view и многое другое. Для этого пользователю необходимо написать свой перехватчик или использовать один из готовых.
Запустим тест еще раз с выключенным Интернетом:
Sections — подготовка состояний до и после теста
Часто, когда мы составляем тесты, нам приходится учитывать, что проверять работу приложения необходимо в условиях определенной подготовленности. Так и в нашем тесте, перед каждым запуском требуется, чтобы устройство приходило в дефолтное состояние и было возвращено в него после прохождения теста.
Для этого в Kaspresso есть блоки before и after. Код внутри блока before будет выполняться перед тестом — здесь мы можем установить настройки по умолчанию. Во время выполнения теста состояние телефона может меняться: мы можем выключить Интернет, сменить ориентацию, но после теста нужно вернуть исходное состояние. Делать это мы будем внутри блока after.
Улучшим наш тестовый класс, используя секции. Перед началом прохождения теста в секции before устанавливаем книжную ориентацию (landscape mode) и включаем Wifi:
device.exploit.setOrientation(Exploit.DeviceOrientation.Portrait) device.network.toggleWiFi(true)
Блоки before и after не только помогают визуально выделить подготовку нужного состояния в отдельные блоки, но и гарантировать их выполнение. Если мы обратимся к тесту, который писали в начале статьи с использованием Espresso, то увидим, что те же действия были записаны в виде шагов, и, если тест упадет в середине выполнения, установка настроек по умолчанию может так и не оказаться вызванной. В случае применения секций before и after гарантируется выполнение этих блоков в нужные моменты.
Также заметим, что сейчас после переворота устройства мы проверяем, что текст остался прежним, но не проверяем, что ориентация действительно поменялась. Получается, что если метод device.exploit.rotate () по какой-то причине не сработал, то ориентация не меняется и проверка текста будет бесполезной. Давайте добавим проверку, что ориентация девайса стала альбомной.
Assert.assertTrue(device.context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE)
package com.kaspersky.kaspresso.tutorial
import android.content.res.Configuration
import androidx.test.ext.junit.rules.activityScenarioRule
import com.kaspersky.kaspresso.device.exploit.Exploit
import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
import com.kaspersky.kaspresso.tutorial.screen.MainScreen
import com.kaspersky.kaspresso.tutorial.screen.WifiScreen
import org.junit.Assert
import org.junit.Rule
import org.junit.Test
class WifiSampleWithStepsTest : TestCase() {
@get:Rule
val activityRule = activityScenarioRule()
@Test
fun test() {
before {
device.exploit.setOrientation(Exploit.DeviceOrientation.Portrait)
device.network.toggleWiFi(true)
}.after {
device.exploit.setOrientation(Exploit.DeviceOrientation.Portrait)
device.network.toggleWiFi(true)
}.run {
step("Open target screen") {
MainScreen {
wifiActivityButton {
isVisible()
isClickable()
click()
}
}
}
step("Check correct wifi status") {
WifiScreen {
wifiStatus.hasEmptyText()
checkWifiButton {
isVisible()
isClickable()
click()
}
wifiStatus.hasText(R.string.enabled_status)
device.network.toggleWiFi(false)
checkWifiButton.click()
wifiStatus.hasText(R.string.disabled_status)
}
}
step("Rotate device and check wifi status") {
WifiScreen {
device.exploit.rotate()
Assert.assertTrue(device.context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE)
wifiStatus.hasText(R.string.disabled_status)
}
}
}
}
}
Заключение и полезные ссылки
Итак, подведем итоги. Open-source-фреймворк Kaspresso позволяет делать тестовый код читаемым за счет декларативного подхода с использованием Kotlin DSL и реализованным паттерном Page Object «из коробки» и стабильным за счет внутренних доработок и интерсепторов. Некоторые плюсы Kaspresso, рассмотренные нами на конкретном примере:
- Хорошая читаемость — больше не нужно использовать длинные конструкции с matcher«ами для поиска элементов на экране для взаимодействия из теста, и все элементы интерфейса, с которыми будет взаимодействовать ваш тест, могут быть описаны в одном месте — в конкретном объекте Screen.
- Логирование — Kaspresso предоставляет развернутые и понятные логи с указанием текущего шага. Вам не нужно их добавлять вручную — все реализовано внутри. В случае необходимости вы можете изменять и дополнять логи по своему желанию.
- Архитектура кода — используя описанную выше реализацию паттерна Page Object, вы можете сделать свой код в тестовых файлах более читабельным, удобным для дальнейшей поддержки, повторно используемым и понятным. Kaspresso также предоставляет различные методы и абстракции для улучшения архитектуры (такие как step, секции before и after и многое другое).
- Низкий порог входа в освоение фреймворка — для написания автотестов не требуется знания языка программирования, достаточно использовать декларативный подход.
Если эта статья была полезна для вас и вы планируете использовать Kaspresso в своих проектах, то присоединяйтесь к сообществу Kaspresso в Телеграм. Там мы постараемся оказать вам всю необходимую поддержку и проанонсируем следующие статьи из текущего цикла материалов о Kaspresso.
Вы также можете пройти Tutorial, подготовленный командой Kaspresso, чтобы ознакомиться с другими возможностями фреймворка, и посетить раздел Wiki. Надеемся увидеть вас среди наших контрибьюторов!
И не забудьте поставить звезду на Github;)