Используем 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-сценарий, использующий cucumber-java8 библиотеку. Еще одним акцентом нашего примера станет реализация архитектурного паттерна под названием Page Object Model при помощи PicoContainer. В конце мы взглянем на недостаток использования PicoContainer с фреймворком Cucumber.

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

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

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

Фича-файлы

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

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.pages.HomePage;
import click.webelement.cucumber.selenium.LazyWebDriver;
import io.cucumber.java8.En;

public class DemonstrateInjectionPrimary implements En {

    HomePage homePage;
    LazyWebDriver driver;

    public DemonstrateInjectionPrimary(HomePage homePage, LazyWebDriver driver){
        this.homePage = homePage;
        this.driver = driver;
        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);
        });
    }

}

Так как наши шаги будут взаимодействовать с объектами типов WebDriver и HomePage, мы должны дать знать PicoContainer о том, что необходимо произвести их "инъекцию" через конструктор.

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

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

package click.webelement.cucumber.steps.definitions;

import click.webelement.cucumber.pages.UselessHomePage;
import io.cucumber.java8.En;


public class DemonstrateInjectionSecondary implements En {

    UselessHomePage uselessHomePage;

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

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

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

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

В отличие от некоторых других DI фреймворков, Cucumber интегрируется с PicoContainer "из коробки". Вам необходимо только добавить соответствующую зависимость в pom.xml и фреймворк сам будет искать нужные классы в класспасе. Классы будут инстанциироваться через дефолтный конструктор. В случае отсутствия такоговго, будут искаться классы параметров существующего конструктора и инстанциироваться через их дефолтный конструктор. И т.д.

Думайте об эффективности, когда используете 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() у ObjectFactory. Она делегирует вызов в низлежащую имплментацию, которая, в свою очередь, вызовет методы 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 успешно воспроизвел шаги сценария, логи также показывают, что объекты, не связанные с запущенным сценарием, также были созданы.

Недостаток

Вы, возможно (особенно если имели практический опыт с DI-концепцией), заметили, что в нашем примере отсутствуют инъекции вида:

MyInterface field = new MyInterfaceImpl();

Так произошло потому, что интеграция PicoContainer с Cucumber не позволяет организовать такой маппинг просто. В моей следующей статье про #Cucumber я покажу другой пример, где реализую паттерн Page Object с DI-фреймворком Guice.

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

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