Используем PicoContainer, реализуя архитектуру Page Object в Selenium WebDriver и Cucumber Java 8 (лямбда-выражения)

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

Типичной проблемой (не обязательно относящейся к Cucumber) при написании автоматизированных тестов является то, как мы "поставляем" объект WebDriver в каждый конкретный автоматизированный тест из нашего набора. Специфика Cucumber здесь заключается в том, что не смотря на поддержку JUnit и TestNG, Cucumber всё же не рекомендует использовать их для формирования и последующей очистки состояния, необходимого для функционирования теста.

Другим специфичным для Cucumber моментом является то, что он контролирует исполнение сценария при том, что методы, определяющие шаги могут быть распределены по разным классам. Таким образом не существует очевидного общего места, через которое мы могли бы передавать объект WebDriver между шагами. Равно как и не существует очевидного способа, поддерживать неизменный объект WebDriver от начала сценария и до его конца с пересозданием объекта для каждого следующего теста.

Основной концепцией, поддерживаемой Cucumber для решения упомянутых проблем является концепция, называемая Dependency Injection. Эта концепция позволяет скрытым от пользователя (в данном случае под пользователем я подразумеваю разработчика логики шагов) образом связывать поля некоторого объекта с другими объектами используя некоторую заданную логику. Логика эта задается при помощи специальных фреймвроков. Наверняка вы слышали про Spring или Guice. Cucumber поддерживает их обоих, но рекомендует пользоваться фреймворком под названием PicoContainer.

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

В этой статье мы увидим как действовать в подобной ситуации. Мы рассмотрим пример в котором реализуем параметризованный Cucumber-сценарий, использующий cucumber-java8 библиотеку. Еще одним акцентом этого примера станет реализация архитектурного паттерна под названием Page Object Model при помощи PicoContainer.

Давайте теперь взглянем на всё ближе.

Описание примера и версии зависимостей

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

Фича-файлы

Ниже показан текст фича-файла, описывающий сценарий теста, который мы планируем реализовать в нашем классе:

Feature: Stare

Scenario Outline: Stare at the page
  Given Open page https://webelement.click/en/welcome
  Then Go to All Posts
  And Stare for <time> seconds

Examples:
| time           |
| 5              |
| 10             |

У нас также будет еще один сценарий. Его мы запускать не планируем, однако, он потребуется нам для демонстрации некоторых особенностей работы PicoContainer с фреймворком Cucumber. Мы поговорим об этих особенностях позже, а пока давайте посмотрим на этот "бесполезный" сценарий:

Feature: Useless

  Scenario: Useless scenario
    Given Whatever

Объектные представления страниц (Page Objects)

Такой же подход мы применим и для объектных представлений наших страниц. Вот наш "полезный" пейдж обджект:

package click.webelement.cucumber.pages;

import click.webelement.cucumber.selenium.LazyWebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.PageFactory;

public class HomePage {

    @FindBy(linkText = "All Posts")
    WebElement allPosts;

    public HomePage(LazyWebDriver driver){
        System.out.println("Instantiating HomePage object.");
        PageFactory.initElements(driver, this);
    }

    public void goToAllPosts(){
        System.out.println("Clicking All Posts..");
        allPosts.click();
        System.out.println("Done.");
    }

}

А вот "бесполезный" пейдж обджект:

package click.webelement.cucumber.pages;

public class UselessHomePage {

    public UselessHomePage(){
        System.out.println("Instantiating useless home page..");
    }

    public String whatever(){
        return "Whatever..";
    }

}

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

Вы, возможно, обратили внимание на использование типа LazyWebDriver в нашем "полезном" пейдж обджекте. Это наш кастомный класс, о котором мы поговорим чуть позже. Пока отмечу, что такой подход, позволяет делать Cucumber тесты эффективными при использовании PicoContainer.

Maven-зависимости

Для запуска примеров из статьи, нам понадобятся всего лишь три зависимости (пропишите их в pom.xml файле вашего проекта):

<dependency>
    <groupId>org.seleniumhq.selenium</groupId>
    <artifactId>selenium-java</artifactId>
    <version>3.141.59</version>
</dependency>
<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-java8</artifactId>
    <version>6.1.1</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-picocontainer</artifactId>
    <version>6.1.1</version>
    <scope>test</scope>
</dependency>

В своём примере я также планирую использовать драйвер GeckoDriver и браузер FireFox (хотя на самом деле, это не влияет на репрезентативность примера - вы можете использовать то, что вам нравится).

Реализуем логику шагов

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

Ниже показан код, реализующий логику шагов нашего "полезного" сценария. Давайте взглянем на него и обсудим важные моменты.

package click.webelement.cucumber.steps.definitions;

import click.webelement.cucumber.selenium.LazyWebDriver;
import click.webelement.cucumber.pages.HomePage;
import io.cucumber.java8.En;
import org.picocontainer.annotations.Inject;

public class DemonstrateInjectionPrimary implements En {

    @Inject HomePage homePage;
    @Inject LazyWebDriver driver;

    public DemonstrateInjectionPrimary(){
        Given("Open page {}", (String url) -> {
            System.out.println("Opening url: " + url);
            driver.get(url);
        });
        Then("Go to All Posts", ()->{
           homePage.goToAllPosts();
        });
        And("Stare for {int} seconds", (Integer seconds) -> {
            System.out.println("Staring at the page for "
                                         + seconds.toString()
                                         + " seconds..");
            Thread.sleep(seconds * 1000);
        });
    }

}

В отличие от классического синтаксиса Cucumber где мы бы инджектили состояние через параметры конструктора, здесь мы вынуждены использовать конструктор без параметров. К счастью, PicoContainer поддерживает прямые инъекции полей. Так как наши шаги будут взаимодействовать с объектами типов WebDriver и HomePage, мы помечаем данные поля при помощи аннотации org.picocontainer.annotations.Inject.

Как мы увидим чуть позже, класс LazyWebDriver имплементирует интерфейс WebDriver, так что код наших шагов, будет работать с объектом driver как с обычным драйвером.

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

package click.webelement.cucumber.steps.definitions;

import click.webelement.cucumber.pages.UselessHomePage;
import io.cucumber.java8.En;
import org.picocontainer.annotations.Inject;


public class DemonstrateInjectionSecondary implements En {

    @Inject
    UselessHomePage uselessHomePage;

    public DemonstrateInjectionSecondary(){
        Given("Whatever", () -> {
            System.out.println(uselessHomePage.whatever());
        });
    }
}

Заметьте, что код "полезного" и "бесполезного" сценариев не пересекается. Код "полезного" сценария не использует состояние "бесполезного" и наоборот. Это будет иметь значение позже.

Сообщаем Cucumber и PicoContainer как инициализировать поля в наших классах

Для поддержки механизма Dependency Injection в фреймворке Cucumber предоставляется сущность, именуемая ObjectFactory (интерфейс). В начале каждого сценария Cucumber вызывает метод start() у такой фабрики, а в конце каждого сценария - метод stop(). Эти методы реализуются таким образом, чтобы выбранный вами DI-фреймворк знал как именно ему производить инъекции в поля и как ему освобождать ресурсы, если возникнет такая необходимость.

Реализуем кастомную фабрику объектов

Самый простой путь интеграции вашего проекта с PicoContainer - создание фабрики, делегирующей всю функциональность уже существующей фабрике PicoFactory, но с небольшим изменением:

package click.webelement.cucumber.steps.factory;

import click.webelement.cucumber.pages.HomePage;
import click.webelement.cucumber.pages.UselessHomePage;
import click.webelement.cucumber.selenium.LazyWebDriver;
import io.cucumber.core.backend.ObjectFactory;
import io.cucumber.picocontainer.PicoFactory;

public class CustomFactory implements ObjectFactory {

    private PicoFactory delegate = new PicoFactory();

    public CustomFactory(){
        addClass(LazyWebDriver.class);
        addClass(HomePage.class);
        addClass(UselessHomePage.class);
    }

    @Override
    public void start() {
        delegate.start();
    }

    @Override
    public void stop() {
        delegate.stop();
    }

    @Override
    public boolean addClass(Class<?> clazz) {
        return delegate.addClass(clazz);
    }

    @Override
    public <T> T getInstance(Class<T> type) {
        return delegate.getInstance(type);
    }
}

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

Регистрируем нашу кастомную фабрику в Cucumber

Cucumber спроектирован таким образом, что фабрики объектов регистрируются, используя механизм SPI. Для того, чтобы зарегистрировать фабрику, вам будет необходимо пройти ряд конфигурационных шагов:

  1. В папке resources создайте каталог META-INF/services

  2. В каталоге services создайте файл с именем io.cucumber.core.backend.ObjectFactory

  3. Откройте этот файл и добавьте туда строчку click.webelement.cucumber.steps.factory.CustomFactory (поменяйте при необходимости на то, что соответствует вашему коду)

  4. В каталог resources добавьте файл cucumber.properties (если его там ещё нет)

  5. Добавьте в проперти-файл следующее свойство: cucumber.object-factory=click.webelement.cucumber.steps.factory.CustomFactory (поменяйте при необходимости на то, что соответствует вашему коду)

Итак, теперь всё готово к запуску. Всё, кроме одной вещи. Мы всё еще не имеем понятия для чего в примере понадобился "бесполезный" сценарий, а также, что за класс такой - LazyWebDriver.

Думайте об эффективности, когда используете PicoContainer

Когда PicoContainer стартует, он пытается инстанциировать все классы, о которых ему известно, при условии связи этих классов с любым из сценариев в вашем classpath, даже с теми, которые в данный момент не запускаются. Это может стать проблемой, когда процесс создания объекта потребляет много ресурсов. Создание объекта WebDriver идеально подходит под описанную ситуацию (т.к. происходит запуск самого процесса вебдрайвера, связывания клиентского объекта с сервером, открытие браузера и т.д.).

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

Реализация LazyWebDriver наглядно демонстрирует решение проблемы эффективности при помощи т.н. "ленивой" инициализации. Давайте взглянем на реализацию класса и обсудим важные моменты.

package click.webelement.cucumber.selenium;

import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.picocontainer.Disposable;

import java.util.List;
import java.util.Set;

public class LazyWebDriver implements WebDriver, Disposable {

    private WebDriver delegate = null;

    private WebDriver getDelegate() {
        if (delegate == null) {
            System.setProperty("webdriver.gecko.driver", "/path_to_webdriver/geckodriver");
            delegate = new FirefoxDriver();
        }
        return delegate;
    }

    @Override
    public void get(String url) {
        getDelegate().get(url);
    }

    @Override
    public String getCurrentUrl() {
        return getDelegate().getCurrentUrl();
    }

    @Override
    public String getTitle() {
        return getDelegate().getTitle();
    }

    @Override
    public List<WebElement> findElements(By by) {
        return getDelegate().findElements(by);
    }

    @Override
    public WebElement findElement(By by) {
        return getDelegate().findElement(by);
    }

    @Override
    public String getPageSource() {
        return getDelegate().getPageSource();
    }

    @Override
    public void close() {
        getDelegate().close();
    }

    @Override
    public void quit() {
        getDelegate().quit();
    }

    @Override
    public Set<String> getWindowHandles() {
        return getDelegate().getWindowHandles();
    }

    @Override
    public String getWindowHandle() {
        return getDelegate().getWindowHandle();
    }

    @Override
    public TargetLocator switchTo() {
        return getDelegate().switchTo();
    }

    @Override
    public Navigation navigate() {
        return getDelegate().navigate();
    }

    @Override
    public Options manage() {
        return getDelegate().manage();
    }

    @Override
    public void dispose() {
        System.out.println("Killing WebDriver");
        if(delegate != null){
            delegate.quit();
        }
    }
}

Довольно много кода.. К счастью, все современные среды разработки позволяют генерировать код, делегирующий функции другому классу. Вам не придется писать всё вручную. Важно то, что когда PicoContainer встречает поле типа LazyWebDriver, он инстанциирует его, используя конструктор по умолчанию. В этой точке на самом не происходит фактически ничего, кроме выделения памяти под создаваемый объект. Вся "тяжёлая" логика запускается только тогда, когда происходит первое обращение к методу драйвера. Когда ваш код выполняет driver.get(…​), запускается вот эта часть:

private WebDriver getDelegate() {
    if (delegate == null) {
        System.setProperty("webdriver.gecko.driver", "/path_to_webdriver/geckodriver");
        delegate = new FirefoxDriver();
    }
    return delegate;
}

В этот момент создается "настоящий" драйвер. Вы можете добавить свою логику определения какой драйвер создавать (Chrome, Opera, IE, и т.д.). Также, вам будет необходимо изменить путь к исполняемому файлу вашего драйвера. Все последующие вызовы, обращенные к LazyWebDriver будут перенаправляться к уже созданному объекту "настоящего" драйвера.

Еще одной немаловажной вещью, которую необходимо иметь в виду, является то, что такие классы должны имплементировать интерфейс org.picocontainer.Disposable. Метод этого интерфейса используется когда PicoContainer завершает свою работу. Это хорошее место, для того чтобы высвободить ресурсы, занятые вашим драйвером:

@Override
public void dispose() {
    System.out.println("Killing WebDriver");
    if(delegate != null){
        delegate.quit();
    }
}

Цепочка действий будет выглядеть так: когда сценарий завершится, Cucumber вызовет метод stop() у нашей CustomFactory. Она делегирует вызов PicoFactory, которая, в свою очередь, вызовет методы stop() и dispose() у контейнера. Контейнер пройдется по всем созданным объектам, контракт которых соответствует интерфейсу Disposable и вызовет у них метод dispose.

Запускаем всё наконец

Итак, на данный момент у вас должны быть два файла сценария: "полезный" и "бесполезный". Давайте запустим "полезный" сценарий и посмотрим на вывод в консоль. Он должен выглядеть так:

Instantiating HomePage object.
Instantiating useless home page..
Opening url: https://webelement.click/en/welcome
Clicking All Posts..
Done.
Staring at the page for 5 seconds..
Killing WebDriver
Instantiating HomePage object.
Instantiating useless home page..
Opening url: https://webelement.click/en/welcome
Clicking All Posts..
Done.
Staring at the page for 10 seconds..
Killing WebDriver

2 Scenarios (2 passed)
6 Steps (6 passed)
0m34.620s

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

Суммируя вышеизложенное, отмечу, что мы изучили основную концепцию, лежащую в основе создания и уничтожения объектов, а также обмена объектами внутри сценария в Cucumber при помощи PicoContainer на примере реализации теста на Selenium с использованием паттерна проектирования Page Object. Если у вас остались вопросы, задавайте их тут. Я постараюсь дополнить статью опираясь на ваши замечания.