Используем относительные локаторы для элементов, инициализированных через PageFactory.initElements() - Java

Одним из частных вопросов при реализации паттерна Page Objects с использованием вспомогательного класса PageFactory является вопрос локации одних компонентов страницы относительно других компонентов страницы. В сегодняшней статье я покажу один из способов реализации таких связок. Остальные способы, в целом, являются вариациями показанного. Здесь я не буду подробно останавливаться на том почему это работает именно так. Вместо этого, следующую свою статью я посвящу подробному обзору PageFactory и тому, что находится у него "под капотом" и как этим профессионально пользоваться. А пока, продолжим.

Классический подход и базовые положения архитектуры

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

public void test(){
	// ... Some preceding code including instantiating of "WebDriver driver = new ..."
	WebElement baseElement = driver.findElement(By.xpath("//base_node"));
	WebElement childElement = baseElement.findElement(By.xpath(".//child_node"));
	// ... Some following code
}

Такой подход используется многими и позволяет искать одни элементы в контексте других. Давайте копнём чуть вглубь. Если посмотреть на код выше, становится очевидным, что мы можем обращаться к методу findElement не только у объекта driver, но также и у объекта baseElement, не смотря на то, что один реализует интерфейс WebDriver, а второй - интерфейс WebElement. Дело в том, что оба этих интерфейса расширяют интерфейс SearchContext, который и описывает функциональность поиска одного элемента или их коллекцию (а точнее List). При этом, оба метода, определяемы в SearchContext принимают на вход объект класса By.

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

Описание примера

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

Модель страницы и классическое использование PageFactory

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

package click.webelement.pagefactory.relative;

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

public class WebElementClickBase {

    @FindBy(xpath = "//ul[@class='side-menu']")
    protected WebElement sideMenu;

    public WebElementClickBase(WebDriver driver){
        PageFactory.initElements(driver, this);
    }

    public void printBaseTagName(){
        System.out.println(sideMenu.getTagName());
    }

}

Что произойдет, когда мы создадим экземпляр нашей базовой страницы? Код PageFactory увидит, что страница, которую мы передали для обработки это сам объект this. Он просканирует поля нашего объекта, выделит из них поля классов WebElement и List<WebElement>, проверит аннотированы эти поля как-то или нет. Если поля аннотированы одной из известных Selenium'у аннотаций, он использует параметры таких аннотаций чтобы сформировать способ поиска элементов, если аннотаций нет, то формирует стандартный способ, где имя поля является айдишником или именем элемента.

Сформировав такой способ, Selenium создает прокси-объект, который перехватывает любое обращение (в рамках методов, определённых в интерфейсах WebElement, WrapsElement и Locatable) к полю страницы и перенаправляет его реально созданному элементу (создавая его, если тот еще не создан - т.н. ленивая инициализация) получая этот элемент с использованием способа, определенного в аннотациях.

Давайте еще раз более предметно рассмотрим этот момент. Допустим, у нас есть поле WebElement myButton;. Мы инициализировали поля страницы при помощи класса PageFactory. Что произойдет, кода мы попытаемся вызвать метод myButton.click()?

Т.к. инициализация уже произошла, полю myButton присвоен экземпляр прокси-объекта. У этого объекта есть метод click(), т.к. создавая прокси-объект, мы указали, что он должен соответствовать интерфейсу WebElement. Также у этого объекта есть т.н. InvocationHandler (всё это входит в стандартный пакет java.lang.reflect). Этот хэндлер описывает действия, которые необходимо выполнить при обращении к проксированному методу.

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

Ключевым в нашем классе является поле protected WebElement sideMenu;. В классе мы видим конструктор. Вообще-то PageFactory может инициализировать наши поля и тогда, когда явного конструктора у класса нет. Но иногда бывает полезно хранить в классе ссылку на вебдрайвер, например, чтобы иметь возможность описывать бизнес-функции той или иной страницы, требующие обращения непосредственно к драйверу (например выполнение JavaScript-кода). Такую ссылку имеет смысл передавать во время конструирования объекта страницы. В данном классе мы хранить такую ссылку не будем, но кастомным конструктором всё равно воспользуемся, т.к. хотим чтобы страница сама инициализировала свои поля в момент создания.

Кроме того, в глаза бросается метод public void printBaseTagName(). Его я добавил для демонстрации того, что мы действительно оперируем двумя отдельными элементами: отдельно базовым и отдельно дочерним. Какой либо пользы в реальной жизни такой способ не принесет.

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

package click.webelement.pagefactory.relative;

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

public class WebElementClickBase {

    @FindBy(xpath = "//ul[@class='side-menu']")
    protected WebElement sideMenu;

}

Выглядит весьма просто, не так ли? Здесь мы убрали "лишние" методы, в том числе и конструктор, так что для инициализации полей в объекте такого класса, нам придется вызывать PageFactory.initElements(…​) извне. Отмечу также опять, что более подробно об этом мы поговорим в отдельной статье, посвященной PageFactory.

Перегруженные методы PageFactory.initElements(..), а также немного об ElementLocator’ах

Перед тем, как приступить непосредственно к примеру, взглянем поверхностно на то, как класс PageFactory предлагает нам инициализировать элементы нашей страницы (да, более подробно в моем отдельном обзоре, посвященному PageFactory и паттерну Page Objects). Класс предлагает нам четыре способа:

public class PageFactory {

  // ...

  public static <T> T initElements(WebDriver driver, Class<T> pageClassToProxy) {
    // ...
  }

  public static void initElements(WebDriver driver, Object page) {
    // ...
  }

  public static void initElements(ElementLocatorFactory factory, Object page) {
    // ...
  }

  public static void initElements(FieldDecorator decorator, Object page) {
    // ...
  }

  // ...

}

Два из них мы отсекаем сразу, т.к. они предлагают нам передать им WebDriver в качестве контекста поиска. Мы обратим свой взгляд на третий метод: initElements(ElementLocatorFactory factory, Object page)

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

Если кратко объяснить, что такое ElementLocator, то можно сказать, что это объект, который для каждого поля вашей страницы, определяет то, как будут искаться элементы для него. В свою очередь, ElementLocatorFactory - это компонент, который умеет создавать ElementLocator для какого-то указанного поля. Учитывая, что все эти методы каскадно вызываются друг из друга, можно предположить, что существует какая-то реализация этих классов "по умолчанию". Так оно и есть.

Разработчики Selenium позаботились о нас и создали (даже не одну) реализацию для пары интерфейсов ElementLocator / ElementLocatorFactory, а именно фабрику DefaultElementLocatorFactory, которая умеет производить объекты класса DefaultElementLocator. Фабрика эта конструируется на основе всего, что реализует интерфейс SearchContext, а значит нам подойдет не только WebDriver, но и WebElement тоже.

Создаем зависимые элементы через PageFactory.initElements().

Итак, нам предстоит применить метод initElements(ElementLocatorFactory factory, Object page) для инициализации зависимых элементов. Мы должны помнить о двух вещах:

  1. Метод инициализирует поля готового объекта. Если мы применим этот метод на объекте поля которого уже были проинициализированы, они переинициализируются по новым правилам (например, с новым контекстом поиска). Поэтому страница-объект, содержащая зависимые поля не должна находиться в родственных связях со страницой, содержащей базовый элемент.

  2. Если мы применяем метод поиска по xpath (как в данном примере), то выражение следует начинать с символа ., иначе элемент будет искаться начиная с корня документа не зависимо от используемого контекста.

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

package click.webelement.pagefactory.relative;

import org.openqa.selenium.SearchContext;
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 MenuPage {

    @FindBy(xpath = "./li")
    List<WebElement> items;

    public MenuPage(SearchContext searchContext){
        PageFactory.initElements(new DefaultElementLocatorFactory(searchContext), this);
    }

    public void printDependent(){
        items.stream().map(w -> w.getText()).forEach(System.out::println);
    }

}

Как мы видим, это независимый класс, содержащий поле типа List<WebElement>. В конструкторе мы ожидаем прихода объекта, имплементирующего интерфейс SearchContext (например WebElement или WebDriver). Оборачиваем то, что получили в фабрику, входящую в состав пакета Selenium, а именно DefaultElementLocatorFactory, и указываем объект, поля которого хотим инициализировать - свой же созданный экземпляр (this).

Добавляем метод printDependent() для демонстрации работы нашего подхода. В методе мы используем стримы для упрощения задачи распечатки текста элементов. Метод map() преобразует список WebElement в список текстов внутри этих элементов, а далее к каждому элементу (фактически - строке) мы применяем метод println() из объекта System.out, выводя таким образом результат в консоль.

Также, добавляем метод, возвращающий нам экземпляр дочерней страницы в класс WebElementClickBase

public MenuPage getMenuPage(){
	return new MenuPage(sideMenu);
}

Вызываем всё из теста

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

package click.webelement.pagefactory.relative;

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 TestRelative {

    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 testPageFactory() {
        driver.get("https://webelement.click/en/welcome");
        WebElementClickBase menu = new WebElementClickBase(driver);
        menu.printBaseTagName();
        menu.getMenuPage().printDependent();
    }

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

    }

}

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

P.S. - Фабрика DefaultElementLocatorFactory производит локаторы только для тех элементов, которые мы ожидаем увидеть сразу после загрузки страницы. Для динамических элементов (например контента, управляемого AJAX-запросами), разработчики Selenium подготовили похожий инструмент. Для работы с такими элементами вместо фабрики DefaultElementLocatorFactory используйте фабрику AjaxElementLocatorFactory. Конструктор нашей дочерней страницы, в таком случае, выглядел бы так:

public MenuPage(SearchContext searchContext){
	PageFactory.initElements(new AjaxElementLocatorFactory(searchContext, 10), this);
}

где в конструкторе AjaxElementLocatorFactory после searchContext мы указываем таймаут (в секундах) ожидания элементов, инициализированных таким способом.

Остались вопросы? Задавайте их тут. Я постараюсь дополнить статью опираясь на ваши замечания.