Разработка и тестирование программного обеспечения на первый взгляд кажутся очень отличными друг от друга, но есть аспекты, которые важны в обеих дисциплинах.
В этой статье мы рассмотрим некоторые распространенные паттерны и методологии проектирования ПО, которые могут быть полезны при работе с автоматизацией UI, особенно при создании фреймворка тестирования пользовательского интерфейса. Приведенные здесь примеры и варианты использования относятся к нашему собственному фреймворку.
Обратите внимание, что это не полноценные, а скорее упрощенные примеры кода, которые иллюстрируют концепции. Поскольку это мой основной язык программирования, приведенный ниже код написан на Java. Я постарался сделать его максимально простым и понятным.
Полезные паттерны для автоматизации тестирования UI
Паттерн проектирования - это не законченный фрагмент кода, который можно использовать в проектах. Напротив, это больше похоже на план решения.
Большим преимуществом использования паттернов проектирования является то, что они упрощают общение с другими разработчиками и тестировщиками. Если все знают об основных принципах работы паттерна, вам просто нужно упомянуть его название, чтобы быть на одной волне, вместо того чтобы каждый раз подробно демонстрировать свой код.
Паттерн Декоратор (Decorator)
Мой фреймворк должен поддерживать различные варианты компонентов веб-сайта. Это необходимо, потому что наше веб-приложение постоянно меняется, и A/B-тесты выполняются на уровне компонентов.
Если у вас есть подобное требование, паттерн Декоратор может вам подойти! Он позволяет упаковать компоненты в «конверты», которые перезаписывают или дополняют только определенные функции. Вам не нужно писать новый класс для каждой новой характеристики компонента: должны быть реализованы только изменения. Вы также можете использовать этот метод, если веб-компоненты меняются в зависимости от размера браузера или типа устройства.
Пример Декоратора
В этом примере у нас есть два компонента Login. У второго есть дополнительная кнопка «отменить».
Обычный способ справиться с этим - создать классы для каждого компонента. Второй будет иметь функциональность входа в систему первого, плюс функцию «отмены». Однако создание более одного класса приводит к дублированию кода и отсоединяет каждый новый вариант компонента от его коллег. Это становится еще более сложным, если у вас есть несколько компонентов входа в систему, например, для десктоп и мобильных устройств.
Давайте посмотрим на это с точки зрения декоратора!
LoginComponent - это интерфейс для каждого компонента Login. В нем говорится, что каждый компонент должен иметь определенный метод login.
package decorator;
public interface LoginComponent {
void login(String user, String password);
}
BasicLoginComponent имеет конкретную реализацию метода login. В этом примере он просто выводит «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 переопределяет функциональность login новым классом, специфичным для мобильных устройств. Опять же, он просто выводит «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 может добавить функцию cancel в любой компонент Login.
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("=================");
// This is the basic login component
LoginComponent loginComponent = new BasicLoginComponent();
loginComponent.login("User", "PW");
// Let's turn it into a mobile login component.
loginComponent = new MobileLoginDecorator(loginComponent);
loginComponent.login("User", "PW");
// Finally, we can add a cancel functionality.
loginComponent = new CancelButtonDecorator(loginComponent);
((CancelButtonDecorator) loginComponent).cancel();
}
}
Результат всего этого:
DECORATOR PATTERN
=================
Basic login: User, PW
Mobile login: User, PW
Click the cancel button
Несмотря на то, что сначала это кажется большим количеством накладных расходов, в конечном итоге это меньше работы. Вам нужно только реализовать измененную или добавленную функциональность в декораторе и добавить ее к компоненту, который вы хотите изменить. Это делает ваш тестовый код лаконичным и модульным.
Page Object и Page Component
Одним из первых паттернов, характерных для автоматизации UI, является паттерн Page Object. Это означает, что все функции конкретной страницы заключены в класс. Это хорошо для простых представлений без большого количества возможностей взаимодействия, поскольку объекты страницы ясны и управляемы.
Однако, если страница содержит много функций, классы объектов страницы могут стать огромными и превратиться в сложный и хаотичный код. Здесь и появляется расширение объектов страницы: Page Component. Идея состоит в том, чтобы обернуть функциональность компонента в класс, а не всю страницу.
Пример 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.
На данный момент такой подход хорош. Но если на эту страницу добавить больше функций, тестовый код может быстро запутаться.
Пример Page Component
Здесь на помощь приходит Page Component. В нашем случае вы можете разделить страницу на два компонента: панель поиска и список результатов.
Класс 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.
Это решение требует большего количества классов, но в каждом из них гораздо меньше кода, что облегчает его понимание и поддержку. Это также позволяет улучшить повторное использование каждого компонента в случае, если он появляется более чем на одной странице.
Паттерн Фабрика (Factory)
В общем случае, особенно при использовании паттерна, упомянутого выше, при тестировании приложения необходимо создавать множество различных классов. Очевидный подход заключается в том, чтобы просто создать новый экземпляр каждого нужного вам класса и использовать его.
Однако у этого подхода есть серьезный недостаток. Поскольку каждый класс компонента должен быть известен фреймворку и каждый компонент должен совместно использовать дублированный код инициализации при его создании. С помощью фабричного класса можно реализовать один источник истины, который может создавать экземпляры каждого необходимого класса.
Пример Фабрики
Для этого примера мы можем расширить приведенный выше пример и использовать фабрику для создания компонентов.
Во-первых, нам нужен класс, от которого наследуются все наши компоненты, поэтому все они имеют метод инициализации. Мы также могли бы использовать интерфейс, если бы каждый компонент функции мог иметь другой метод инициализации. Для простоты предположим, что инициализация одинакова для всех.
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 {
...
}
Недостающая часть - это фабричный класс, который отвечает за производство компонентов. Для иллюстрации фабричный класс принимает имя компонента в качестве аргумента, создает экземпляр соответствующего класса, вызывает его метод initialize и возвращает его обратно.
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;
}
}
Код 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
Компонент запрашивается, создается и инициализируется должным образом.
Как и в предыдущих примерах, теперь классов больше. Преимущество состоит в том, что существует централизованное место для инициализации компонентов, что делает разработку более эффективной, если будущие компоненты должны быть реализованы.
Внедрение зависимости (Dependency Injection)
Этот паттерн происходит от идеи «инверсии контроля». В этом паттерне объекты получают другие объекты, которые им нужны, извне, вместо того, чтобы создавать их самостоятельно. Это упрощает создание вложенных классов и значительно упрощает их юнит тестирование.
Как правило, программное обеспечение, использующее внедрение зависимостей, полагается на какую-то специализированную среду, такую как Spring или Guice, которая обрабатывает создание и внедрение объектов. Чтобы прояснить концепцию, в следующем примере фреймворк не используется.
Пример Внедрения зависимости
Здесь мы хотим предоставить функции входа в систему для тестирования UI простого веб-сайта. Однако мы хотим хранить имя пользователя и пароль непосредственно внутри класса, поэтому нам не нужно передавать их всякий раз, когда мы хотим вызвать логин. Кроме того, наше требование состоит в том, что у нас может быть несколько наборов имени пользователя и пароля в зависимости от тест-кейса. Это новое требование не позволяет нам просто включить данные для логина на страницу Login, поскольку она должна оставаться гибкой. Вот почему «вводить» данные извне - хороший выбор.
Это интерфейс 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 и использует его в своем методе login. Таким образом, фактическое имя пользователя и пароль для использования не управляются самой страницей LoginPage, а вместо этого выбираются и вводятся извне.
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, полезно придерживаться некоторых рекомендаций, которые помогут вам реализовать правильные вещи для ваших клиентов или товарищей по команде. Лично я всегда стараюсь придерживаться приведенных ниже примеров. Они оказались очень полезными для меня, не давая мне заблудиться в этом процессе.
Не усложняй (Keep It Simple)
Простые системы работают лучше сложных. Этот принцип применим не только к внутренней работе программного обеспечения, но и к интерфейсу конечного пользователя. В случае фреймворка тестирования, написание теста должно быть максимально простым. В конце концов, одна из ключевых концепций любого фреймворка - это упрощение более сложных задач.
Вам это не понадобится (You Aren’t Gonna Need It)
Лично для меня этот принцип, также известный как YAGNI, является одним из самых важных. Он связан с «сохранением простоты» в том смысле, что вы должны делать самое простое, что может работать. Это означает, что новые функции следует добавлять только тогда, когда вы уверены, что они необходимы, а не потому, что вы думаете, что они понадобятся в какой-то момент.
Если вы будете придерживаться этого принципа, вы, вероятно, быстрее разработаете свой тестовый фреймворк, потому что вам не нужно думать обо всех возможностях перед внедрением. Кроме того, вы можете предоставить именно те инструменты, которые необходимы по запросу, вместо предоставления готового решения, которое может быть перегружено и сложным в использовании.
Переведено командой QApedia. Еще больше переведенных статей вы найдете на нашем телеграм-канале.