Автоматизируем UI-тесты Android приложения с использование паттерна Page Object +9

image

Паттерн Page Object появился в тестировании web и очень хорошо себя там зарекомендовал. Когда я начал автоматизировать тесты для android приложения, то первым делом подумал про него. Поискал информацию в сети, поспрашивал коллег и, в принципе, не нашел доводов не попробовать. Предлагаю посмотреть, что из этого вышло.

Классический Page Object подразумевает два уровня абстракции: элементы страницы и тесты. Я выделяю еще один — бизнес-логика. Отмечу, что то, как вы построите ваш фреймворк, будет очень сильно влиять на простоту написания тестов в будущем, а так же на их поддержку. Я стараюсь делать так, чтобы код теста выглядел как будто это обычный тест-кейс, написанный рядовым тестером. Т.е. начинаю с конца:

  1. Пишу красивый и понятный код тест-кейса,
  2. Реализую методы из тест-кейса в слое бизнес-логики,
  3. Описываю элементы, которые нужны для работы теста.

Данный подход хорош тем, что мы не делаем ничего лишнего — фреймворк построен на столько, на сколько это нужно для работы тестов. Можно сказать, что это концепция MVP в тестировании: быстро сделали кусочек, и он уже начал приносить пользу. Если вы сначала пишете тысячи строк, описывая страницы вашего приложения и способы взаимодействия с ними, а месяца через три вылезаете из «норы» для первого клика и осознаете, что надо было сделать все по-другому, то большая часть вашего творения будет обречена навсегда «пылиться» в подвалах git'а… Истинный тестировщик знает, что чем раньше нашел ошибку, тем дешевле ее исправлять. Используйте этот подход во всем — написал тест за пару часов, попробовал, не понравилось — выкинул, извлек урок, поехал дальше.

Итак, входные данные:

  • Android приложение для торговли на бирже;
  • Java, чтобы работать в одном стэке с разработчиками;
  • базовый фреймворк UI Automator;
  • нужно написать тест логина в приложение.

Подготовка проекта


Так как я старался максимально интегрироваться в процесс разработчиков, то новый проект создавать не стал. Согласно документации, инструментальные тесты необходимо разместить в папке src/androidTest/java. В моем случае сборщик уже был настроен, если у вас не так, то почитайте про конфигурацию билда. Также нам нужна среда разработки, Android SDK, Emulator, tools, platform-tools и необходимые платформы для эмулятора. Если вы пользуетесь Android Studio, то все это можно быстро поставить через SDK Manager:



Слой тестов


Как писал выше, будем автоматизировать тест на логин в приложении. Помним, что код должен выглядеть так же, как обычный тест-кейс:

Precondition: запустить приложение.
Step 1: осуществить логин под учетной записью myLogin/myPassword.
Step 2: проверить имя текущего пользователя.
Expected Result: текущий пользователь «Иванов Иван».

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

Наш класс будет выглядеть так:

@RunWith(AndroidJUnit4.class)
public class LoginTests {

    private TestApplication myApp;

    @Before
    public void setUp() {
        myApp = new TestApplication();
    }

    @Test
    public void testLogin() {
        myApp.login("myLogin","myPassword");
        String currentUser = myApp.getCurrentUserName();
        assertEquals("Wrong name", "Иванов Иван", currentUser);
    }

    @After
    public void tearDown() {
        myApp.close();
    }
}

Бизнес-логика


В тесте используется класс TestApplication() и два его метода: login() и getCurrentUserName(). Плюс нужен конструктор класса (в нем запуск приложения) и метод close() Фронт работы ясен:

public class TestApplication  {

    private UiDevice device;
    private PageObject page;

    public TestApplication() {
    }

    public void login(String login, String password) {
    }

    public String getCurrentUserName() {
        return ""
    }

    public void close() {
    }

}

У экземпляра нашего класса будет две переменные:

  • device, принадлежащая классу android.support.test.uiautomator.UiDevice — через нее взаимодействуем с нашим устройством;
  • page, класс PageObject, который мы будем создавать в следующем разделе.

Начнем с конструктора. В нем создадим экземпляры наших переменных и запустим приложение:

public TestApplication() {
    // Connect to device
    device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
    device.pressHome();

    // Get launch intent
    String packageName = InstrumentationRegistry.getTargetContext()
            .getPackageName();
    Context context = InstrumentationRegistry.getContext();
    Intent intent = context.getPackageManager()
            .getLaunchIntentForPackage(packageName)
            .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);

    // Stat application
    context.startActivity(intent);

    // Get page objects
    page = new PageObject(device, packageName);
}

Пара советов про запуск приложения
Для бОльшего контроля тестируемого приложения можно добавить ожидания запуска лончера (если запускаете эмулятор «на холодную») и самого приложения. Так советуют и в документации.

Замечание: запуск приложения через android.content.Context.getTargetContext() подходит только в том случае, если ваши тесты в одном проекте с самим приложением. Если отдельно, то необходимо будет запускать через меню.

Бизнес-логика теста — это конкретные шаги, которые должен совершить пользователь для получения какого-либо значимого (для пользователя) результата. Осуществление входа под своей УЗ — значимый результат. Шаги: клик на кнопку «Login», ввести имя пользователя в поле «Login», ввести пароль в поле «Password», кликнуть на кнопку «Sign In». Таким образом, наш метод наполняется шагами:

public void login(String login, String password) {
    page.login().click();
    page.loginEntry().setText(login);
    page.passwordEntry().setText(password);
    page.signIn().click();
}

Для получения текущего пользователя все проще, просто получаем значение поля:

public String getCurrentUserName() {
    return page.currentUserName().getText();
}
}

А для закрытия приложения просто нажмем на кнопку Home:

public void close() {
    device.pressHome();
}

Описание элементов страницы


Концепция данного слоя — он должен возвращать готовые к использованию элементы (в нашем контексте это класс android.support.test.uiautomator.UiObject2). Т. е., потребитель не должен переживать на счет состояния объекта, если он вернулся, то с ним сразу же можно взаимодействовать: кликать, заполнять или считывать текст. Отсюда важное следствие — в этом слое будем реализовывать ожидания:

private UiObject2 getUiObject(BySelector selector) {
    return device.wait(Until.findObject(selector), 10000);
}

Метод, определенный выше, будет использоваться публичным интерфейсом нашего класса PageObject. Пример для поля «Login»:

public UiObject2 loginEntry() {
    return getUiObject(loginEntrySelector());
}

Осталось определить селектор, по которому будем находить поле. Экземпляр android.support.test.uiautomator.BySelector удобнее всего получить с помощью статических методов класса android.support.test.uiautomator.By. Я договорился с разработкой, что везде, по возможности, будут уникальные resource-id:

private BySelector loginEntrySelector() {
    return By.res(packageName, "login");
}

Инспекцию интерфейса удобно производить в утилите uiautomatorviewer, входящей в пакет tools (устанавливали в разделе Подготовка):



Выделяем нужный элемент и в разделе Node detail смотрим resource-id. Он состоит из названия пакета и, собственно, айдишника. Название пакета мы получили в конструкторе класса TestApplication, и использовали при создании объекта PageObject.

Код полностью:

класс PageObject
public class PageObject {

    private UiDevice device;
    private final String packageName;

    private BySelector loginButtonSelector() {
        return By.res(packageName, "user_view");
    }
    private BySelector loginEntrySelector() {
        return By.res(packageName, "login");
    }
    private BySelector passwordEntrySelector() {
        return By.res(packageName, "password");
    }
    private BySelector signInSelector() {
        return By.res(packageName, "btnLogin");
    }
    private BySelector userNameSelector() {
        return By.res(packageName, "user_name");
    }

    public PageObject(UiDevice device, String packageName) {
        this.device = device;
        this.packageName = packageName;
    }

    public UiObject2 login() {
        return getUiObject(loginButtonSelector());
    }
    public UiObject2 loginEntry() {
        return getUiObject(loginEntrySelector());
    }
    public UiObject2 passwordEntry() {
        return getUiObject(passwordEntrySelector());
    }
    public UiObject2 signIn() {
        return getUiObject(signInSelector());
    }
    public UiObject2 currentUserName() {
        return getUiObject(userNameSelector());
    }

    private UiObject2 getUiObject(BySelector selector) {
        return device.wait(Until.findObject(selector), 10000);
    }

}


Запуск тестов


На этом все, осталось запустить. Для начала подключаемся к устройству или стартуем эмулятор. Проверим подключение командой adb devices (утилита adb входит в состав platform-tools):

List of devices attached
emulator-5554   device

Если у вас gradle, то далее делаем

gradlew.bat connectedAndroidTest

и наслаждаемся как «вкалывают роботы, а не человек» (с).

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (14):

  1. Gennadii_M
    /#18864303

    + в карму за уровень бизнес-логики ) Этого мало кто делает, а пользы от этого уровня немеренно.
    А что будете делать когда в игру встепит и iOS?

    • qwez
      /#18864653

      Благодарю)
      На самом деле, хотел все реализовать на Appium, тогда тесты и часть бизнес-логики можно было бы переиспользовать для iOS. Но не получилось договорится со всеми заинтересованными сторонами… Решил пока попробовать так, это лучше, чем месяцами спорить про инструменты.
      А когда дойдем до iOS, будем дальше думать.

      • Gennadii_M
        /#18864861

        Интересно, как бы вы это делали (переиспользование для iOS). А как раз щас это делаю.
        Месяцами спорить — это, конечно, не выход. Но когда зайдёт iOS, то дальше думать будет просто долго. У меня первые 24 теста спортировались с андроида на яблоко меньше чем за пол дня с минимальными усилиями и работают одинаково. С UIAutomator-om так быстро не получится.

        • qwez
          /#18865001

          Уровень с тестами был бы одинаковый, различия только в бизнес-логике (возможно, частично) и page object. Создаем свой класс для каждой платформы — TestApplicationAndroid и TestApplicationIOs, если есть общие методы, то выделяем их в родительский класс. Тесты запускаем с параметром (название платформы) и добавляем логику в setUp() — какой класс использовать для переменной myApp.

          А как вы портировали тесты на iOS?

          • Gennadii_M
            /#18865059

            Я делаю через dependency injection. Уровень бизнес-логики (сервисы) получает готовый объект пейджи и от платформы зависит какая именно пейджа попадёт в конструктор сервиса. Таким образом уровень сервисов тоже не меняется, пока имплементация бизнес логики одинаковая для обоих платформ. + структура получается понятной и прозрачной. Выходит гибко и расширяемо.
            Есть ассемблер сервисов, который знает из каких частей собирать каждый конкретный сервис. Все классы с пейджами и сервисами собираются в один объект динамически и передаются в этот ассемблер. В случае с айосью я беру из объекта с классами дженерик классы и классы специфические для платформы и мёржу их. Таким образом, если есть разница в локаторах, к примеру, то нужно в папке ios создать класс, унаследоваться от дженерик класса и описать разницу. Всё. Дополнительно собирать или что-то куда-то передавать уже не надо.
            Если интересно, то здесь есть демо проект на TypeScript. Пока, правда без доков. В обозримом будущем планирую добавить.

  2. smartdev
    /#18864651

    «Данный подход хорош тем, что мы не делаем ничего лишнего — фреймворк построен на столько, на сколько это нужно для работы тестов. Можно сказать, что это концепция MVP в тестировании»
    Можно сказать, что это TDD)

    • Gennadii_M
      /#18864975

      Я бы сказал BDD. Имея уровень бизнес-логики мы не грарантируем, что будем писать тесты до кода приложения, но это даёт нам возможность абстрагироваться в тестах от имплементации и использовать более высокоуровневые методы.

      • lxsmkv
        /#18868463

        Вот не знаю я. Я переодически размышлял над тем, чтобы впилить поведенческий уровень. Имея такой уровень можно действительно описывать тесты более высокоуровнево.
        Но я вижу одну сложность.

        У нас получается вместо одного интерефейса — два. Интерфейс самого PgO и интерфейс бизнес компоненты которая инкапсулирует поведенческие сценарии. Мы получаем необходимость поддерживать больше кода. И когда в PgO интерфейс ясен — он является производной элементов управления страницы, то для поведенческого уровня необходимо по каким-то лишь создателю известным критериям придумывать, какие поведенческие цепочки собрать вместе. В итоге то, что этот тест на самом деле делает скрывается от читателя теста за абстрактыми вызовами сценарных методов. Что значит «залогиниться на сайте»? Что значит «удалить пользователя»?

        Все «как сделать» скрываются и подменяются на «что сделать».

        Мне много времени приходится проводить с воспроизведением упавшего теста вручную, чтобы определить есть ли действительно проблема в продукте. Так вот, я из теста считываю все шаги которые мне нужно сделать поэкранно и покнопочно.

        А теперь представьте, у меня в тесте было бы написано что-то вроде:
        «залогинься на странице»
        «добавь в корзину два предмета»
        «выйди из системы»
        «залогинься на странице»
        «проверь что корзина не пуста»

        Допустим баг в том что, корзина как пуста, после выхода ничего не сохранилось.

        Так вот я начинаю воспроизводить по этому сценарию и у меня все работает, я начинаю рвать на себе волосы, а потом смотрю в код «добавь в корзину два предмета» и оказывается, что проблема возникает только когда мы добавляем предметы определенной категории, как они были закодированы в бизнес-логике теста, а не с любыми предметами.

        Ну это фиктивная проблема максимально приближенная к реальности.
        Если бы тест был написан на голых PgO то из теста читалось бы напрямую, что было добавлено в корзину.

        Я часто пишу багрепорты, и нередко сталкиваюсь с тем что, несмотря на то, что написано все довольно однозначно, (за годы я выработал такой сухой бюрократический стиль чтобы ежу было ясно как воспроизвести) иногда случается, что разработчики просто делают не то что написано. Они читают то что написано, и делают не так как написано: тыкают туда куда совсем не надо. Забывают выполнить сетап указаный в начале и что угодно.

        Все дело в том, что натуральный язык как ни крути неоднозначен. А человек еще и работает в контексте своего ограниченного понимания и своего видения. Ну и невнимательность присущая всем в большей мере или меньшей.

        И вот в итоге мне трудно найти доводы в пользу слоя бизнес-логики на человекопонятном сценарном языке. Какая практическая польза от этого? Его приходится расшифровывать. А зачем когда можно все читать в оригинале, так сказать.

        Какие аргументы вы ложите на другую чашу весов, чтобы можно было оправдать введение такого слоя?

        • Gennadii_M
          /#18868989

          Все «как сделать» скрываются и подменяются на «что сделать».

          В этом и вся фишка.
          Я переодически размышлял

          Попробуйте разок сделать и посмотреть на сколько это удобно вам. Я вообще не представляю, как без этого ) На столько упрощает жизнь, чтоб я б даже не поверил, если б мне раньше сказали )
          А теперь представьте, у меня в тесте было бы написано что-то вроде:
          «залогинься на странице»

          А теперь представьте, что у вас каждый из 873-ёх тестов выполняет логин и логика логина поменялась. Что будете делать? В этом как раз вся изюминка, что вы меняете 1 метод логина и не запариваетесь. А тесты меняются только когда меняется high level бизнес логика. Я когда первый раз воспользовался таким подходом, то диву давался на сколько мало изменений нужно делать, чтоб всё снова заработало.
          А на счёт абстракций, то у меня 4 уровня — Page elements, page objects, page actions и services. Так вот это очень удобно, когда вся сложная логика реализована внизу, а сверху и мануальщики писать могут. Я, кстати, проводил эксперимент. Посадил мануальщика, который знает JS на уровне могу объявить переменную. Дал ему пустой тест и сказал — у тебя есть слово service, вперёд. Парень написал тест.
          Если бы тест был написан на голых PgO то из теста читалось бы напрямую

          1. Если перейти внутрь метода сервиса, то там английским по дракуле написано, чего он делает.
          2. Ещё удобней — логгируйте всё, что делаете. Я в тесте даже не знаю каким юзером я буду логиниться, а от этого многое зависит и всё от того, что я не хочу их хардкодить. У меня есть объект с описанием юзеров (что у кого есть) и юзер сервис, которому я в тесте говорю — выбери мне юзера блондинку с третим размером. А в логах я вижу какая блондинка мне попалась, какие у неё креды и все действия, которые она делала пошагово со всеми данными.

        • qwez
          /#18869441

          В говорите, что товары, которые нужно положить в корзину, прописаны хардкодом в методе? Это по определению плохо. Вместо «добавь в корзину два предмета» нужно сделать «добавь в корзину предмет»(«название предмета»). И в тесте явно указывать название: myApp.addProductToCart("Яндекс.Станция");

          • Gennadii_M
            /#18869603

            С первой половиной полностью согласен, со второй не полностью )
            Тесты не должны знать про тест дату. К примеру нужно добавить товар дороже, чем 1000. 1000 для нас — сумма при которой доставка бесплатно.
            Плохо:
            addExpensiveProduct() — внутри метода зашит продукт
            Лучше:
            addProduct(teslaModelS) — дженерик метод не знает про данные, данные передаются в тесте
            Хорошо:
            addProduct(helper.findProduct.costsMore(testData.freeDeliveryCost)) — ни тесты, ни метод не знает про дату. Если сумма для бесплатной доставки меняется — она меняется в одном месте.

            • lxsmkv
              /#18871929 / +1

              Если не секрет, какое у Вас приблизительно количество PageObject классов? И наверное мне еще интересно сколько у вас пользовательских сценариев?
              Просто у нас нетипичное приложение которое пишется на заказ (т.е требования составляем не мы), это не веб, не десктоп и не мобайл. Мне не с чем сравнивать, чтобы понять как у нас обстоят дела. У меня их около 80 штук. Из них составлено около 170 тестовых последовательностей. Сценариев у нас нет. Вот и вопрос, стоит заморачиваться и определять сценарии и писать обертки? Исходя из того, что эта работа — разработка сценариев и написание оберток — это время которое не будет потрачено на написание новых проверок, анализ найденных ошибок в приложении, заведение багрепортов и пр.

              • Gennadii_M
                /#18872781

                Я меньше месяца работаю над этим проектом. У меня Android и iOS, generic пейджей 14, platform specific по 2, тестов 25.

                у нас нетипичное приложение которое пишется на заказ (т.е требования составляем не мы)

                А бывает по-другому ;) Я 1 раз только работал на продуктовом проекте. Всё остальное — аутсорс, аутстаф, где нетипичное приложение и требования не у нас.
                Тут суть не в обёртках, а в том, чтоб было гибко и расширяемо. Как именно это делать — дело каждого. Если ваше решение отвечает SOLIDам и вы спите спокойно по ночам — то это то, ради чего стоит ходить на работу )