Пожалуй, лучшая архитектура для UI тестов

  • Tutorial

Наверное, где-то есть идеальная статья, сразу и полностью раскрывающая тему архитектуры тестов, легких и в написании, и в чтении, и в поддержке, и так, чтобы быть понятной начинающим, с примерами реализации и областей применения. Хочу предложить свое видение этой «идеальной статьи», в том формате, о котором мечтала, только получив первую задачу «напиши автотесты». Для этого расскажу о известных и не очень подходах к автотестам веба, зачем, как и когда их применять, а также про удачные решения хранения и создания данных.

Привет, Хабр! Меня зовут Диана, я руководитель группы тестирования пользовательских интерфейсов, автоматизирую веб и десктоп тесты уже пять лет. Примеры кода будут на java и для web, но, на практике проверено, подходы применимы и к питону с десктопом.

В начале было...


Вначале было слово, и слов было много, и заполняли они все страницы равномерно кодом, не обращая внимания на эти ваши архитектуры и принципы DRY (don’t repeat yourself — не надо повторять код, который вы уже написали три абзаца выше).

Простыня


На самом деле, архитектура «портянки», она же «простыня», она же сваленный в кучу неструктурированный код, равномерно заполняющий экран, не так уж плоха и вполне применима в следующих ситуациях:

  • Быстроклик в три строчки (ну ладно, в двести три) для очень маленьких проектов;
  • Для примеров кода в мини-демо;
  • Для первого кода в стиле «хелоу ворд» среди автотестов.

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

import com.codeborne.selenide.Condition;
import com.codeborne.selenide.WebDriverRunner;
import org.testng.annotations.Test;

import static com.codeborne.selenide.Selenide.*;

public class RandomSheetTests {
    @Test
    void addUser() {
        open("https://ui-app-for-autotest.herokuapp.com/");
        $("#loginEmail").sendKeys("test@protei.ru");
        $("#loginPassword").sendKeys("test");
        $("#authButton").click();
        $("#menuMain").shouldBe(Condition.appear);

        $("#menuUsersOpener").hover();
        $("#menuUserAdd").click();

        $("#dataEmail").sendKeys("mail@mail.ru");
        $("#dataPassword").sendKeys("testPassword");
        $("#dataName").sendKeys("testUser");
        $("#dataGender").selectOptionContainingText("Женский");
        $("#dataSelect12").click();
        $("#dataSelect21").click();
        $("#dataSelect22").click();
        $("#dataSend").click();

        $(".uk-modal-body").shouldHave(Condition.text("Данные добавлены."));

        WebDriverRunner.closeWebDriver();
    }
}

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

PageObject


Слышали слухи, что PageObject устарел? Вы просто не умеете его готовить!

Основной рабочей единицей в этом паттерне является «страница», то есть законченный набор элементов и действий с ними, например, MenuPage — класс, описывающий все действия с меню, то есть клики по вкладкам, раскрытие выпадающих подпунктов и так далее.



Чуть сложнее составить PageObject для модального окошка (для краткости «модалки») создания объекта. Набор полей класса понятен: все поля ввода, чекбоксы, выпадающие списки; а для методов есть два варианта действий: можно сделать как универсальные методы «заполни все поля модалки», «заполни все поля модалки рандомными значениями», «проверь все поля модалки», так и отдельные методы «заполни название», «проверь название», «заполни описание» и так далее. Что использовать в конкретном случае определяется приоритетами — подход «один метод на всю модалку» увеличивает скорость написания теста, но по сравнению с подходом «по одному методу на каждое поле» сильно проигрывает в читаемости теста.

Пример
Составим общий Page Object создания пользователей для обоих видов тестов:
public class UsersPage {

    @FindBy(how = How.ID, using = "dataEmail")
    private SelenideElement email;
    @FindBy(how = How.ID, using = "dataPassword")
    private SelenideElement password;
    @FindBy(how = How.ID, using = "dataName")
    private SelenideElement name;
    @FindBy(how = How.ID, using = "dataGender")
    private SelenideElement gender;
    @FindBy(how = How.ID, using = "dataSelect11")
    private SelenideElement var11;
    @FindBy(how = How.ID, using = "dataSelect12")
    private SelenideElement var12;
    @FindBy(how = How.ID, using = "dataSelect21")
    private SelenideElement var21;
    @FindBy(how = How.ID, using = "dataSelect22")
    private SelenideElement var22;
    @FindBy(how = How.ID, using = "dataSelect23")
    private SelenideElement var23;
    @FindBy(how = How.ID, using = "dataSend")
    private SelenideElement save;

    @Step("Complex add user")
    public UsersPage complexAddUser(String userMail, String userPassword, String userName, String userGender, 
                                    boolean v11, boolean v12, boolean v21, boolean v22, boolean v23) {
        email.sendKeys(userMail);
        password.sendKeys(userPassword);
        name.sendKeys(userName);
        gender.selectOption(userGender);
        set(var11, v11);
        set(var12, v12);
        set(var21, v21);
        set(var22, v22);
        set(var23, v23);
        save.click();
        return this;
    }

    @Step("Fill user Email")
    public UsersPage sendKeysEmail(String text) {...}

    @Step("Fill user Password")
    public UsersPage sendKeysPassword(String text) {...}

    @Step("Fill user Name")
    public UsersPage sendKeysName(String text) {...}

    @Step("Select user Gender")
    public UsersPage selectGender(String text) {...}

    @Step("Select user variant 1.1")
    public UsersPage selectVar11(boolean flag) {...}

    @Step("Select user variant 1.2")
    public UsersPage selectVar12(boolean flag) {...}

    @Step("Select user variant 2.1")
    public UsersPage selectVar21(boolean flag) {...}

    @Step("Select user variant 2.2")
    public UsersPage selectVar22(boolean flag) {...}

    @Step("Select user variant 2.3")
    public UsersPage selectVar23(boolean flag) {...}

    @Step("Click save")
    public UsersPage clickSave() {...}

    private void set(SelenideElement checkbox, boolean flag) {
        if (flag) {
            if (!checkbox.isSelected()) checkbox.click();
        } else {
            if (checkbox.isSelected()) checkbox.click();
        }
    }
}

А в классе тестов пользователей распишем тест с комплексными действиями:

    @Test
    void addUser() {
        baseRouter.authPage()
                .complexLogin("test@protei.ru", "test")
                .complexOpenAddUser()
                .complexAddUser("mail@test.ru", "pswrd", "TESTNAME", "Женский", true, false, true, true, true)
                .checkAndCloseSuccessfulAlert();
    }

И с подробными действиями:

    @Test
    void addUserWithoutComplex() {
        //Arrange
        baseRouter.authPage()
                .complexLogin("test@protei.ru", "test");
        //Act
        baseRouter.mainPage()
                .hoverUsersOpener()
                .clickAddUserMenu();
        baseRouter.usersPage()
                .sendKeysEmail("mail@test.ru")
                .sendKeysPassword("pswrd")
                .sendKeysName("TESTNAME")
                .selectGender("Женский")
                .selectVar11(true)
                .selectVar12(false)
                .selectVar21(true)
                .selectVar22(true)
                .selectVar23(true)
                .clickSave();
        //Assert
        baseRouter.usersPage()
                .checkTextSavePopup("Данные добавлены.")
                .closeSavePopup();
    }

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

Суть в том, что внутри пейджей инкапсулируются (реализация скрыта, доступны только логические действия) все действия со страницами, таким образом, в тесте используются уже бизнес-функции. А это в свою очередь позволяет писать свои пейджи под каждую платформу (веб, десктоп, мобилки), не меняя при этом тесты.

Жаль только, что абсолютно одинаковые интерфейсы на разных платформах встречаются редко.

Для уменьшения расхождения между интерфейсами появляется соблазн усложнения отдельных шагов, их выносят в отдельные промежуточные классы, а тесты становятся все менее читаемыми, вплоть до двух шагов: «авторизоваться, сделать хорошо», тест закончен. Кроме веба в наших проектах дополнительных интерфейсов не было, а читать кейсы приходится чаще, чем писать, поэтому в угоду читаемости исторические PageObject приобрели новый вид.

PageObject — классика, о которой знают все. По этому подходу можно найти множество статей с примерами почти на любом языке программирования. По использованию PageObject очень часто судят, знает ли кандидат что-то о тестировании пользовательских интерфейсов. Выполнение тестового задания с помощью этого подхода — это то, чего ждут большинство работодателей, и его очень много живет в продакшн-проектах, даже если тестят только веб.

А что еще бывает?


Как ни странно, не PageObject’ом единым!

  • Часто встречается паттерн ScreenPlay, о котором можно почитать например тут. У нас он не прижился, так как использовать bdd-подходы без вовлечения людей, не умеющих читать код — бессмысленное насилие над автоматизаторами.
  • У js-фреймворков появляются свои собственные упрощающие жизнь подходы, помимо обязательного PageObject, но при их разнообразии говорить о чем-то устоявшемся и универсальном, мне кажется, слишком смело.
  • Можно написать и что-то свое, например, фреймворк на основе ModelBaseTesting, о чем хорошо рассказали в докладе с гейзенбага докладе с гейзенбага. Этот подход используется в первую очередь в проектах со сложносвязанными объектами, когда обычных тестов не хватает для проверки всех возможных комбинаций состояний и взаимодействий объектов.

А я вам расскажу подробнее про Page Element, позволяющий уменьшить количество однотипного кода, повысив при этом читаемость и обеспечив быстрое понимание тестов даже у тех, кто не знаком с проектом. А еще на нем (со своими блекджеками и преферансами, конечно!) построены популярные не-js фреймворки htmlElements, Atlas и епамовский JDI.

Что такое Page Element?


Для построения паттерна Page Element начнем с самого низкоуровневого элемента. Как говорит Викисловарь, «виджет» — программный примитив графического интерфейса пользователя, имеющий стандартный внешний вид и выполняющий стандартные действия. Например, самый простой виджет «Кнопка» — на него можно кликнуть, у него можно проверить текст и цвет. В «Поле ввода» можно ввести текст, проверить, какой текст введен, кликнуть, проверить отображение фокуса, проверить количество введенных символов, ввести текст и нажать «Enter», проверить placeholder, проверить подсветку «обязательности» поля и текст ошибки, и всё, что еще может понадобиться в конкретном случае. При этом все действия с этим полем стандартны на любой странице.



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

  • Клик по элементу оглавления с заданным текстом,
  • Проверка существования элемента с заданным текстом,
  • Проверка отступа элемента с заданным текстом.

Виджеты могут быть двух видов: с локатором в конструкторе и с локатором, вшитым внутрь виджета, без возможности его изменить. Оглавление обычно одно на странице, у него способ поиска на странице можно оставить «внутри» действий с оглавлением, выносить его локатор отдельно нет смысла, так как извне локатор можно случайно испортить, а выгоды от его отдельного хранения нет. В свою очередь текстовое поле — вещь универсальная, с ним наоборот нужно работать только через локатор из конструктора, потому что полей ввода может быть много и сразу. Если появляется хотя бы один метод, предназначенный только для одного специального поля ввода, например с дополнительнм кликом по выпадающей подсказке, это уже не просто поле ввода, под него уже пора делать свой виджет.

Для уменьшения всеобщего хаоса виджеты, как элементы страницы, объединяются во все те же пейджи, откуда, видимо, и составлено название Page Element.

public class UsersPage {

    public Table usersTable = new Table();

    public InputLine email = new InputLine(By.id("dataEmail"));
    public InputLine password = new InputLine(By.id("dataPassword"));
    public InputLine name = new InputLine(By.id("dataName"));
    public DropdownList gender = new DropdownList(By.id("dataGender"));
    public Checkbox var11 = new Checkbox(By.id("dataSelect11"));
    public Checkbox var12 = new Checkbox(By.id("dataSelect12"));
    public Checkbox var21 = new Checkbox(By.id("dataSelect21"));
    public Checkbox var22 = new Checkbox(By.id("dataSelect22"));
    public Checkbox var23 = new Checkbox(By.id("dataSelect23"));
    public Button save = new Button(By.id("dataSend"));

    public ErrorPopup errorPopup = new ErrorPopup();
    public ModalPopup savePopup = new ModalPopup();
}

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

    @Test
    public void authAsAdmin() {
        baseRouter
                .authPage().email.fill("test@protei.ru")
                .authPage().password.fill("test")
                .authPage().enter.click()
                .mainPage().logoutButton.shouldExist();
    }

Можно добавить классический слой степов, если есть необходимость для этого в вашем фреймворке (реализация remote библиотеки на Java для RobotFramework требует на вход класс степов, например), или если хочется добавить аннотаций для красивых отчетов. Мы это сделали генератором на основе аннотаций, если интересно, пишите в комментах, расскажем.

Пример класса степов авторизации
public class AuthSteps{

    private BaseRouter baseRouter = new BaseRouter();

    @Step("Sigh in as {mail}")
    public BaseSteps login(String mail, String password) {
        baseRouter
                .authPage().email.fill(mail)
                .authPage().password.fill(password)
                .authPage().enter.click()
                .mainPage().logoutButton.shouldExist();
        return this;
    }
    @Step("Fill E-mail")
    public AuthSteps fillEmail(String email) {
        baseRouter.authPage().email.fill(email);
        return this;
    }
    @Step("Fill password")
    public AuthSteps fillPassword(String password) {
        baseRouter.authPage().password.fill(password);
        return this;
    }
    @Step("Click enter")
    public AuthSteps clickEnter() {
        baseRouter.authPage().enter.click();
        return this;
    }
    @Step("Enter should exist")
    public AuthSteps shouldExistEnter() {
        baseRouter.authPage().enter.shouldExist();
        return this;
    }
    @Step("Logout")
    public AuthSteps logout() {
        baseRouter.mainPage().logoutButton.click()
                .authPage().enter.shouldExist();
        return this;
    }
}
public class BaseRouter {
// Класс для создания страниц, чтобы не дублировать этот код везде, где понадобится обращение к странице
    public AuthPage authPage() {return page(AuthPage.class);}
    public MainPage mainPage() {return page(MainPage.class);}
    public UsersPage usersPage() {return page(UsersPage.class);}
    public VariantsPage variantsPage() {return page(VariantsPage.class);}
}


Эти степы очень похожи на степы внутри пейджей, практически ничем не отличаются. Но выделение их в отдельные классы открывает простор для генерации кода, при этом, жесткая связь с соответствующей страницей не теряется. При этом, если в пейдже не прописывать степы, то смысл инкапсуляции исчезает, а если не добавлять класс степов в pageElement, то взаимодействие со страницей всё равно остается отделенным от бизнес-логики.

Таким образом, для составления теста нужно описать простые действия с каждым элементом страницы, причем некоторые элементы вроде полей ввода универсальны и могут быть использованы в нескольких проектах практически без изменений. После этого для каждой страницы указывается набор полей с локаторами. И все, можно писать сам тест, который гарантированно окажется читаемым и воспроизводимым, ведь он состоит из прямых указаний «нажми тут, введи текст там». Проверили на десятке проектов — этот подход самый читаемый, причем не надо дублировать в каждом page object однотипный код для каждого поля, ведь все нужные действия указаны один раз в виджетах!

Хранение данных


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

Самый простой способ — передавать данные прямо в тесте «как есть» или переменными. Этот способ подходит для архитектуры простыни, но в больших проектах начинается бардак.

Другой способ — хранение данных в качестве объектов, именно он для нас оказался лучшим, так как собирает в одно место все данные, относящиеся к одной сущности, убирая соблазн все перемешать и использовать что-то не то не там. Кроме того, у этого способа есть множество дополнительных улучшений, которые могут быть полезны на отдельных проектах.

Для каждой сущности создается описывающая ее модель, которая в самом простом случае содержит названия и типы полей, например вот модель пользователя:

public class User {
    private Integer id;
    private String mail;
    private String name;
    private String password;
    private Gender gender;

    private boolean check11;
    private boolean check12;
    private boolean check21;
    private boolean check22;
    private boolean check23;

    public enum Gender {
        MALE,
        FEMALE;

        public String getVisibleText() {
            switch (this) {
                case MALE:
                    return "Мужской";
                case FEMALE:
                    return "Женский";
            }
            return "";
        }
    }
}

Лайфхак №1: если у вас rest-подобная архитектура клиент-серверного взаимодействия (между клиентом и сервером ходят json или xml объекты, а не кусочки нечитаемого кода), то можно загуглить json to <ваш язык> object, вероятно, нужный генератор уже есть.

Лайфхак №2: если ваши разработчики сервера пишут на том же объектно-ориентированном языке программирования, то можно использовать их модели.

Лайфхак №3: если вы джавист и компания позволяет использовать сторонние библиотеки, а вокруг нет нервных коллег, предсказывающих много боли еретикам, использующим дополнительные библиотеки вместо чистой и прекрасной Java, берите ломбок! Да, обычно IDE может сгенерировать геттеры, сеттеры, toString и билдеры. Но при сравнении наших ломбоковских моделек и разрабских без ломбока виден профит в сотни строк «пустого», не несущего бизнес-логики кода на каждый класс. При использовании ломбока не надо бить по рукам тех, кто перемешивает поля и геттеры сеттеры, класс читается легче, можно получить представление об объекте сразу, без пролистывания трех экранов.

Таким образом, у нас появляются каркасы объектов, на которые нужно натянуть тестовые данные. Данные можно хранить как final static переменные, например это может быть полезно для главного админа системы, из под которого создаются другие пользователи. Использовать лучше именно final, чтобы не было соблазна данные изменять в тестах, потому что тогда следующий тест вместо админа может получить «бесправного» пользователя, не говоря уже про параллельный запуск тестов.

public class Users {
    public static final User admin = User.builder().mail("test@protei.ru").password("test").build();
}

Для получения данных, не влияющих на другие тесты, можно использовать паттерн «прототип» и клонировать в каждом тесте свой инстанс. Мы решили сделать проще: написать метод-рандомилку полей класса, примерно вот так:

    public static User getUserRandomData() {
        User user = User.builder()
                .mail(getRandomEmail())
                .password(getShortLatinStr())
                .name(getShortLatinStr())
                .gender(getRandomFromEnum(User.Gender.class))
                .check11(getRandomBool())
                .check21(getRandomBool())
                .check22(getRandomBool())
                .check23(getRandomBool())
                .build();
//business-logic: 11 xor 12 must be selected
        if (!user.isCheck11()) user.setCheck12(true); 
        if (user.isCheck11()) user.setCheck12(false);
        return user;
    }

При этом методы, творящие непосредственный рандом, лучше вынести в отдельный класс, так как они будут использоваться и в других моделях:



В методе получения рандомного пользователя использовался паттерн «строитель», который нужен для того, чтобы не создавать новый вид конструктора под каждый необходимый набор полей. Вместо него, конечно, можно просто вызывать нужный конструктор.

Этот способ хранения данных использует паттерн Value Object, на его основе можно добавить любых своих хотелок, в зависимости от надобностей проекта. Можно добавить сохранение объектов в базу, и таким образом подготовить систему перед тестом. Можно не рандомить пользователей, а загружать их из файлов properties (и еще одна классная библиотека ). Можно использовать везде одного и того же пользователя, но сделать так называемые Реестры данных (data registry) под каждый вид объектов, в котором к имени или другому уникальному полю объекта будет добавляться значение сквозного счетчика, и в тесте всегда будет свой уникальный testUser_135.

Можно написать свое Хранилище объектов (гуглить object pool и flyweight), из которого запрашивать необходимые сущности в начале теста. Хранилище отдает один из своих уже готовых к работе объектов и отмечает его у себя занятым. В конце теста объект возвращается в хранилище, где его по необходимости чистят, отмечают свободным и отдают следующему тесту. Так делают, если операции создания объектов очень ресурсоемкие, а при таком подходе хранилище работает независимо от тестов и может заниматься подготовкой данных под следующие кейсы.

Создание данных


Для кейсов редактирования пользователя вам определенно понадобится созданный пользователь, которого вы будете редактировать, при этом, в общем-то, тесту редактирования не важно, откуда этот пользователь взялся. Есть несколько способов его создать:

  • нажимать кнопки руками перед тестом,
  • оставить данные от предыдущего теста,
  • развернуть перед тестом из backup,
  • создать кликами по кнопкам прямо в тесте,
  • использовать API.

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

Использование результатов предыдущего теста нарушает принцип атомарности и не дает запускать тест отдельно, придется запускать всю пачку, а ui-тесты не такие уж и быстрые. Хорошим тоном считается писать тесты так, что каждый можно запустить в гордом одиночестве и без дополнительных танцев. Кроме того, бага в создании объекта, уронившая предыдущий тест, вовсе не гарантирует багу в редактировании, а в такой конструкции тест на редактирование упадет следом, и работает ли именно редактирование — не узнать.

Использовать backup (сохраненный образ базы данных) с нужными для теста данными уже более менее хороший подход, особенно если backup разворачиваются автоматически или если данные в базу кладут сами тесты. Однако почему в тесте используется конкретно этот объект не очевидно, также могут начатся проблемы пересечения данных при большом количестве тестов. Иногда backup перестают корректно работать из-за обновления архитектуры базы, например, если нужно запустить тесты на старой версии, а в backup уже есть новые поля. С этим можно бороться, организовав хранилище backup под каждую версию приложения. Иногда backup перестают быть валидными опять же из-за обновления архитектуры базы — регулярно появляются новые поля, поэтому backup нужно регулярно актуализировать. А еще внезапно может оказаться, что конкретно вот такой единственный пользователь-из-backup никогда не падает, а если бы пользователь был создан только что или имя ему задавали немножко рандомно, то нашли бы баг. Это называется «эффект пестицида», тест перестает ловить баги, потому что приложение «привыкло» к одинаковым данным и не падает, а отклонений в сторону нет.

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

Наконец, еще один способ создания пользователя — через http-API из теста, то есть вместо кликов по кнопкам сразу отправить запрос на создание нужного пользователя. Таким образом уменьшен, насколько возможно, пестицид, очевидно, откуда взялся пользователь, а скорость создания сильно выше, чем при кликах по кнопкам. Минусы этого способа в том, что он не подходит для проектов без json или xml в протоколе обмена данными между клиентом и сервером (например если разработчики пишут используя gwt и не хотят писать дополнительный api для тестировщиков). Можно при использовании API потерять кусок логики, выполняемой админкой, и создать не валидную сущность. API может меняться, отчего тесты упадут, однако обычно об этом известно, да и изменения ради изменений никому не нужны, скорее всего это новая логика, которую все равно придется проверять. Также возможно, что и на уровне API будет бага, но от этого ни один способ кроме готовых backup не застрахован, поэтому подходы к созданию данных лучше комбинировать.

Добавим капельку API


Среди способов подготовки данных нам больше всего подошли http-API для текущих нужд отдельного теста и разворачивание backup для дополнительных тестовых данных, которые в тестах не меняются, например, иконок для объектов, чтобы тесты этих объектов не падали при багах в загрузке иконок.

Для создания объектов через API в Java оказалось удобнее всего использовать библиотеку restAssured, хоть она предназначена не совсем для этого. Хочу поделиться парой найденных фишек, знаете еще — пишите!

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

public class ApiSettings {
    private static String loginEndpoint="/login";

    public static RequestSpecification testApi() {
        RequestSpecBuilder tmp = new RequestSpecBuilder()
                .setBaseUri(testConfig.getSiteUrl())
                .setContentType(ContentType.JSON)
                .setAccept(ContentType.JSON)
                .addFilter(new BeautifulRest())
                .log(LogDetail.ALL);
        Map<String, String> cookies = RestAssured.given().spec(tmp.build())
                .body(admin)
                .post(loginEndpoint).then().statusCode(200).extract().cookies();
        return tmp.addCookies(cookies).build();
    }
}

Можно добавить возможность сохранения Cookies конкретного пользователя, тогда количество запросов к серверу уменьшится. Второе возможное расширение этого метода — сохранять полученные Cookies для текущего теста, и подкидывать их драйверу браузера, пропуская шаг авторизации. Выигрыш — секунды, но если их умножить на количество тестов, можно неплохо ускориться!

Есть плюшечка для аллюра и красивых отчетов, обратите внимание на строку .addFilter(new BeautifulRest()):

Класс BeautifulRest

public class BeautifulRest extends AllureRestAssured {
        public BeautifulRest() {}

        public Response filter(FilterableRequestSpecification requestSpec, FilterableResponseSpecification responseSpec, FilterContext filterContext) {
            AllureLifecycle lifecycle = Allure.getLifecycle();
            lifecycle.startStep(UUID.randomUUID().toString(), (new StepResult()).setStatus(Status.PASSED).setName(String.format("%s: %s", requestSpec.getMethod(), requestSpec.getURI())));
            Response response;
            try {
                response = super.filter(requestSpec, responseSpec, filterContext);
            } finally {
                lifecycle.stopStep();
            }
            return response;
        }
}


Модели объектов отлично ложатся на restAssured, так как библиотека сама справляется с сериализацией и десериализаций моделей в json/xml (превращением из json/xml форматов в объект заданного класса).

    @Step("create user")
    public static User createUser(User user) {
        String usersEndpoint = "/user";
        return RestAssured.given().spec(ApiSettings.testApi())
                .when()
                .body(user)
                .post(usersEndpoint)
                .then().log().all()
                .statusCode(200)
                .body("state",containsString("OK"))
                .extract().as(User.class);
    }

Если рассмотреть подряд несколько степов создания объектов, можно заметить идентичность кода. Для уменьшения одинакового кода можно написать общий метод создания объектов.

    public static Object create(String endpoint, Object model) {
        return RestAssured.given().spec(ApiSettings.testApi())
                .when()
                .body(model)
                .post(endpoint)
                .then().log().all()
                .statusCode(200)
                .body("state",containsString("OK"))
                .extract().as(model.getClass());
    }

    @Step("create user")
    public static User createUser(User user) {
                  create(User.endpoint, user);
    }

Еще раз про рутинные операции


В рамках проверки редактирования объекта нам в целом не важно, как объект появился в системе — через api или из backup, или все-таки создан ui-тестом. Важные действия — найти объект, нажать у него иконку «редактировать», очистить поля и заполнить их новыми значениями, нажать «сохранить» и проверить, все ли новые значения правильно сохранились. Всю ненужную информацию, не относящуюся непосредственно к тесту, лучше убирать в отдельные методы, например, в класс степов.

    @Test
    void checkUserVars() {        
//Arrange
        User userForTest = getUserRandomData();
       
 // Проверка корректности сохранения полей уже есть в другом тесте, 
 // этот тест проверяет отображение вариантов из-под залогинившегося юзера, 
 // поэтому не важно, как юзер создан
        usersSteps.createUser(userForTest);
        authSteps.login(userForTest);
       
 //Act
        mainMenuSteps
                .clickVariantsMenu();
       
 //Assert
        variantsSteps
                .checkAllVariantsArePresent(userForTest.getVars())
                .checkVariantsCount(userForTest.getVarsCount());
        
//Cleanup
        usersSteps.deleteUser(userForTest);
    }

Важно не увлекаться, так как тест, состоящий только из «комплексных» действий, становится менее читаемым, его сложнее воспроизвести без копания по коду.

    @Test
    void authAsAdmin() {
        authSteps.login(Users.admin);
// Это всё, просто авторизовались под админом. Все действия и проверки внутри. 
// Не очень очевидно, не правда ли? 

Если в сьюте появляются практически одинаковые тесты, у которых отличается только подготовка данных, (например, надо проверить, что все три разновидности «разноправных» пользователей могут совершить одни и те же действия, или есть разные виды управляющих объектов, для каждого из которых надо проверить создание одинаковых зависимых объектов, или нужно проверить фильтрацию по десяти видам статусов объектов), то все равно нельзя повторяющиеся части выносить в отдельный метод. Совсем нельзя, если читаемость вам важна!

Вместо этого нужно почитать про data-driven тесты, для Java+TestNG это будет примерно так:

    @Test(dataProvider = "usersWithDifferentVars")
    void checkUserDifferentVars(User userForTest) {
        //Arrange
        usersSteps.createUser(userForTest);
        authSteps.login(userForTest);
        //Act
        mainMenuSteps
                .clickVariantsMenu();
        //Assert
        variantsSteps
                .checkAllVariantsArePresent(userForTest.getVars())
                .checkVariantsCount(userForTest.getVarsCount());
    }

 // Метод возвращает пользователей с полным перебором трех булевых параметров. 
 // Предположим, это важное бизнес-требование.
    @DataSupplier(name = "usersWithDifferentVars")
    public Stream<User> usersWithDifferentVars(){
        return Stream.of(
            getUserRandomData().setCheck21(false).setCheck22(false).setCheck23(false),
            getUserRandomData().setCheck21(true).setCheck22(false).setCheck23(false),
            getUserRandomData().setCheck21(false).setCheck22(true).setCheck23(false),
            getUserRandomData().setCheck21(false).setCheck22(false).setCheck23(true),
            getUserRandomData().setCheck21(true).setCheck22(true).setCheck23(false),
            getUserRandomData().setCheck21(true).setCheck22(false).setCheck23(true),
            getUserRandomData().setCheck21(false).setCheck22(true).setCheck23(true),
            getUserRandomData().setCheck21(true).setCheck22(true).setCheck23(true)
        );
    }

Тут используется библиотека Data Supplier, которая является надстройкой над TestNG Data Provider, позволяющей использовать типизированные коллекции вместо Object [] [], но суть та же. Таким образом мы получаем один тест, выполняемый столько раз, сколько входных данных он получает.

Выводы


Итак, для создания большого, но удобного проекта автотестов пользовательских интерфейсов нужно:

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

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

Примеры кода из статьи в виде готового проекта добавлены в гит.
НТЦ ПРОТЕЙ
Компания

Комментарии 4

    0
    из всей статьи наиболее интересно было бы узнать про

    > но, на практике проверено, подходы применимы и к питону с десктопом

    как там с десктопом?! по своей практике сужу, там всё плохо!

    Ну, предположим, можно как-то извернуться и через какой-то a11y-фреймворк получить-таки программный доступ к деталям пользовательского интерфейса. Но ведь это надо еще так уметь писать, чтобы приложение получилось с поддержкой a11y!..

    Другой вариант: sikuli и прочие движки на распознавании образов — тоже боль!

    Ладно, есть же куте и, например, squish… Но, простите, сколько-сколько камазов денег привезти? Спасибо, мы вам перезвоним…

    Где же тот грааль, о котором вскользь упоминает автор?! ;)
      0
      На один проект таки дали камаз денег, там на уровне squish описаны виджеты, генератор степов питонячий, а тесты вообще на роботе, но архитектура остается та же)

      Еще один проект более менее жил с winium и ретраями, но очень уж он, зараза такая, нестабильный, сам по себе падал. Переписали на appium WindowsDriver, тесты бегают стабильнее и быстрее.
      Локаторы ищем через ui-spy, так что, видимо, наши wpf разрабы могут в a11y)
        0
        понятно… вы больше на винду заглядываетесь, похоже )

        у нас основной тренд — qt + qml под линуксом, в т.ч. на эмбеде…
      0
      Гибкий и масштабируемый подход, особенно совместно с кодогенерацией степов. Сильно ускоряет разработку тестов и повышает читабельность.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое