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

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

cawc8vnleqzkfkogcatemvvtysk.png

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

8ghmga0d97daz5wyqoghxlxm9yc.png

Создадим объект 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», по аналогии можно найти остальные идентификаторы для каждого элемента на нашем экране:

v0p_d4ufvrwslc14lqojnopqfg0.png

Описание элементов 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, сразу изучим его разметку:
nf-g_dlvq8h2dskgqpdmx2pn_ne.png

Составим 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.
xrevhfktdjkckwkezvyh9dvbksq.png

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» выглядят следующим образом:
vnaskaal5tdsvbrhca_aqzve3ju.png


Если же остались вопросы касательно 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 и задать все необходимые вопросы можно в нашем телеграм-чате.

© Habrahabr.ru