Как и любой хороший фреймворк, Selenium является расширяемым. Предоставляя довольно широйки набор инструментов по умолчанию, он всё же не может предугадать потенциальные потребности отдельно взятого проекта. Как раз для таких случаев возможность расширения и предусматривается разработчиками. В этой статье мы взглянем на один из возможных вариантов раширения - добавление собственной стратегии поиска By
и интеграцию этой стратегии в инструментарий PageFactory
.
Почему мне вообще нужен какой-то отдельный метод поиска? Ведь есть xpath.
В основном для удобства. Если для вашей системы какакая-то стратегия характерна и эффективна, то такую стратегию имеет смысл описать в терминах, понятных Selenium. Кроме того, ваш конкретный вебдрайвер, умеющий работать с каким-то своим специфичным пользовательским интерфейсом, может иметь свою "родную" стратегию поиска, которая отсутствует в стандартном наборе (например - поиск по цвету).
Описание примера
В одной из моих статей, я объяснял как взаимодействовать с svg-элементами в Selenium. В той статье я использовал классический подход взаимодействия с элементами. Например, для того чтобы найти один из элеменотов, я использовал команду вида driver.findElements(By.xpath("//*[name()='circle'"));
.
На самом деле поиск был более специфичным, но я сократил его в целях краткости примера.
Если бы в моем тестовом проекте подобные обращения были бы частыми, мне было бы проще разработать свою стратегию поиска, и, возможно, интегрировать свою стратегию в инструментарий, предоставляемый PageFactory. Итак, в нашем примере мы расширим базовый функционал Selenium таким образом, что:
-
У нас появится своя стратегия поиска, расширяющая класс
By
. Эта стратегия будет искать svg-элементы, относящиеся к классу фигур. -
У нас появится своя аннотация (что-то вроде
@FindBySvg
), где бы мы могли задавать тип svg-фигуры, которую мы хотим ассоциировать с полем страницы. -
Мы сможем использовать нашу новую аннотацию и стандартные аннотации Selenium для разных полей на одной и той же странице.
Реализуем стратегию поиска
Что такое стратегия поиска? Это метод, которым будет осуществляться поиск элемента в пользовательском интерфейсе. Все реализации WebDriver должны соответствовать стандарту W3C, а значит должны поддерживать поиск по xpath, по CSS, а также по некоторым другим стратегиям. Сам алгоритм поиска реализуется внутри конкретного драйвера. Мы лишь можем указать что мы хотим с его помощью найти. Как я уже упомянул, мы будем искать фигуры svg. Для того, чтобы свести к минимуму возможные опечатки при описании искомого элемента в нашей новой стратегии, мы введем enum
, который будет хранить в себе все возможные фигуры.
package click.webelement.pagefactory.customby.bys; public enum SvgShape { CIRCLE, ELLIPSE, LINE, PATH, POLYGON, POLYLINE, RECT, NONE; @Override public String toString() { if(this.equals(NONE)){ return ""; } return super.toString().toLowerCase(); } }
Кроме того, нам нужно создать класс, расширяющий абстрактный класс By
, и реализовать в нем метод findElements
, который будет возвращать список элементов, удовлетворяющих данному локатору. Мы будем придерживаться дизайна, заданного самими разработчиками Selenium. Создадим наши кастомные стратегии как статические внутренние классы таким образом, что мы сможем создавать экземпляры наших стратегий, также как это делается в стандартной библиотеке. В случае стандартной библиотеки мы бы написали бы driver.findElement(By.xpath(…))
, а в нашем "расширенном случае", мы напишем driver.findElement(CustomBy.svgShape(…))
. Для того, чтобы подобный синтаксис стал возможным, нам надо реализовать нашу новую структуру классов, как показано ниже:
package click.webelement.pagefactory.customby.bys; import org.openqa.selenium.By; import org.openqa.selenium.SearchContext; import org.openqa.selenium.WebElement; import java.io.Serializable; import java.util.List; /** * This By is used to look up svg shape elements which have the given shape type */ public class CustomBy{ public static By svgShape(SvgShape shape){ return new BySvgShape(shape); } public static class BySvgShape extends By implements Serializable{ SvgShape shape; public BySvgShape(SvgShape shape){ this.shape = shape; } @Override public List<WebElement> findElements(SearchContext context) { return context.findElements(By.xpath("//*[name()='svg']//*[name()='" + shape.toString() + "']")); } @Override public String toString() { return "By shape: " + shape.toString(); } } }
Итак, суть нашего расширения находится во внутреннем классе BySvgShape
. Мы расширяем абстрактный класс By
, который требует от нас описать логику работы метода public List<WebElement> findElements(SearchContext context)
. Наша реализация работает на основе уже существующего способа поиска по xpath. Кстати, видны и некоторые огрехи (а.к.а. особенности). В частности:
-
Мы реализуем наш поиск на очень упрощенной модели svg-изображения, которая, например, не имеет вложенных друг в друга svg-изображений. Т.е. как-то, конечно, она работать в таком случае будет, но брать и использовать ее без изменений в своем конкретном случае я не рекомендую. Этот пример пригоден только для демонстрации расширяемости фреймворка Selenium, и работать он будет только с svg-моделью, представленной на этом сайте.
-
При трансляции объектного представления перечисления фигур, итоговые значения всегда будет в нижнем регистре. Учитывая, что мы используем значения тегов не в качестве самих тегов, а в значении сравнения с результатом функции
name()
этот поиск может не работать если имена ваших элементов, например написаны в заглавном регистре.
Проверяем нашу стратегию в тестах
Мы на пол-пути к цели, но мы уже можем использовать свои наработки в тестах. Давайте посмотрим полный пример. Мы создадим один тест с классическим подходом к взаимодействию с элементами, а после добавим немного классов и проверим работают ли наши стратегии с PageFactory. Итак классический подход.
package click.webelement.pagefactory.customby; import click.webelement.pagefactory.customby.bys.CustomBy; import click.webelement.pagefactory.customby.bys.SvgShape; import org.junit.jupiter.api.*; import org.openqa.selenium.By; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; import org.openqa.selenium.firefox.FirefoxDriver; import java.util.List; public class CustomByTest { WebDriver driver; @BeforeAll public static void globalSetup() { System.setProperty("webdriver.gecko.driver", "/home/alexey/Dev/webdrivers/geckodriver"); } @BeforeEach public void setUp() { driver = new FirefoxDriver(); } @Test public void testClassicApproach(){ driver.get("https://webelement.click/stand/svg?lang=en"); List<WebElement> circlesRoot = driver.findElements(CustomBy.svgShape(SvgShape.CIRCLE)); List<WebElement> circlesParentContext = driver .findElement(By.className("post-body")) .findElements(CustomBy.svgShape(SvgShape.CIRCLE)); List<WebElement> circlesWrongContext = driver .findElement(By.className("left-pane")) .findElements(CustomBy.svgShape(SvgShape.CIRCLE)); Assertions.assertEquals(4, circlesRoot.size() , "Four circles are found from root"); Assertions.assertEquals(4, circlesParentContext.size() , "Four circles are found from parent context"); Assertions.assertEquals(0, circlesWrongContext.size() , "Zero circles are found from wrong context"); } @AfterEach public void tearDown() { if (driver != null) { driver.quit(); } } }
Так выглядит тестовый класс в нашем примере. В нем уже есть один тест, который демонстрирует использование нашей кастомной стратегии. Он уже включает в себя всю необходимую стороннюю работу по подготовке и завершению тестов, поэтому код второго теста будет представлен только тестовым методом, который нужно будет добавить в этот имеющийся класс.
Создаем аннотацию @FindBySvg
Следующая наша цель - заставить нашу стратегию работать с PageFactory. Для этого нам надо аннотировать поля, и каким-то образом дать знать PageFactory что эту аннотацию надо рассматривать как сущность, несущую информацию о стратегии поиска, которую нужно использовать для связывания поля объекта и элемента пользовательского интерфейса. Вообще всё, что связано с аннотациями - это подраздел большого и сложного раздела "Рефлексия" - механизма, позволяющего вашей программе анализировать себя в процессе исполнения. Если вы знакомы с этим механизмом - отлично. Если нет, вам скорее всего придется знакомиться с ним по ключевым словам, встречающимся далее в статье.
Код аннотации, отвечающей нашим требованиям представлен ниже. Далее мы его поверхностно рассмотрим.
package click.webelement.pagefactory.customby.bys; import org.openqa.selenium.By; import org.openqa.selenium.support.AbstractFindByBuilder; import org.openqa.selenium.support.PageFactoryFinder; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.lang.reflect.Field; @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) @PageFactoryFinder(FindBySvg.FindByCustomBuilder.class) public @interface FindBySvg { SvgShape shape() default SvgShape.NONE; public static class FindByCustomBuilder extends AbstractFindByBuilder { public By buildIt(Object annotation, Field field) { FindBySvg findBy = (FindBySvg) annotation; return CustomBy.svgShape(findBy.shape()); } } }
Внимание: дальнейший текст содержит чрезмерное употребление слова "аннотация" и его производных. Уберите впечатлительных и легко возбудимых людей от экрана.
Итак, немного поясню ключевые моменты представленного кода:
-
public @interface FindBySvg
- это синтаксис, используемый для объявления аннотации. Наша аннотация сама аннотирована. Эти аннотации, описывают то, каким образом мы будем использовать нашу аннотацию.-
@Retention(RetentionPolicy.RUNTIME)
говорит о том, что получать доступ к нашей аннотации мы сможем в процессе исполнения программы. -
@Target(ElementType.FIELD)
говорит о том, что с помощью нашей аннотации мы сможем аннотировать только поля -
@PageFactoryFinder(FindBySvg.FindByCustomBuilder.class)
- если две предыдущих аннотации входят в стандартный пакет джавы, то данная аннотация поставляется в пакете Selenium. Она используется в PageFactory для того чтобы указать, какой класс будет использоваться для формирования стратегии поиска, ассоциированной с помеченным аннотацией полем (он должен расширять абстрактный классAbstractFindByBuilder
).
-
-
SvgShape shape() default SvgShape.NONE;
означает, что мы будем аннотировать поле таким способом:@FindBySvg(shape = SvgShape.CIRCLE)
. То есть мы будем указывать параметр, который сможет принимать одно из значений перечняSvgShape
. Если же мы опустим скобки вообще, то аннотации присвоется значение по умолчанию, равноеSvgShape.NONE
.
Этот кусок:
public static class FindByCustomBuilder extends AbstractFindByBuilder { public By buildIt(Object annotation, Field field) { FindBySvg findBy = (FindBySvg) annotation; return CustomBy.svgShape(findBy.shape()); } }
встраивается в общую канву следующим образом.
Когда PageFactory начинает обрабатывать размеченный нами класс страницы, логика обработки проходит через класс DefaultElementLocator
(в том случае, если мы намеренно не меняли эту реализацию интерфейса ElementLocator
изменив такое поведение). Этот ElementLocator
создается на основании каждого из полей нашего страничного класса. Из поля извлекаются аннотации, и если у поля встречаются такие аннотации, которые сами аннотированы аннотацией PageFactoryFinder
, берется значение последней (в нашем случае - FindBySvg.FindByCustomBuilder.class
), которое представляет собой ссылку на класс.
Используя механизмы рефлексии, на основании класса создается объект (для того чтобы объект мог быть создан, мы и переопределили метод buildIt(…)
- иначе класс остался бы абстрактным и было бы не возможно создать его экземпляр), у которого вызывается метод buildIt
. Таким образом экземпляр ElementLocator
для каждого конкретного поля содержит ссылку на объект By
, определяющий стратегию поиска, ассоциированную с этим полем.
Более подробно и предметно этот момент мы рассмотрим в моей отдельной статье, посвященной разбору PageFactory и ассоциированных классов.
Теперь у нас есть аннотация. Мы можем аннотировать ей поля, но к сожалению пока наше решение всё еще неполноценно. Иногда, попытки инициализировать страницу с полями, помеченными такой аннотацией, приведут к тому, что такие поля будут ссылаться на null
.
Расширяем DefaultFieldDecorator
Итак, почему вышеобозначенная проблема имеет место? Дело в том, что стандартный подход инициализации полей упирается в класс DefaultFieldDecorator
. Сущность FieldDecorator
отвечает непосредственно за создание прокси-объекта и связывание его с декорируемым полем нашего Page-класса. Вся информация, о поле, о которой мы говорили до этого, так или иначе вкладывается в FieldDecorator
и используется им в момент декорирования.
Уже существующий в библиотеке класс DefaultFieldDecorator
, используемый в стандартном нерасширенном подходе, прежде чем начать декорирование, пытается определить, годится ли для декорирования предоставленное поле. Критерии простые:
-
поле должно иметь тип
WebElement
-
либо поле должно иметь тип
List<WebElement>
Во втором критерии и заложена наша проблема. Для проверки такого критерия в классе выделен отдельный метод - protected boolean isDecoratableList(Field field)
. На наше счастье он не приватный, а значит - его можно переопределить. Проблема данного метода в том, что там есть такая часть:
if (field.getAnnotation(FindBy.class) == null && field.getAnnotation(FindBys.class) == null && field.getAnnotation(FindAll.class) == null) { return false; }
Очевидно, нашей аннотации в этом списке нет, а значит единственный способ ее добавить - переопределить метод в своём классе, расширяющий DefaultFieldDecorator
. К сожалению, весь остальной код метода нам придется перенести, ради добавления одной строчки кода проверки нашей собственной аннотации. По сему, создаем свой класс-расширение:
package click.webelement.pagefactory.customby.bys; import org.openqa.selenium.WebElement; import org.openqa.selenium.support.FindAll; import org.openqa.selenium.support.FindBy; import org.openqa.selenium.support.FindBys; import org.openqa.selenium.support.pagefactory.DefaultFieldDecorator; import org.openqa.selenium.support.pagefactory.ElementLocatorFactory; import java.lang.reflect.Field; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.util.List; public class CustomFieldDecorator extends DefaultFieldDecorator { public CustomFieldDecorator(ElementLocatorFactory factory) { super(factory); } @Override protected boolean isDecoratableList(Field field) { if (!List.class.isAssignableFrom(field.getType())) { return false; } // Type erasure in Java isn't complete. Attempt to discover the generic // type of the list. Type genericType = field.getGenericType(); if (!(genericType instanceof ParameterizedType)) { return false; } Type listType = ((ParameterizedType) genericType).getActualTypeArguments()[0]; if (!WebElement.class.equals(listType)) { return false; } if (field.getAnnotation(FindBy.class) == null && field.getAnnotation(FindBys.class) == null && field.getAnnotation(FindAll.class) == null && field.getAnnotation(FindBySvg.class) == null) { return false; } return true; } }
Такое расширение поможет нам одним и тем же способом декорировать как поля, аннотированные нашей собственной аннотацией, так и стандартными аннотациями Selenium. Остался лишь небольшой штрих.
Как теперь всё это применить?
Мы проделали большую работу. К данному моменту у нас есть:
-
Описание своей стратегии поиска
BySvgShape
-
Собственная аннотация
@FindBySvg
-
Класс
CustomFieldDecorator
, расширяющийDefaultFieldDecorator
добавлением нашей новой аннотации в перечень допустимых для полей типаList<WebElement>
Всё остальное Selenium сделает сам. Однако, как подсказать ему, что необходимо использовать наш расширенный подход? Для этого в классе PageFactory существует метод initElements(FieldDecorator decorator, Object page)
. Свой собственный FieldDecorator
у нас уже есть. Так как он базируется на DefaultFieldDecorator
, при его создании надо передать в конструктор фабрику ElementLocatorFactory
. Ничего связанного с этой фабрикой мы не меняли, так что будем использовать её реализацию по умолчанию. В итоге для того чтобы инициализировать поля в пейдж-обджекте, мы будем использовать такую строчку:
PageFactory.initElements(new CustomFieldDecorator(new DefaultElementLocatorFactory(driver)), this);
Давайте посмотрим, как выглядит пример страницы, размеченной как стандартной, так и нашей новой аннотациями.
package click.webelement.pagefactory.customby; import click.webelement.pagefactory.customby.bys.CustomFieldDecorator; import click.webelement.pagefactory.customby.bys.FindBySvg; import click.webelement.pagefactory.customby.bys.SvgShape; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; import org.openqa.selenium.support.FindBy; import org.openqa.selenium.support.PageFactory; import org.openqa.selenium.support.pagefactory.DefaultElementLocatorFactory; import java.util.List; public class SvgPage { @FindBySvg(shape = SvgShape.CIRCLE) private List<WebElement> circles; @FindBy(xpath = "//h2") private WebElement header; public SvgPage(WebDriver driver){ PageFactory.initElements(new CustomFieldDecorator(new DefaultElementLocatorFactory(driver)), this); } public void printCustomElements(){ circles.stream().forEach(System.out::println); } public void printNativeElement(){ System.out.println(header.getText()); } }
Данная модель страницы даёт объектное представление страницы SVG-стенда, представленного на этом сайте. Посмотрим, теперь, как выглядит тест, работающий с такой моделью (ранее мы договорились, что я покажу только сам тестовый метод, который можно вставить в тестовый класс, показанный выше и запустить его).
@Test public void testPageObject() { driver.get("https://webelement.click/stand/svg?lang=en"); SvgPage page = new SvgPage(driver); page.printCustomElements(); page.printNativeElement(); }
Тест фактически ничего не тестирует, но демонстрирует доступ к полям страницы-объекта, который можно перенести в реальную жизнь.
Остались вопросы? Задавайте их тут. Я постараюсь дополнить статью опираясь на ваши замечания.