Почему не следует использовать ExpectedConditions в Page Object дизайне на Selenium Java

Многие инженеры-автоматизаторы при реализации классического подхода к поиску элементов в динамически-изменяемом интерфейсе используют комбинацию утилитарного класса ExpectedConditions с реализацией Wait-подобных "ожидателей". Однако, в архитектуре Page Object Селениум предлагает другой механизм для решения таких задач. В этой статье мы рассмотрим что такое ExpectedConditions, почему его дизайн является спорным, посмотрим на пример архитектуры Page Object и наконец реализуем условное ожидание элементов наших пейдж-обджектов, используя тот механизм, который Селениум предоставляет "из коробки".

Описание примера: тест в архитектуре Page Object, ожидающий от элемента принятия некоторого состояния

Представим, что у нас есть типичный Page Object-класс, который бы большинство людей использовали для описания области навигационного меню страницы https://webelement.click/en/welcome. Давайте взглянем на такой класс:

package click.webelement.pagefactory.conditions;

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

public class MenuPage {

    @FindBy(xpath = ".//li[1]/a")
    WebElement main;

    @FindBy(xpath = ".//li[2]/a")
    WebElement aboutMe;

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

    public MenuPage switchToMain(){
        main.click();
        return this;
    }

    public MenuPage switchToAboutMe(){
        aboutMe.click();
        return this;
    }

}

Что мы здесь наблюдаем? Довольно стандартный набор. Поля, описанные как WebElement, а также методы, некоторым образом описывающие логику обращения к этим полям. Проблема в том, что такой подход не позволяет ждать пока элементы примут некоторое заданное состояние. Например, если элемент находится под контролем джаваскрипта, он может изменить свое состояние через некоторое время после полной загрузки страницы, и мы, возможно, захотим считать такой элемент готовым для взаимодействия (загруженным) только после того, как он такое состояние примет.

В классическом подходе (не пейдж обджектном) мы бы использовали классы Wait и ExpectedConditions. Однако, для пейджобджект архитектуры, которая имеет в Selenium "нативную" поддержку, предлагается использовать другой механизм.

Что такое ExpectedConditions и в каких случаях мы его используем

Говоря просто, ExpectedConditions - это утилитарный класс, который предоставляет набор подготовленных объектов, классы которых имплементируют интерфейс Function. Когда вы описываете такой класс, вы определяете какой тип будет приниматься функцией как параметр и какой тип будет возвращаться функцией в качестве результата. В имплементации, поставляемой вместе с клиентскими библиотеками для языка Java, каждое условие (condition) в классе ExpectedConditions - это Function, которая всегда принимает WebDriver в качестве параметра, а возвращать может что-то, зависящее от конкретного реализованного условия. Пример использования такого подхода может выглядеть так:

private void doClick(){
    Wait<WebDriver> waiter = new FluentWait<>(driver);
    WebElement element = waiter
                .until(
                    ExpectedConditions
                        .visibilityOfElementLocated(By.xpath(".//some-element"))
                );
    element.click();
}

Главный спорный момент в таком способе состоит в том, что в то время как вейтеры наподобие FluentWait поддерживают любой тип параметра для функции, проверяющей выполнение условия (в целом, вы даже не ограничены рамками Selenium - вы вольны использовать вообще любой тип событий, которые Java может определить), проверки, реализованные в ExpectedConditions применимы только к объектам типа WebDriver. Противоречивость такой архитектуры демонстрируется в нескольких предопределённых кондишенах. Например:

public static ExpectedCondition<Boolean> attributeToBeNotEmpty(final WebElement element,
                                                               final String attribute) {
  return driver -> getAttributeOrCssValue(element, attribute).isPresent();
}

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

Как использовать вейтеры в Page Objects, созданных при помощи PageFactory.init()

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

  1. В классе PageFactory существует несколько методов init(). Каждый используется для инъекции полей в ваш пейдж-обджект. Вы можете либо использовать более простой метод, так что он каскадно вызовет остальные, используя некоторые дефолтные реализации компонент, необходимых для инициирования полей объекта, либо использовать более низкоуровневые методы, подставляя в них свои кастомные реализации таких компонент.

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

  3. Селениум предлагает две дефолтные реализации такого интерфейса: DefaultElementLocator и AjaxElementLocator.

    1. DefaultElementLocator предлагает некую простейшую логику поиска элемента. Фактически, он просто делегирует поиск тому SearchContext, который на данный момент используется (либо WebDriver либо WebElement). В дополнение, он поддерживает кеширование элементов. При включении такой опции, Селениум будет переиспользовать уже найденный элемент при повторном обращении к полю пейдж обджекта вместо того, чтобы искать его заново (это, к слову, не всегда является хорошей идеей, т.к. иногда вы можете наткнуться на проблему "протухшего" элемента)

    2. AjaxElementLocator расширяющий DefaultElementLocator. К базовому классу он добавляет возможность повторного поиска элемента в течение некоторого заданного времени. Мы взглянем на этот компонент по-ближе.

  4. Еще одним важным элементом общей картины является интерфейс LocatorFactory. Он определяет то, какие локаторы (см. выше) и с какими полями будут связываться. Мы собираемся реализовать простейшую фабрику, которая будет создавать одинаковые локаторы для всех полей. Однако, в вашем случае вы можете захотеть создать что-то более сложное. Например, ваша фабрика, возможно, будет создавать локаторы, отталкиваясь от вашей кастомной аннотации, которой будут аннотированы ваши поля.

Итак, держа в уме всё вышеописанное, мы можем двигаться дальше к реализации наших локаторов.

Кастомизация AjaxElementLocator под нашу задачу

Класс AjaxElementLocator реализован таким образом, что он оборачивает логику поиска элемента из родительского класса в цикл, повторяя поиск каждый раз когда искомый элемент не удается найти. Нюанс состоит в том, что по реализованной в классе логике, элемент считается найденным как только он появился в DOMе. Это поведение мы поменяем. К счастью, разработчики Selenium предусмотрели потребность в таких изменениях. В этом же классе существует метод, который по задумке призван определить готов ли элемент к взаимодействию с ним. Его реализация в классе выглядит следующим образом:

protected boolean isElementUsable(WebElement element) {
  return true;
}

Как вы могли заметить, реализация метода по умолчанию предполагает, что метод всегда возвращает true. Это, в свою очередь, значит, что никакой дополнительной проверки (кроме наличия элемента в DOMе) не проводится. В этот метод мы и внесем свои изменения. Так как изменения будут совсем незначительными, мы не будем создавать отдельного класса. Вместо этого, мы создадим нашу кастомную реализацию ElementLocatorFactory и расширим AjaxElementLocator прямо внутри её метода create.

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

package click.webelement.pagefactory.conditions;

import java.util.function.Function;
import org.openqa.selenium.SearchContext;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.pagefactory.AjaxElementLocator;
import org.openqa.selenium.support.pagefactory.ElementLocator;
import org.openqa.selenium.support.pagefactory.ElementLocatorFactory;
import java.lang.reflect.Field;

public class AjaxConditionalLocatorFactory implements ElementLocatorFactory {

    final SearchContext searchContext;
    final int timeOutInSeconds;
    final Function<WebElement, Boolean> condition;

    public AjaxConditionalLocatorFactory(SearchContext searchContext,
                                         int timeOutInSeconds,
                                         Function<WebElement, Boolean> condition) {
        this.searchContext = searchContext;
        this.timeOutInSeconds = timeOutInSeconds;
        this.condition = condition;
    }

    @Override
    public ElementLocator createLocator(Field field) {
        return new AjaxElementLocator(searchContext, field, timeOutInSeconds) {
            @Override
            protected boolean isElementUsable(WebElement element) {
                return condition.apply(element);
            }
        };
    }
}

Необходимо дать некоторые разъяснения касаемо нашего нового класса.

  1. Т.к. мы расширяем AjaxElementLocator, который использует SearchContext, Field и int, ожидая увидеть их в своём конструкторе, нам необходимо откуда-то взять эти значения, чтобы передать их ему. Field требуется исходя из контракта интерфейса ElementLocatorFactory - его значение будет предоставлено той частью Selenium которую мы менять не планируем. Остальные вещи - наша ответственность, поэтому мы сохраняем их через конструктор фабрики.

  2. Так как мы планируем реализовать проверку условий, я предлагаю придерживаться подхода, сходного с тем, что используется в ExpectedConditions (возьмем оттуда только хорошее). Условием мы будем считать, класс, реализующий интерфейс Function<T, R>, где T - тип параметра, принимаемого функцией, а R - тип результата, ею возвращаемого. Как мы видим из моей реализации, наши кондишены будут принимать WebElement в качестве параметра и возвращать значение типа Boolean.

  3. Интерфейс Function определяет единственный метод apply(), так что наш переопределенный метод isElementUsable будет просто делегировать проверку указанному в конструкторе условию.

Реализуем свои кастомные условия

Давайте создадим пару условий. В сходной с ExpectedConditions манере мы подготовим класс, содержащий статические методы, возвращающие Function<WebElement, Boolean>. Ниже представлен пример реализации:

package click.webelement.pagefactory.conditions;

import org.openqa.selenium.WebElement;
import java.util.function.Function;
import java.util.regex.Pattern;

public class MyConditions {

    public static Function<WebElement, Boolean> elementIsVisibleAndEnabled(){
        return webElement -> webElement.isDisplayed() && webElement.isEnabled();
    }

    public static Function<WebElement, Boolean> elementTextCorrespondsToAPattern(final Pattern p){
        return webElement -> {
            String elementText = webElement.getText();
            if(p.matcher(elementText).matches()){
                return true;
            }else{
                return false;
            }
        };
    }

}

Как можно заметить, здесь мы реализовали два условия. Первое проверяет не просто присутствие элемента в DOM, но также его видимость и то заенейблен ли он. Второе условие содержит более сложную проверку. В частности, оно проверяет соответствие текста элемента, заданному паттерну, описанному регулярным выражением. Имея такой пример под рукой, вы можете с лёгкостью добавить свои собственные условия.

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

Конструируем пейдж-класс с кастомной реализацией ElementLocatorFactory

Сейчас нам надо слегка изменить наш пейдж-класс. Вместо того, чтобы использовать метод PageFactory.init(WebDriver, Object), мы будем использовать метод PageFactory.init(ElementLocatorFactory, Object), который дает нам больше гибкости, и в целом, в большей степени отвечает нашим целям. Наш новый конструктор будет выглядеть так:

public MenuPage(SearchContext searchContext, Function<WebElement, Boolean> condition){
    PageFactory.initElements(
            new AjaxConditionalLocatorFactory(
                    searchContext,
                    10,
                    condition
            ),
            this
    );
}

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

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

Мы также вводим значение таймаута (параметр используется в расширямом нами классе AjaxElementLocator), определяющего время, по истечению которого мы считаем поиск проваленным.

Заключительный шаг: пишем тест

Теперь, когда мы всё подготовили, мы можем написать тест. У нас есть наша кастомная ElementLocatorFactory, которая производит ElementLocator, который расширяет AjaxElementLocator, у которого переопределён метод isElementUsable(WebElement element), который теперь использует логику, реализованную в наших кастомных условиях. Давайте посмотрим на то, как может выглядеть тестовый класс, использующий всё то, что мы приготовили:

package click.webelement.pagefactory.conditions;

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;
import java.util.regex.Pattern;

public class PageFactoryConditionsTest {

    WebDriver driver;

    @BeforeAll
    public static void globalSetup() {
        System.setProperty("webdriver.gecko.driver", "/home/alexey/Desktop/Dev/webdrivers/geckodriver");
    }

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

    @Test
    public void testCapitalizedTest() {
        driver.get("https://webelement.click/en/welcome");
        MenuPage menuPage = new MenuPage(
            driver,
            MyConditions.elementTextCorrespondsToAPattern(Pattern.compile("^[A-Z]{1}.*"))
        );
        menuPage.switchToAboutMe();
        menuPage.switchToMain();
    }

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

}

Что же происходит в этом тесте? Данный тест проверяет, что все пункты меню навигационной панели начинются с заглавной латинской буквы. Мы можем представить, что текст на нашей странице стилизуется джаваскриптом (как, например, это происходит на моем сайте в случае стилизации вставок исходного кода) уже после того как все элементы загружены. Поэтому мы считаем, что меню готово к взаимодействию только после того, как такой скрипт отработал.

Ну что ж, теперь вы представляете себе цельный процесс реализации механизма ожидания для архитектуры Page Object в том виде, как он изначально был задуман разработчиками Selenium. Это, конечно, не означает, что другие подходы к решению подобных задач не имеют права на существование. Однако, я уверен, что продемонстрированный подход является наиболее гибким и наиболее надежным из них.

Если после прочтения у вас всё ещё остались вопросы, присылайте их мне используя эту форму обратной связи. Я постараюсь ответить вам напрямую, а также скорректировать статью, опираясь на ваши отзывы.