Как сделать автотесты гибкими и лаконичными

от автора

При написании автотестов, так же как и при написании основного кода, важно придерживаться чистой архитектуры. Без нее мы можем столкнуться с некоторыми трудностями: при любых изменениях интерфейса потребуется обновлять код во множестве файлов, иногда тесты могут дублировать функциональность, а задача поддержать новые возможности приложения может превратиться в долгую и сложную адаптацию существующих тестов.

Меня зовут Арсений Федоров, я — разработчик автоматизированных тестов в команде 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 (справа) и ускоренного (слева):

image

Помимо этого, Kaspresso предоставляет возможность разделить тесты на шаги по аналогии с ручными test-case-ами, добавляя внутри логи на каждый шаг. В случае падения теста по логам вы сможете сразу понять, какие шаги были пройдены успешно, а на каком произошла ошибка. Помимо логов вам будут доступны иерархия графических элементов, видео, скриншоты экрана и т. д. При необходимости работать напрямую с OS Android вы можете воспользоваться встроенной в Kaspresso поддержкой adb. И результаты выполнения теста можно увидеть в понятном виде благодаря интеграции с Allure. Подробнее о преимуществах Kaspresso вы можете узнать здесь.

Приступим к делу, попробуем автоматизировать приложение Tutorial. Все описанные далее шаги вы можете воспроизвести самостоятельно, скачав исходники проекта и запустив его. Мы опишем с вами главный экран MainActivity, а также автоматизируем тестирование LoginActivity. Итоговый результат со всеми написанными тестами доступен в ветке TECH-tutorial-results, вы можете в любой момент перейти на нее и посмотреть готовый код.

MainActivity выглядит следующим образом:

Создадим объект MainScreen, наследующий KScreen:

object MainScreen : KScreen<MainScreen>() {      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<MainScreen>() {      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<MainActivity>()      @Test     fun test() {         MainScreen {             loginActivityButton {                 isVisible()                 click()             }         }     }  }

Вы можете заметить, что после создания объекта MainScreen в коде теста мы можем небольшим количеством строк обратиться к элементам экрана, выполнить необходимые проверки и кликнуть на нужную кнопку. Исполнив наш тест, мы встретим экран LoginActivity, сразу изучим его разметку:

Составим LoginScreen:

object LoginScreen : KScreen<LoginScreen>() {      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<MainActivity>()      @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<MainActivity>()      @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<MainActivity>()      @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<MainActivity>()      @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>.() -> 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>.() -> 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<MainActivity>()      @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<AfterLoginScreen>() {      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<MainActivity>()      @Test     fun test() {      } }

Для того чтобы попасть на этот экран, нам нужно пройти процесс авторизации. Без использования сценариев нам бы пришлось заново выполнять все шаги — запускать главный экран, кликать на кнопку, затем вводить логин и пароль и снова кликать на кнопку. Но сейчас весь этот процесс сводится к использованию LoginScenario:

class AfterLoginActivityTest : TestCase() {      @get:Rule     val activityRule = activityScenarioRule<MainActivity>()      @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<MainActivity>()      @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 и задать все необходимые вопросы можно в нашем телеграм-чате.


ссылка на оригинал статьи https://habr.com/ru/articles/773914/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *