Как сделать автотесты гибкими и лаконичными
При написании автотестов, так же как и при написании основного кода, важно придерживаться чистой архитектуры. Без нее мы можем столкнуться с некоторыми трудностями: при любых изменениях интерфейса потребуется обновлять код во множестве файлов, иногда тесты могут дублировать функциональность, а задача поддержать новые возможности приложения может превратиться в долгую и сложную адаптацию существующих тестов.
Меня зовут Арсений Федоров, я — разработчик автоматизированных тестов в команде Kaspersky Internet Security for Android. В этой статье покажу, как можно избежать всех вышеперечисленных проблем, выбрав другой подход к разработке автотестов, а также разберу несколько хороших практик.
Как паттерн «Page Object» код в порядок приводит
Когда мы пишем тесты для приложения, нам необходимо обращаться к элементам (View) приложения, выполняя различные проверки или действия. Если же в каждом написанном тесте мы будем постоянно явно прописывать идентификаторы (id) элементов, наш код будет уязвим к изменениям в пользовательском интерфейсе: из-за такого подхода нам придется переписывать все измененные идентификаторы в каждом автотесте, где они использовались.
Предотвратить подобные ситуации позволяет паттерн «Page object». Его суть заключена в представлении экрана (Page) приложения в виде объекта (тестовой абстракции), в котором объявлены и проинициализированы все графические элементы соответствующего экрана и организовано взаимодействие с ними. Более подробно про этот паттерн можно узнать здесь.
Все примеры, представленные в данной статье, будут использовать наш open-source фреймворк для автотестирования — Kaspresso. Почему же не Espresso?
Во-первых, Kaspresso использует декларативный подход к написанию тестов благодаря Kakao — обертке Kotlin DSL над Espresso. Лучше один раз увидеть, чем сто раз услышать:
Espresso
@Test
fun testFirstFeature() {
onView(withId(R.id.toFirstFeature))
.check(ViewAssertions.matches(
ViewMatchers.withEffectiveVisibility(
ViewMatchers.Visibility.VISIBLE)))
onView(withId(R.id.toFirstFeature)).perform(click())
}
Kakao
@Test
fun testFirstFeature() {
MainScreen {
toFirstFeatureButton {
isVisible()
click()
}
}
}
Во-вторых, Kaspresso повышает стабильность тестов: позволяет избегать flaky tests с помощью «перехватчиков» (interceptors). Они особенно полезны, когда мы работаем с асинхронными графическими элементами или списками.
В-третьих, Kaspresso содержит KAutomator — удобный Kotlin DSL wrapper над UiAutomator, который ускоряет UI-тесты. Вы можете увидеть разницу в работе стандартного UI Automator (справа) и ускоренного (слева):
Помимо этого, Kaspresso предоставляет возможность разделить тесты на шаги по аналогии с ручными test-case-ами, добавляя внутри логи на каждый шаг. В случае падения теста по логам вы сможете сразу понять, какие шаги были пройдены успешно, а на каком произошла ошибка. Помимо логов вам будут доступны иерархия графических элементов, видео, скриншоты экрана и т. д. При необходимости работать напрямую с OS Android вы можете воспользоваться встроенной в Kaspresso поддержкой adb. И результаты выполнения теста можно увидеть в понятном виде благодаря интеграции с Allure. Подробнее о преимуществах Kaspresso вы можете узнать здесь.
Приступим к делу, попробуем автоматизировать приложение Tutorial. Все описанные далее шаги вы можете воспроизвести самостоятельно, скачав исходники проекта и запустив его. Мы опишем с вами главный экран MainActivity, а также автоматизируем тестирование LoginActivity. Итоговый результат со всеми написанными тестами доступен в ветке TECH-tutorial-results, вы можете в любой момент перейти на нее и посмотреть готовый код.
MainActivity выглядит следующим образом:
Создадим объект MainScreen, наследующий KScreen:
object MainScreen : KScreen() {
override val layoutId: Int? = null
override val viewClass: Class<*>? = null
}
KScreen представляет собой реализацию паттерна «Page Object», где описываются все элементы, с которыми будет происходить взаимодействие во время теста.
Интересной особенностью реализации «Page Object» в Kaspresso являются переменные layoutId и viewClass — они помогают разработчикам тестов сразу понять, какой файл разметки используется для описываемого экрана, а также какой класс обеспечивает его функциональность. Но сейчас перед нами стоит задача обсудить саму концепцию «Page Object», поэтому пока мы можем их занулить.
Используя UI Automator Viewer или Layout Inspector в Android Studio, найдем идентификатор кнопки «Login Activity», по аналогии можно найти остальные идентификаторы для каждого элемента на нашем экране:
Описание элементов MainScreen выглядит вот так:
object MainScreen : KScreen() {
override val layoutId: Int? = null
override val viewClass: Class<*>? = null
val titleTextView = KTextView { withId(R.id.title) }
val simpleActivityButton = KButton { withId(R.id.simple_activity_btn) }
val wifiActivityButton = KButton { withId(R.id.wifi_activity_btn) }
val notificationActivityButton = KButton { withId(R.id.notification_activity_btn) }
val loginActivityButton = KButton { withId(R.id.login_activity_btn) }
val makeCallActivityButton = KButton { withId(R.id.make_call_activity_btn) }
val flakyActivityButton = KButton { withId(R.id.flaky_activity_btn) }
val listActivityButton = KButton { withId(R.id.list_activity_btn) }
}
Теперь из любого создаваемого нами теста мы можем обратиться к объекту MainScreen и работать с элементами экрана.
Напишем же наш первый тест, который проверит наличие кнопки «Login Activity» на экране и кликнет на нее.
Для этого создадим класс LoginActivityTest, наследующий TestCase:
class LoginActivityTest : TestCase() {
/**
* activityScenarioRule используется для вызова MainActivity перед запуском теста.
* Более подробную информацию о activityScenarioRule можно найти здесь:
* https://developer.android.com/reference/androidx/test/ext/junit/rules/ActivityScenarioRule
*/
@get:Rule
val activityRule = activityScenarioRule()
@Test
fun test() {
MainScreen {
loginActivityButton {
isVisible()
click()
}
}
}
}
Вы можете заметить, что после создания объекта MainScreen в коде теста мы можем небольшим количеством строк обратиться к элементам экрана, выполнить необходимые проверки и кликнуть на нужную кнопку. Исполнив наш тест, мы встретим экран LoginActivity, сразу изучим его разметку:
Составим LoginScreen:
object LoginScreen : KScreen() {
override val layoutId: Int? = null
override val viewClass: Class<*>? = null
val usernameEditText = KEditText { withId(R.id.input_username) }
val passwordEditText = KEditText { withId(R.id.input_password) }
val loginButton = KButton { withId(R.id.login_btn) }
}
Модернизируем LoginActivityTest и попробуем авторизоваться, используя логин »123456» и пароль »123456»:
class LoginActivityTest : TestCase() {
@get:Rule
val activityRule = activityScenarioRule()
@Test
fun test() {
val username = "123456"
val password = "123456"
MainScreen {
loginActivityButton {
isVisible()
click()
}
}
LoginScreen {
usernameEditText { replaceText(username) }
passwordEditText { replaceText(password) }
loginButton { click() }
}
}
}
После авторизации мы встретим последний экран — AfterLoginActivity.
Kaspresso позволяет внутри теста проверить, какая активность в данный момент отображается, для этого можно использовать класс Device. Завершим наш первый тест проверкой, что после авторизации AfterLoginActivity появилась на экране нашего устройства:
class LoginActivityTest : TestCase() {
@get:Rule
val activityRule = activityScenarioRule()
@Test
fun test() {
val username = "123456"
val password = "123456"
MainScreen {
loginActivityButton {
isVisible()
click()
}
}
LoginScreen {
usernameEditText { replaceText(username) }
passwordEditText { replaceText(password) }
loginButton { click() }
}
device.activities.isCurrent(AfterLoginActivity::class.java)
}
}
Если бы мы не использовали паттерн «Page Object», код нашего теста выглядел бы следующим образом:
class LoginActivityTest : TestCase() {
@get:Rule
val activityRule = activityScenarioRule()
@Test
fun test() {
val username = "123456"
val password = "123456"
val loginActivityButton = KButton { withId(R.id.login_activity_btn) }
loginActivityButton {
isVisible()
click()
}
val usernameEditText = KEditText { withId(R.id.input_username) }
val passwordEditText = KEditText { withId(R.id.input_password) }
val loginButton = KButton { withId(R.id.login_btn) }
usernameEditText { replaceText(username) }
passwordEditText { replaceText(password) }
loginButton { click() }
device.activities.isCurrent(AfterLoginActivity::class.java)
}
}
При таком подходе довольно проблематично с ходу понять, в каких строчках с какими экранами взаимодействует тест. Если в таком автотесте добавятся новые проверки и действия, существует риск, что код станет совсем нечитаемым. Поэтому для создания хороших и масштабируемых автотестов рекомендуется использовать «Page Object».
Разделяем тест на шаги
Любой тест (в том числе ручной) выполняется по test-кейсам, то есть у тестировщика есть последовательность шагов, которые он выполняет для проверки работоспособности экрана. Kaspresso позволяет разделить наш код на шаги при помощи конструкции step (). Step-ы также помогают структурировать логи нашего автотеста.
Чтобы использовать step-ы, необходимо внутри теста вызвать метод run{} и в фигурных скобках перечислить все шаги, которые будут выполнены во время теста. Каждый шаг нужно вызывать внутри функции step ().
Испытаем на практике:
class LoginActivityTest : TestCase() {
@get:Rule
val activityRule = activityScenarioRule()
@Test
fun test() {
run {
val username = "123456"
val password = "123456"
step("Open login screen") {
MainScreen {
loginActivityButton {
isVisible()
click()
}
}
}
step("Try to login") {
LoginScreen {
usernameEditText { replaceText(username) }
passwordEditText { replaceText(password) }
loginButton { click() }
}
}
step("Check current screen") {
device.activities.isCurrent(AfterLoginActivity::class.java)
}
}
}
}
Благодаря step-ам логи уровня INFO с тегом «KASPRESSO» выглядят следующим образом:
Если же остались вопросы касательно Steps, предлагаю вам ознакомиться с этой статьей. В ней также рассказывается подробнее о секциях Before/After, которые вы могли заметить в логах.
Давайте теперь попробуем реализовать проверки негативных сценариев — если пользователь ввел логин или пароль меньше допустимой длины (6 символов).
При создании нескольких автотестов нужно придерживаться правила — на каждый test-case свой тестовый метод. То есть проверку на поведение при вводе некорректного логина и пароля мы не будем делать в этом же методе, а создадим отдельные в том же классе LoginActivityTest:
@Test
fun loginUnsuccessfulIfUsernameIncorrect() {
run {
val username = "12"
val password = "123456"
step("Open login screen") {
MainScreen {
loginActivityButton {
isVisible()
click()
}
}
}
step("Try to login") {
LoginScreen {
usernameEditText { replaceText(username) }
passwordEditText { replaceText(password) }
loginButton { click() }
}
}
step("Check current screen") {
device.activities.isCurrent(LoginActivity::class.java)
}
}
}
И такой же тест на то, что логин введен верно, а пароль неверно:
@Test
fun loginUnsuccessfulIfPasswordIncorrect() {
run {
val username = "123456"
val password = "1234"
step("Open login screen") {
MainScreen {
loginActivityButton {
isVisible()
click()
}
}
}
step("Try to login") {
LoginScreen {
usernameEditText { replaceText(username) }
passwordEditText { replaceText(password) }
loginButton { click() }
}
}
step("Check current screen") {
device.activities.isCurrent(LoginActivity::class.java)
}
}
}
Предлагаю также переименовать первый тест, чтобы по его названию было понятно, что мы проверяем именно успешную авторизацию.
@Test
fun test()
Меняем на:
@Test
fun loginSuccessfulIfUsernameAndPasswordCorrect()
Можно заметить, что в приведенных выше автотестах повторяются строки с переходом на экран LoginActivity и вводом реквизитов для входа. Было бы интересно переиспользовать эти шаги!
Используем сценарии
В Kaspresso есть инструмент Scenario, позволяющий объединить несколько шагов в упорядоченную последовательность действий, которую будет удобно использовать в тестах, где данные шаги повторяются.
Создадим класс LoginScenario, наследующий Scenario. Для его работы необходимо переопределить свойство steps, в котором мы должны перечислить все шаги данного сценария.
class LoginScenario : Scenario() {
override val steps: TestContext.() -> Unit = {
step("Open login screen") {
MainScreen {
loginActivityButton {
isVisible()
click()
}
}
}
step("Try to login") {
LoginScreen {
usernameEditText { replaceText(username) }
passwordEditText { replaceText(password) }
loginButton { click() }
}
}
}
}
Но здесь возникает проблема, что мы не инициализировали username и password. Решить это можно, указав их в качестве параметра в классе LoginScenario внутри конструктора. Тогда эта часть кода:
class LoginScenario : Scenario()
Меняется на:
class LoginScenario(
private val username: String,
private val password: String
) : Scenario()
Итоговый код сценария:
class LoginScenario(
private val username: String,
private val password: String
) : Scenario() {
override val steps: TestContext.() -> Unit = {
step("Open login screen") {
MainScreen {
loginActivityButton {
isVisible()
click()
}
}
}
step("Try to login") {
LoginScreen {
usernameEditText { replaceText(username) }
passwordEditText { replaceText(password) }
loginButton { click() }
}
}
}
}
Применим сценарий в наших тестах в LoginActivityTest:
class LoginActivityTest : TestCase() {
@get:Rule
val activityRule = activityScenarioRule()
@Test
fun loginSuccessfulIfUsernameAndPasswordCorrect() {
run {
step("Try to login with correct username and password") {
scenario(
LoginScenario(
username = "123456",
password = "123456",
)
)
}
step("Check current screen") {
device.activities.isCurrent(AfterLoginActivity::class.java)
}
}
}
@Test
fun loginUnsuccessfulIfUsernameIncorrect() {
run {
step("Try to login with incorrect username") {
scenario(
LoginScenario(
username = "12",
password = "123456",
)
)
}
step("Check current screen") {
device.activities.isCurrent(LoginActivity::class.java)
}
}
}
@Test
fun loginUnsuccessfulIfPasswordIncorrect() {
run {
step("Try to login with incorrect password") {
scenario(
LoginScenario(
username = "123456",
password = "1234",
)
)
}
step("Check current screen") {
device.activities.isCurrent(LoginActivity::class.java)
}
}
}
}
Мы рассмотрели один случай, когда сценариями удобно пользоваться, — когда одни и те же шаги используются в разных тестах в рамках тестирования одного экрана. Но это не единственное их предназначение.
В приложении может быть множество экранов, попасть на которые можно только будучи авторизованным. В этом случае для каждого такого экрана придется заново описывать все шаги авторизации. Но при использовании сценариев это становится очень простой задачей.
Сейчас после входа у нас открывается экран AfterLoginActivity. Давайте напишем тест для этого экрана.
Первым делом создаем Page Object:
object AfterLoginScreen : KScreen() {
override val layoutId: Int? = null
override val viewClass: Class<*>? = null
val title = KTextView { withId(R.id.title) }
}
Добавляем тест:
class AfterLoginActivityTest : TestCase() {
@get:Rule
val activityRule = activityScenarioRule()
@Test
fun test() {
}
}
Для того чтобы попасть на этот экран, нам нужно пройти процесс авторизации. Без использования сценариев нам бы пришлось заново выполнять все шаги — запускать главный экран, кликать на кнопку, затем вводить логин и пароль и снова кликать на кнопку. Но сейчас весь этот процесс сводится к использованию LoginScenario:
class AfterLoginActivityTest : TestCase() {
@get:Rule
val activityRule = activityScenarioRule()
@Test
fun test() {
run {
step("Open AfterLogin screen") {
scenario(
LoginScenario(
username = "123456",
password = "123456"
)
)
}
step("Check title") {
AfterLoginScreen {
title {
isVisible()
}
}
}
}
}
}
Также сценарии можно вызывать внутри других сценариев, больше о Scenario вы можете узнать здесь.
Таким образом, благодаря использованию сценариев код становится чистым, понятным и переиспользуемым. А для проверки экранов, доступных только авторизованным пользователям, теперь не нужно делать множество одинаковых шагов. Важно отметить, что мы добились хорошей расширяемости нашего автотеста. В случае если на экране LoginActivity изменятся идентификаторы UI-элементов, код самих тестов не будет подвержен изменениям. Чтобы вернуть работоспособность тестам, нужно будет поправить только LoginScreen.
Для контраста приведем код созданных нами автотестов без использования хороших практик, который теперь сможет присниться вам только в кошмарах:
class LoginActivityTest : TestCase() {
@get:Rule
val activityRule = activityScenarioRule()
@Test
fun test() {
val loginActivityButton = KButton { withId(R.id.login_activity_btn) }
loginActivityButton {
isVisible()
click()
}
val usernameEditText = KEditText { withId(R.id.input_username) }
val passwordEditText = KEditText { withId(R.id.input_password) }
val loginButton = KButton { withId(R.id.login_btn) }
usernameEditText { replaceText("123456") }
passwordEditText { replaceText("123456") }
loginButton { click() }
device.activities.isCurrent(AfterLoginActivity::class.java)
pressBack()
usernameEditText { replaceText("123456") }
passwordEditText { replaceText("1234") }
loginButton { click() }
device.activities.isCurrent(LoginActivity::class.java)
usernameEditText { replaceText("12") }
passwordEditText { replaceText("123456") }
loginButton { click() }
device.activities.isCurrent(LoginActivity::class.java)
}
}
Подводим итоги
Сегодня мы с вами научились писать гибкие, масштабируемые, легко поддерживаемые автотесты, применяя паттерн «Page Object» и удобство Kaspresso. Если вас заинтересовал фреймворк Kaspresso и вы хотите внедрить его в свой проект — по этой ссылке доступен полный туториал со всеми деталями.
Также есть возможность посмотреть исходный код Kaspresso в Github. Будем благодарны вашим звездочкам, и, если вы захотите что-нибудь улучшить в Kaspresso, присоединяйтесь к числу контрибьюторов.
А пообщаться с разработчиками Kaspresso и задать все необходимые вопросы можно в нашем телеграм-чате.