Простой способ добавить человекочитаемое имя в WebElement поле Page Object используя аннотацию в Selenium тестах на Java

Довольно часто разработчикам автоматизированных тестов на Selenium бывает необходимо назначить некоторому элементу WebElement человекочитаемое имя. Такое имя может использоваться разработчиками по разному. Чаще всего оно помогает связывать некоторые события, отраженные в логах, с тем или иным элементом.

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

В этой статье мы будем говорить о первом случае. Мы реализуем аннотацию, которая будет использоваться для присвоения заданной строки в качестве имени поля типа WebElement внутри page object класса, инстанциируемого утилитой PageFactory.

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

Вы, всё же, сможете обращаться к "имени", указанному через аннотацию. Однако, это будет не очень удобно и не особо эффективно, т.к. вам придётся работать с полем (т.е. со ссылкой на объект), а не с самим объектом. В то же время, такой подход может работать более или менее хорошо, если вы будете использовать его при разработке утилитарных методов, работающих с WebElement, но не являющихся частью WebElement.

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

Давайте теперь взглянем на всё более детально

Описание примера: логируем имя элемента при попытке его обнаружить и в ситуации, когда обнаружение завершилось не успешно

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

<html>
  <head/>
  <body>
    <input type="button" style="margin: 50px;">
  </body>
</html>

Тест будет оперировать объектным представлением страницы (page object), определяющим кнопку на ней.

package click.webelement.pagefactory.customelement.easy;

import org.openqa.selenium.SearchContext;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.PageFactory;

public class PageObjectWithName {

    @Name("My test name")
    @FindBy(xpath = "//input[@type='button']")
    WebElement button;

    public PageObjectWithName(SearchContext context) {
        // TODO: implement page initialization
    }

    public void clickButton(){
        button.click();
    }

}

где @Name - аннотация, которую мы также собираемся разработать.

Добавляем аннотацию

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

package click.webelement.pagefactory.customelement.easy;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Name {
    String value();
}

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

Интегрируем созданную аннотацию в Selenium Page Factory инструментарий

Напомню, что наша задача - реализовать логирование человекочитаемого имени поля Page Object при попытке доступа к этому полю, а также при неуспешном завершении такого поиска. Для её решения мы собираемся реализовать сущность ElementLocator, которая должна отвечать за логику поиска элемента. Мы не будем разрабатывать такой локатор с нуля. Вместо этого мы воспользуемся уже имеющимся классом DefaultElementLocator. Данная имплементация реализует базовую логику поиска статичных элементов на странице.

Пару слов о том, что происходит "под капотом" когда вы инициализируете страницу с помощью PageFactory. Когда вы так делаете, Selenium пробегается по всем полям, аннотированным @FindBy и имеющим типы WebElement либо List<WebElement>. Он использует фабрику ElementLocatorFactory, которая производит по одному объекту типа ElementLocator для каждого заданного поля. Затем Selenium присваивает каждому из этих полей прокси-объект, который перехватывает вызовы методов findElement(..) и findElements(..) интерфейса WebElement и перенаправляет их соответствующим реализациям методов в ElementLocator.

Расширяем DefaultElementLocator

Давайте посмотрим на наше расширение DefaultElementLocator (который, в свою очередь, является реализацией интерфейса ElementLocator).

package click.webelement.pagefactory.customelement.easy;

import org.openqa.selenium.SearchContext;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.pagefactory.DefaultElementLocator;
import java.lang.reflect.Field;
import java.util.UUID;

public class WECElementLocator extends DefaultElementLocator {

    final String UNNAMED = UUID.randomUUID().toString();
    final String elementName;

    public WECElementLocator(SearchContext searchContext, Field field) {
        super(searchContext, field);
        Name elementNameAnnotated = field.getAnnotation(Name.class);
        if (elementNameAnnotated != null){
            elementName = elementNameAnnotated.value();
        }else{
            elementName = UNNAMED;
        }
    }

    private void log(String message){
        if(!UNNAMED.equals(elementName)){
            System.out.println(message + " (for [" + elementName + "])");
        }
    }

    @Override
    public WebElement findElement() {
        try{
            log("Attempt to lookup element..");
            WebElement result = super.findElement();
            log("Element successfully located.");
            return result;
        }catch (Throwable e){
            log("Problem in locating element..");
            throw e;
        }
    }
}

Тут можно отметить ряд примечательных моментов:

  1. Так как мы расширяем некий базовый локатор, мы должны позаботиться о том, чтобы конструктор нашего класса имел бы все необходимые данные для конструирования базовой части. Такими данными являются объекты типов SearchContext и Field. Их мы возьмём из параметров нашего конструктора и передадим в базовый конструктор.

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

  3. Мы можем и не аннотировать поле. В таком случае поле elementName должно иметь какое-то значение "по умолчанию" для избежания проблем обращения к "пустой" ссылке. В примере для такого случая, в качестве имени по умолчанию мы устанавливаем случайный уникальный набор символов. С его помощью, мы определим было ли полю задано некоторое имя или нет.

  4. Мы реализуем метод log, который сам определяет имеет смысл выводить отправленное сообщение или нет. По реализованной логике, сообщения для элементов без заданного имени не выводятся.

  5. Мы переопределяем метод findElement в котором логируем попытку поиска элемента, успешность поиска, а также неуспешность поиска (после чего исключение пробрасывается далее по стеку вызова). Сам поиск выполняется методом базового класса.

Реализуем ElementLocatorFactory

Реализация интерфейса ElementLocatorFactory - ещё один весьма простой шаг. Так как вся кастомная логика запускается внутри ElementLocator, здесь нам просто надо создать и вернуть наш WECElementLocator. Вот код:

package click.webelement.pagefactory.customelement.easy;

import org.openqa.selenium.SearchContext;
import org.openqa.selenium.support.pagefactory.ElementLocator;
import org.openqa.selenium.support.pagefactory.ElementLocatorFactory;
import java.lang.reflect.Field;

public class WECElementLocatorFactory implements ElementLocatorFactory {

    SearchContext context;

    public WECElementLocatorFactory(SearchContext context){
        this.context = context;
    }

    @Override
    public ElementLocator createLocator(Field field) {
        return new WECElementLocator(context, field);
    }
}

Теперь всё необходимое для написания теста реализовано. Давайте поправим наш page object из начала статьи и посмотрим на конечный результат.

Пейдж обджект с кастомной логикой инициализации

Класс, показанный ниже, отличается от первоначального варианта только в части конструктора. Вы можете заметить, что мы используем метод PageFactory.initElements(..), принимающий ElementLocatorFactory в качестве первого своего аргумента. Реализация фабрики и локатора - это то, обсуждением чего мы занимались до данного момента.

package click.webelement.pagefactory.customelement.easy;

import org.openqa.selenium.SearchContext;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.PageFactory;

public class PageObjectWithName {

    @Name("My test name")
    @FindBy(xpath = "//input[@type='button']")
    WebElement button;

    public PageObjectWithName(SearchContext context) {
        PageFactory.initElements(new WECElementLocatorFactory(context), this);
    }

    public void clickButton(){
        button.click();
    }

}

Пишем тест

Тест написан с помощью фреймворка JUnit. Даже если вы не знакомы с этим фреймворком, вы с лёгкостью переложите код, представленный ниже, на любой другой фреймворк или же вообще запустите его через main метод.

package click.webelement.pagefactory.customelement.easy;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.firefox.FirefoxDriver;

public class ElementNameTest {

    WebDriver driver;

    @BeforeAll
    public static void globalSetup() {
        System.setProperty("webdriver.gecko.driver", "/path_to_webdriver/geckodriver");
    }

    @BeforeEach
    public void setUp() {
        driver = new FirefoxDriver();
    }

    @Test
    public void testElementWithName() {
        driver.get("file:///path_to_demo_page/button.html");
        new PageObjectWithName(driver).clickButton();
    }

    @AfterEach
    public void tearDown() {
        if (driver != null) {
            driver.quit();
        }
    }

}

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