Разделы портала

Онлайн-тренинги

.
Распространенные паттерны и методологии UI-автоматизации: реальные примеры
31.01.2022 00:00

Автор: Бенджамин Бишофф (Benjamin Bischoff)
Оригинал статьи
Перевод: Ольга Алифанова

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

В этой статье мы рассмотрим ряд распространенных паттернов и методологий проектирования ПО, полезных для UI-автоматизации в целом и для создания тест-фреймворка для UI в частности. Примеры и сценарии использования в статье относятся к нашему внутреннему кастомному фреймворку.

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

Полезные паттерны для тест-автоматизации UI

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

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

Паттерн "Декоратор"

Внутренний тест-фреймворк моей компании должен поддерживать различные типы компонентов сайта. Это необходимо, так как наше веб-приложение постоянно меняется, и А/Б тесты идут на уровне компонентов.

Если у вас схожие требования, то вам может пригодиться паттерн "декоратор". С его помощью можно упаковать компоненты в "конверты", которые переписывают или поддерживают лишь определенные функции. Вам не нужно писать новый класс для каждой новой характеристики компонента – внедрять нужно только изменения. Эту технику также можно использовать, если веб-компоненты меняются в зависимости от размера браузера или типа устройства.

Пример работы декоратора

В этом базовом примере у нас два компонента логина – у второго есть дополнительная кнопка "Отмена".


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

Посмотрим на это с точки зрения декоратора!

LoginComponent – это интерфейс для каждого компонента логина. Он гласит, что каждый компонент должен иметь определенный метод логина.

package decorator;
 
public interface LoginComponent {
void login(String user, String password);
}

BasicLoginComponent содержит конкретное внедрение метода логина. В этом примере он просто выводит Basic login в командную строку.

package decorator;

public class BasicLoginComponent implements LoginComponent {

  @Override
public void login(String user, String password) {
System.out.println("Basic login: " + user + ", " + password);
}
}

Этот класс – сердце паттерна. LoginDecorator принимает любой LoginComponent и оборачивает его функциональностью. После этого результат остается в LoginComponent.

package decorator;
public abstract class LoginDecorator implements LoginComponent {
private final LoginComponent loginComponent;
public LoginDecorator(LoginComponent loginComponent) {
this.loginComponent = loginComponent;
}
@Override
public void login(String user, String password) {
loginComponent.login(user, password);
}
}

MobileLoginDecorator переписывает базовую функциональность логина новым классом, специфичным для мобильных устройств. Он тоже просто выводит "Mobile login" в командную строку – это сделано, чтобы сократить пример.

package decorator;
public class MobileLoginDecorator extends LoginDecorator {
public MobileLoginDecorator(LoginComponent loginComponent) {
super(loginComponent);
}
@Override
public void login(String user, String password) {
System.out.println("Mobile login: " + user + ", " + password);
}
}

CancelButtonDecorator может добавить функциональность отмены к любому компоненту логина.

package decorator;
public class CancelButtonDecorator extends LoginDecorator {
public CancelButtonDecorator(LoginComponent loginComponent) {
super(loginComponent);
}
public void cancel() {
System.out.println("Click the cancel button");
}
}

Вот так будет выглядеть финальная структура классов:


Протестируем, как оно себя ведет!

package decorator;
public class Main {
public static void main(String[] args) {
System.out.println("DECORATOR PATTERN");
System.out.println("=================");
// Это основной компонент логина
LoginComponent loginComponent = new BasicLoginComponent();
loginComponent.login("User", "PW");
// Превратим его в мобильный компонент
loginComponent = new MobileLoginDecorator(loginComponent);
loginComponent.login("User", "PW");
// И, наконец, добавим отмену
loginComponent = new CancelButtonDecorator(loginComponent);
((CancelButtonDecorator) loginComponent).cancel();
}
}

Результат работы:

DECORATOR PATTERN
=================
Basic login: User, PW
Mobile login: User, PW
Click the cancel button

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

Page Object и компоненты страницы

Паттерн Page Object – один из базовых паттернов UI-автоматизации. В нем вся функциональность определенного веб-UI оборачивается в класс. Это хорошо для простых просмотров, где нет особых возможностей для взаимодействия – page object ясны и хорошо управляемы.

Однако если страница содержит множество функций, классы page object могут стать гигантскими и превратиться в сложный хаотичный код. И тут в игру вступает расширение page object – компоненты страницы. Идея тут в том, чтобы обернуть в класс функциональность компонента, а не всей страницы.

Page Object – пример


Это примитивный интернет-магазин с поиском и списком найденных в результате продуктов. Если реализовать это с использованием Page Object, результат будет выглядеть примерно как этот класс WebshopPage.

package pageobjects;
public class WebshopPage {
public void search(final String queryString) {
System.out.println("Enter " + queryString);
System.out.println("Click search button");
}
public void checkResultHeadline() {
System.out.println("Check if the headline is correct.");
}
public void checkResults() {
System.out.println("Check if there are search results.");
}
}

Тут включены все действия, которые можно выполнять на этой странице. Это можно протестировать при помощи простого main-класса.

package pageobjects;
public class Main {
public static void main(String[] args) {
System.out.println("PAGE OBJECTS");
System.out.println("============");
WebshopPage webshopPage = new WebshopPage();
webshopPage.search("T-Shirt");
webshopPage.checkResultHeadline();
webshopPage.checkResults();
}
}

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

PAGE OBJECTS
============
Enter T-Shirt
Click search button
Check if the headline is correct.
Check if there are search results.

Пока что это неплохой подход. Но если добавить функциональности, то код быстро станет запутанным.

Пример работы компонента страницы

Тут в игру вступают компоненты страницы. В нашем случае можно разделить страницу на два компонента – панель поиска и список результатов.

Класс SearchBar должен содержать только метод поиска.

package pagecomponents;
public class SearchBar {
public void search(final String queryString) {
System.out.println("Enter " + queryString);
System.out.println("Click search button");
}
}

Методы для проверки заголовка результатов и самих результатов принадлежат ResultList:

package pagecomponents;
public class ResultList {
public void checkResultHeadline() {
System.out.println("Check if the headline is correct.");
}
public void checkResults() {
System.out.println("Check if there are search results.");
}
}

WebshopPage еще существует, но в этой версии он просто получает доступ к двум компонентам:

package pagecomponents;
public class WebshopPage {
    public SearchBar searchBar() {
return new SearchBar();
}
public ResultList resultList() {
return new ResultList();
}
}

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

package pagecomponents;
public class Main {
public static void main(String[] args) {
         System.out.println("PAGE COMPONENTS");
System.out.println("===============");
WebshopPage webshopPage = new WebshopPage();
webshopPage.searchBar().search("T-Shirt"); webshopPage.resultList().checkResultHeadline(); webshopPage.resultList().checkResults();
    }
}

Результат будет тем же:

PAGE COMPONENTS
===============
Enter T-Shirt
Click search button
Check if the headline is correct.
Check if there are search results.

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


Паттерн фабрики

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

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

Паттерн фабрики: пример работы

В этом примере мы доработаем пример компонента страницы, используя фабрику для создания компонентов.

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

package factory;
public class Component {
public void initialize() {
System.out.println("Initializing " + getClass().getName());
}
}

Каждый из компонентов может наследовать этому классу Component:

public class ResultList extends Component {
...
}
public class SearchBar extends Component {
...
}

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

package factory;
public class ComponentFactory {
public static Component getComponent(final String componentName) throws Exception {
        System.out.println("Creating " + componentName + "...");
// Create a component instance for the passed in component name.
Component component;
switch (componentName){
case "SearchBar":
component = new SearchBar();
break;
case "ResultList":
component = new ResultList();
break;
default:
throw new Exception(componentName + " unknown.");
}
System.out.println("Component created: " + component);
component.initialize();
return component;
}
}

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

package factory;
public class WebshopPage {
public SearchBar searchBar() throws Exception {
return (SearchBar) ComponentFactory.getComponent("SearchBar");
}
public ResultList resultList() throws Exception {
return (ResultList) ComponentFactory.getComponent("ResultList");
}
}

Код класса main выглядит так же, потому что WebshopPage все еще отвечает за управление своими компонентами.

package factory;
public class Main {
public static void main(String[] args) throws Exception {
         System.out.println("FACTORY PATTERN");
System.out.println("===============");
WebshopPage webshopPage = new WebshopPage();
webshopPage.searchBar().search("Berlin");
}
}

Вот результат работы модифицированного примера:

FACTORY PATTERN
===============
Creating SearchBar...
Component created: factory.SearchBar@3d075dc0
Initializing factory.SearchBar
Enter Berlin
Click search button

The component is requested, created, and initialised as expected.

Как и в предыдущих примерах, тут больше классов. Преимущество в том, что для поиска и инициализации компонентов есть централизованное место – это упростит разработку, если в будущем понадобится внедрять новые компоненты.


Внедрение зависимости

Этот паттерн родился благодаря идее "инверсии контроля". В нем объекты получают другие необходимые им объекты извне, а не создают их самостоятельно. Это упрощает создание вложенных классов и упрощает юнит-тестирование.

Как правило, ПО, использующее внедрение зависимостей, опирается на специализированный фреймворк вроде Spring или Guice, занимающийся созданием и внедрением объектов. Для простоты понимания пример ниже не использует фреймворк.

Пример внедрения зависимости

Мы хотим вставить функциональность логина в UI-тест простого сайта. Однако мы хотим хранить имя пользователя и пароль напрямую в классе, чтобы не передавать их каждый раз, когда требуется авторизация. К тому же, согласно нашим требованиям, у нас может быть несколько пар логина-пароля в зависимости от тест-кейса. Это новое требование не дает нам просто включить данные авторизации в страницу логина, потому что они должны быть гибкими. Поэтому хорошим выбором будет "внедрение" данных извне.

Все копии данных логина должны включать в себя интерфейс LoginData. Он просто возвращает логин и пароль.

package dependencyinjection;
public interface LoginData {
String getUserName();
String getPassword();
}

Рассмотрим две вариации – для "реальных" и "фальшивых" данных авторизации.

package dependencyinjection;
public class LoginDataReal implements LoginData {
    @Override
public String getUserName() {
return "Real user";
}
    @Override
public String getPassword() {
return "Real password";
}
}
package dependencyinjection;
public class LoginDataFake implements LoginData {
    @Override
public String getUserName() {
return "Fake user";
}
    @Override
public String getPassword() {
return "Fake password";
}
}

Конструктор LoginPage получает копию класса LoginData и использует ее в методе логина. Реально используемые логин и пароль не управляются страницей логина напрямую, а выбираются и внедряются извне.

package dependencyinjection;
public class LoginPage {
    private final LoginData loginData;
    public LoginPage(final LoginData loginData) {
this.loginData = loginData;
}
    public void login(){
System.out.println("Logging in with " + loginData.getClass());
System.out.println("- user: " + loginData.getUserName());
System.out.println("- password: " + loginData.getPassword());
}
}

Здесь отсутствует лишь тест-класс, использующий оба набора данных авторизации.

package dependencyinjection;
public class Main {
     public static void main(String[] args) {
        System.out.println("DEPENDENCY INJECTION");
System.out.println("====================");
        LoginPage loginPageReal = new LoginPage(new LoginDataReal());
loginPageReal.login();
        LoginPage loginPageFake = new LoginPage(new LoginDataFake());
loginPageFake.login();
}
}

Этот класс создает две отдельных страницы логина, отличающиеся только переданными данными авторизации. Запуск класса выдаст вот что:

DEPENDENCY INJECTION
====================
Logging in with class dependencyinjection.LoginDataReal
- user: Real user
- password: Real password
Logging in with class dependencyinjection.LoginDataFake
- user: Fake user
- password: Fake password

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


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

Заключение: две методологии

Мы только что увидели множество кода. Завершим статью чем-то совершенно иным – методологиями!

Когда вам нужно писать или расширять тест-фреймворк UI, полезно придерживаться гайдлайнов, которые помогут вам внедрять правильные для ваших заказчиков и коллег вещи. Лично я всегда стараюсь придерживаться примеров ниже. Они оказались очень полезными для меня, помогая мне оставаться на верном пути.

Чем проще, тем лучше

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

Вам это не понадобится

Лично для меня этот принцип, также известный как YAGNI – один из самых важных. Он похож на "чем проще, тем лучше" в плане того, что делать нужно только самое простое из того, что может сработать. Это означает, что новые функции надо добавлять только тогда, когда вы убеждены, что они вам необходимы, а не тогда, когда они, возможно, могут вам когда-то в будущем понадобиться.

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

Дополнительная информация

Для дополнительной информации о шаблонах проектирования см. "Шаблоны объектно-ориентированного проектирования" (Эрих Гамма, Ричард Хельм, Ральф Джонсон, Джон Влиссидес) – 1994.

Кросс-дисциплинарные идеи о сокращении процессного долга: When Testers Deal With Process Debt: Ideas to Manage It And Get Back To Testing Faster.

Обсудить в форуме