Логируем каждый шаг в Selenium текстом и скриншотом (Java).

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

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

Описание примера: логируем текст и делаем скриншот для каждого шага в Selenium

В статье мы смоделируем легаси-фреймворк для тестирования пользовательского интерфейса с использованием Selenium. Модель будет состоять из одного единственного класса, но эта модель легко масштабируется на сколь угодно сложные и сколь угодно большие тестовые фреймворки. Такие фреймворки обычно имеют некоторый единый механизм, создающий объект вебдрайвера, используемый далее повсеместно. Наша задача состоит в том, чтобы добавить в такой фреймворк логирование необходимых событий, не затрагивая сам тестовый код. Итак, вот наша модель:

package click.webelement.logging;

import org.junit.jupiter.api.*;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.firefox.FirefoxDriver;

public class LegacyTest {

    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
    @DisplayName("WebElement.Click demo legacy tests")
    public void testLogging(){
        driver.get("https://webelement.click/en/welcome");
        driver.findElement(By.xpath("//a[text()='About Me']")).click();
    }

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


}

Для демонстрации работы принципа, мы выберем следующие действия, к которым привяжем наши лог-сообщения: driver.get, driver.findElement и WebElement.click. Реальные возможности такого подхода гораздо шире, но для краткости, мы рассмотрим только три вышеупомянутых действия. Мы добавим текстовое логирование к методам, относящимся к вебдрайверу, и снимки экрана для методов вебэлементов. Например, мы будем делать снимок экрана до и снимок экрана после каждого клика по вебэлементу.

EventFiringWebDriver как стандарт подхода к логированию в Selenium Java

Клиентская Java-библиотека Selenium предоставляет класс EventFiringWebDriver, являющийся "обёрткой" для обычного вебдрайвера и действующего как декоратор. Для некоторых методов, он оборачивает вызовы некоторой логикой, исполняющейся до вызова, и некоторой логикой, исполняющейся после. Такой подход реализуется при помощи т.н. листенеров. Также этот класс декорирует объекты WebElement, возвращаемые методами findElement и findElements, при условии их вызова через "обёртку". Элементы и вебдрайвер используют один и тот же листенер, который, в свою очередь, должен реализовывать интерфейс WebDriverEventListener.

Пока что всё выглядит довольно запутанно. Чуть позже мы взглянем на это более пристально и всё прояснится.

И так, пока что мы можем сказать, что для того, чтобы достичь нашей цели, мы должны:

  1. Каким-то образом реализовать листенер, который бы содержал код вызываемый до и после некоторых методов вебдрайвера и вебэлементов. Код бы логировал текстовые сообщения и делал бы снимки экрана.

  2. Обернуть существующий WebDriver в EventFiringWebDriver и зарегистрировать в нем наш листернер (наши листенеры)

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

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

Список поддерживаемых действий можно почерпнуть из списка методов, определяемых интерфейсом WebDriverEventListener. Интерфейс определяет 13 пар методов "before"/"after" как для действий, относящихся к WebDriver, так и для действий, относящихся к WebElement, плюс один метод, определяющий реакцию на возникающие ексепшены.

Реализуем WebDriverEventListener с нашими кастомными обработчиками

Давайте вспомним, что конкретно мы бы хотели логировать в нашем легаси-фрейморке.

  • Логировать навигацию к странице, выполняемую с использованием либо метода driver.navigate() либо метода driver.get(). В таком случае мы будем создавать текстовую запись и делать снимок экрана после того как навигация завершилась.

  • Логировать попытки отыскать элемент через driver.findElement. В таком случае мы будем создавать текстовую запись и делать снимок экрана до того как поиск элемента начинается.

  • Логировать попытки кликнуть на элемент. В таком случае мы будем создавать текстовую запись и делать скриншот до и после того как клик будет исполнен.

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

void afterNavigateTo(String url, WebDriver driver);
void beforeFindBy(By by, WebElement element, WebDriver driver);
void beforeClickOn(WebElement element, WebDriver driver);
void afterClickOn(WebElement element, WebDriver driver);

Проблема в том, что мы не можем реализовать только какую-то часть методов, определяемых интерфейсом, оставив другую часть нереализованной. Java просто не работает так. К счастью, разработчики Selenium подготовили имплементацию интерфейса WebDriverEventListener "по-умолчанию". Такая реализация хранится в абстрактном классе AbstractWebDriverEventListener, реализация методов в котором не содержит полезного кода (тело каждого метода состоит только из пустой строки). Также, из-за того, что этот класс абстрактный, мы не можем создавать его экземпляры напрямую (опять же из-за того что Java так работает).

Подход, который для нас подготовили разработчики Selenium заключается в том, что мы создаем расширение существующего абстрактного класса, в котором мы перегружаем только те методы, которые нас интересуют. Это помогает экономить и время и объем кода. Итак, давайте добавим следующий класс в наш проект:

package click.webelement.logging;

import org.openqa.selenium.*;
import org.openqa.selenium.support.events.AbstractWebDriverEventListener;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.util.UUID;

public class CustomLoggingListener extends AbstractWebDriverEventListener {

    private static final String SCREENSHOT_LOCATION = "/home/alexey/Desktop/Dev/testing/screenshots";

    @Override
    public void afterNavigateTo(String url, WebDriver driver) {
        String messageId = UUID.randomUUID().toString();
        System.out.println(messageId + " : Navigating to [" + url + "] with driver [" + driver + "]");
        takeScreenShot(messageId, driver);
    }

    @Override
    public void beforeFindBy(By by, WebElement element, WebDriver driver) {
        String messageId = UUID.randomUUID().toString();
        System.out.println(messageId + " : Try to locate element using [" + by + "] and driver [" + driver + "] and element [" + element + "]");
        takeScreenShot(messageId, driver);
    }

    @Override
    public void beforeClickOn(WebElement element, WebDriver driver) {
        String messageId = UUID.randomUUID().toString();
        System.out.println(messageId + " : Clicking element [" + element + "] with driver [" + driver + "]");
        takeScreenShot(messageId, driver);
    }

    @Override
    public void afterClickOn(WebElement element, WebDriver driver) {
        String messageId = UUID.randomUUID().toString();
        System.out.println(messageId + " : Clicked element [" + element + "] with driver [" + driver + "]");
        takeScreenShot(messageId, driver);
    }

    private void takeScreenShot(String name, WebDriver driver){
        File src = ((TakesScreenshot)driver).getScreenshotAs(OutputType.FILE);
        try {
            FileChannel srcChannel = new FileInputStream(src).getChannel();
            File dst = new File(SCREENSHOT_LOCATION, name + ".png");
            FileChannel dstChannel = new FileOutputStream(dst).getChannel();
            dstChannel.transferFrom(srcChannel, 0, srcChannel.size());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

}

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

Еще одна вещь, на которую следует обратить внимание - это метод takeScreenShot. Он принимает имя файла в качестве параметра (заметьте, что расширение указывать не требуется), добавляет к нему .png и делает снимок экрана (в том случае, если ваш WebDriver поддерживает такую функциональность), сохраняя его в указанный файл. Более в методе нет каких-либо достойных внимания вещей. Всё достаточно стандартно.

Далее двигаемся к "перегруженным" методам (те, что сопровождаются аннотацией @Override). Прежде всего я логирую текстовые сообщения при помощи стандартного вывода (System.out.println()), что выглядит не очень красиво, но что делает пример проще. Следующее, что стоит отметить - это ограничения на используемые символы при создании файлов. По этой причине я не включаю никакие части свойств самого вебдрайвера или вебэлементов в имена файлов, создаваемых для хранения снимков экрана. Вместо этого, я генерирую случайную строку для каждого лог-сообщения. Используя такой "ключ", сопоставить созданный скриншот с произошедшим в тесте событием не составляет никакого труда.

Интегрируем созданные листенеры в наши легаси-тесты

Нам остается добавить последний штрих. А именно подменить существующую ссылку на вебдрайвер, используемую в тестах, на нашу EventFiringWebDriver обёртку. Это очень просто. Так как EventFiringWebDriver реализует интерфейс WebDriver мы можем просто переприсвоить новое значение существующему полю. В нашем примере, нам просто потребуется внести незначительные изменения в метод setUp():

Вместо этого:

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

У нас в итоге будет вот это:

@BeforeEach
public void setUp(){
    driver = new FirefoxDriver();
    EventFiringWebDriver eventFiringWebDriver = new EventFiringWebDriver(driver);
    eventFiringWebDriver.register(new CustomLoggingListener());
    driver = eventFiringWebDriver;
}

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

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