Добавляем "параметризованную" аннотацию FindBy в Page Object модель на Selenium Java для поддержки тестирования многоязычного приложения

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

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

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

Давайте теперь более детально на всё посмотрим.

Описание примера: реализация параметризованных локаторов в Page Object

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

Тестовые страницы

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

en.html:

<html>
  <body>
    <button name="Press me"></button>
    <label>Read me</label>
  </body>
</html>

ru.html:

<html>
  <body>
    <button name="Нажми меня"></button>
    <label>Прочитай меня</label>
  </body>
</html>

Код объектного представления страницы

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

package click.webelement.pagefactory.localized;

import org.openqa.selenium.SearchContext;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.PageFactory;
import org.openqa.selenium.support.pagefactory.DefaultElementLocatorFactory;

public class ParameterizedPageObject {

    @ParameterizedBy(xpath = "//button[@name='{wec:button.name}']")
    WebElement button;

    @ParameterizedBy(xpath = "//label[text()='{wec:label.text}']")
    WebElement label;

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

}

Вы могли заметить, что в наших локаторах мы используем xpath-выражения с конструкциями вида {wec:button.name}. Такой вид записи и будет определять параметры в нашем примере.

Хранения локализованных строк

Так как в качестве примера из реального мира мы выбрали проблему тестирования локализации, в нашем проекте мы будет придерживаться типичного подхода, используемого для локализации приложений, а именно хранения локализованных строк в проперти-файлах. Файлы будут именоваться по коду используемого языка и иметь расширение .strings. В данном конкретном примере мы подготовим два файла, содержимое которых представлено ниже:

en.strings:

button.name=Press me
label.text=Read me

and ru.strings:

button.name=Нажми меня
label.text=Прочти меня

Код теста будет выбирать значения из какого файла использовать, отталкиваясь от значения системного свойства wec.language. Перейдем к деталям реализации.

Реализуем кастомную аннотацию

Начнем с самой простой части реализации - кастомной аннотации. Этой аннотацией мы будем помечать поля нашего страничного объекта. К сожалению, мы не сможем использовать стандартную аннотацию FindBy т.к. она жестко связана с логикой, заданной разработчиками по умолчанию (где параметризованные локаторы не поддерживаются).

package click.webelement.pagefactory.localized;

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;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@PageFactoryFinder(ParameterizedByBuilder.class)

public @interface ParameterizedBy {

    String xpath() default "";

}

Реализация, показанная в моем примере, поддерживает только xpath-локаторы. Однако, расширить поддержку на остальные типы локаторов не составит труда - главное понять идею.

Наша новая аннотация будет сама аннотирована аннотацией @PageFactoryFinder(ParameterizedByBuilder.class). Она сообщит Selenium где искать код, который должен создавать объект By, используя значение ParameterizedBy, но уже с нашей новой логикой.

Реализуем поставщика значений для параметров

Наш код будет брать значения параметров из проперти-файлов (так обычно реализуется локализация приложений). Один из этих наборов будет дефолтным (набор из файла en.strings), остальные будут хранить переводы на другие языки.

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

Ниже показана реализация такой логики.

package click.webelement.pagefactory.localized;

import java.io.InputStreamReader;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;

public class ParameterProvider {

    private static final String WEC_LANGUAGE_PROP = "wec.language";
    private static final String WEC_LANGUAGE_DEFAULT = "en";
    private Properties properties;
    private static Map<String, ParameterProvider> parameterProviderMap = new HashMap<>();

    private ParameterProvider() {
        Properties defaultProperties = new Properties();
        String language = System.getProperty(WEC_LANGUAGE_PROP, WEC_LANGUAGE_DEFAULT);
        String fileName = language + ".strings";
        try (InputStreamReader defaultPropsIS = new InputStreamReader(Objects.requireNonNull(this
                .getClass()
                .getClassLoader()
                .getResourceAsStream("en.strings")), "UTF-8")) {
            defaultProperties
                    .load(defaultPropsIS);
        } catch (Exception e) {
            System.err.println("Unable to load default properties (en.strings)..");
            e.printStackTrace();
        }
        try (InputStreamReader propsIS = new InputStreamReader(Objects.requireNonNull(this
                .getClass()
                .getClassLoader()
                .getResourceAsStream(fileName)), "UTF-8")) {
            properties = new Properties(defaultProperties);
            properties
                    .load(propsIS);
        }catch (Exception e){
            System.err.println("Unable to load properties from: " + fileName);
            e.printStackTrace();
        }
    }

    private static ParameterProvider getParameterProvider() {
        String language = System.getProperty(WEC_LANGUAGE_PROP, WEC_LANGUAGE_DEFAULT);
        if (!parameterProviderMap.containsKey(language)) {
            parameterProviderMap.put(language, new ParameterProvider());
        }
        return parameterProviderMap.get(language);
    }

    public static String getParameter(String name) {
        return getParameterProvider().properties.getProperty(name);
    }

}

Самой логики здесь не очень много. Большая часть строк отвечает за загрузку файлов в объект пропертей.

Реализуем ParameterizedByBuilder, строящий объект By для поля пейдж обджекта

Наш кастомный билдер должен расширять класс AbstractFindByBuilder, предоставляемый в Java-баиндингах Selenium. Мы переопределим метод public By buildIt(..), используемый Selenium для построения объекта By. Наш класс также будет содержать логику подстановки значений параметров в параметризованные выражения xpath.

package click.webelement.pagefactory.localized;

import org.openqa.selenium.By;
import org.openqa.selenium.support.AbstractFindByBuilder;
import java.lang.reflect.Field;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class ParameterizedByBuilder extends AbstractFindByBuilder {

    protected By buildParameterizedBy(ParameterizedBy findBy) {
        if (!"".equals(findBy.xpath())) {
            return By.xpath(processParameter(findBy.xpath()));
        }
        return null;
    }

    @Override
    public By buildIt(Object annotation, Field field) {
        ParameterizedBy parameterizedBy = (ParameterizedBy)annotation;
        return buildParameterizedBy(parameterizedBy);
    }

    private static String processParameter(String input) {
        Pattern p = Pattern.compile("\\{wec:(.+?)\\}");
        Matcher m = p.matcher(input);
        String result = input;
        while (m.find()) {
            String fullMatch = m.group();
            String propName = m.group(1);
            String propValue = ParameterProvider.getParameter(propName);
            if (propValue == null) {
                throw new IllegalArgumentException("Cannot find property: " + propName);
            }
            result = result.replace(fullMatch, propValue);
        }
        return result;
    }

}

Давайте рассмотрим некоторые моменты представленной реализации. Важная для Selenium часть нашего класса содержится в методе buildIt(). Selenium вызывает этот метод когда пытается инициализировать поле Page Object объекта. Он также передает в метод аннотацию, которой поле аннотировано, а также само инициализируемое поле. Используя переданную аннотацию, мы возвращаем объект By так же как это делается в оригинальной логике, но с одним отличием - мы некоторым образом обрабатываем (изменяем) заданный пользователем локатор.

Важная для нас часть класса - это метод processParameter(). В нём мы используем регулярное выражение для поиска вхождений вида {wec:something}, после чего извлекаем имя параметра и заменяем каждое такое вхождение на соответствующее значение.

Немного отдохнём и посмотрим на то, что у нас на данный момент имеется

И так, теперь мы готовы написать тест. Давайте вспомним что мы подготовили к текущему моменту:

  • Странички en.html и ru.html, которые мы будем использовать в качестве целей для нашего автотеста (поместите их в один каталог)

  • Файлы en.strings и ru.strings, которые содержат локализованные строки (свойства/параметры), ссылки на которые будут присутствовать в наших локаторах (поместите их в папку resources в своём проекте).

  • Класс ParameterizedPageObject, реализующий код нашего объектного представления страницы (page object)

  • Класс ParameterizedBy, который мы используем для аннотирования полей нашего пейдж обджекта

  • Класс ParameterizedByBuilder, который будет использоваться Selenium для подготовки объекта By для аннотированного поля (с функцией параметризации)

  • Класс ParameterProvider, который просто берет нужное значение для заданного свойства из файла

Ок. Давайте уже писать тест.

Реализуем тест

Мы напишем тест, используя фреймворк TestNG. Фреймворки такого типа делают управление жизненным циклом автоматизированного теста проще, а также позволяют легко создавать параметризованные автотесты. Вот код самого теста, который мы позднее обсудим:

package click.webelement.pagefactory.localized;

import org.openqa.selenium.WebDriver;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.testng.annotations.*;

public class ParameterizedPageObjectTest {

    WebDriver driver;

    @DataProvider(name = "languages")
    Object[][] dataProvider(){
        return new Object[][]{
                {"en"},
                {"ru"}
        };
    }

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

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

    @Test(dataProvider = "languages")
    public void testPage(String language){
        System.setProperty("wec.language", language);
        driver.get("file:///PATH_TO_PAGES/" + language + ".html");
        ParameterizedPageObject ppom = new ParameterizedPageObject(driver);
        ppom.button.click();
        ppom.label.getText();
    }

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

}

Запустите тест и посмотрите на результат. Если вы точно следовали примеру, тест для английского языка пройдет успешно, но для русского языка упадёт. Так должно произойти потому что я намеренно указал некорректное значение одной из строк в странице ru.html.

Ограничения (дисклеймер)

Архитектура, показанная в примере, хорошо работает для однопоточного исполнения тестов, но не будет правильно работать в многопоточном случае. Ограничение исходит из того способа, который мы используем для задания текущего языка (мы не можем хранить разные значения одного системного свойства для разных потоков). Если вам необходимо запускать ваши тесты в параллель, вам придется переработать этот механизм. Я старался не усложнять логику в тех частях, которые слабо релевантны рассматриваемой теме.

Готовое решение

Если вы не хотите разрабатывать всё самостоятельно, ниже вы можете найти библиотеку, которую я для вас подготовил. Она потоко-безопасна и поддерживает все типы локаторов. Просто добавьте зависимость в скоуп вашего Maven-проекта:

<dependency>
  <groupId>click.webelement</groupId>
  <artifactId>parameterized-findby</artifactId>
  <version>1.0.0</version>
</dependency>

Вы также можете найти примеры использования библиотеки на странице проекта в GitHub.

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