Взаимодействуем с svg-элементами в Selenium Java

Svg - формат описания векторной графики, принятый в семью W3C в 1999 году. Графика описанная в таком формате понятна большинству современных браузеров и корректно ими отображается. Кроме того, большим преимуществом svg формата является то, что он может интегрироваться в DOM-структуру страницы на которой отображается, что делает возможным делать такую графику интерактивной. Благодаря этим особенностям формата svg, часто можно встретить сайты, где пользовательский интерфейс (либо его часть) реализована в этом формате. Для потенциального тестировщика-автоматизатора это может означать, рано или поздно, ему придется описать автоматический тест (возможно, даже, с использованием фреймворка #Selenium), для такого интерфейса. В сегодняшней статье, мы напишем такой тест, опираясь на простой пример svg-интерфейса, который я для вас подготовил.

Описание примера

Пример представляет собой модель светофора, управляемого кнопкой. При "корректном" режиме работы, цвета переключаются один за другим сверху вниз. При "некорректном" режиме, после зелёного цвета, загораются два цвета: красный и зелёный одновременно. Режимы работы переключаются соответствующим чекбоксом. Мы напишем тест, проверяющий порядок переключения цветов в обоих режимах (второй режим нужен для того чтобы продемонстрировать работу на проблемной сборке). Картинка ниже - интерактивная. Можете попробовать повзаимодействовать с ней в "ручном" режиме.

image/svg+xml PUSH
Buggy behavior:

Подготовка к разработке теста

Тест мы будем разрабатывать на языке Java. В моем примере я буду использовать браузер FireFox и операционную систему Linux. Также, кроме Selenium я планирую использовать Maven и JUnit. Если вы пока не знакомы с этими фреймворками - не беда. Концептуально они не влияют на тему статьи, а используются скорее для того чтобы минимизировать вспомогательный код, сконцентрировавшись на действительно важном. Для того, чтобы наш код получил доступ к необходимым библиотекам, убедитесь, что ваш файл pom.xml выглядит примерно так:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>webelement-click</groupId>
    <artifactId>selenium-svg-example</artifactId>
    <version>1.0-SNAPSHOT</version>

    <dependencies>
        <!-- https://mvnrepository.com/artifact/org.seleniumhq.selenium/selenium-java -->
        <dependency>
            <groupId>org.seleniumhq.selenium</groupId>
            <artifactId>selenium-java</artifactId>
            <version>3.141.59</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-api -->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>5.5.2</version>
            <scope>test</scope>
        </dependency>

    </dependencies>

</project>

После этого, скачайте, и разместите где-либо webdriver для вашей операционной системы. На момент написания статьи, официальным вебдрайвером для браузера Mozilla FireFox считается драйвер Geckodriver, доступный для скачивания по этой ссылке. Если вы используете Linux, не лишним также будет убедиться в том, что у вашего пользователя есть права на запуск исполняемого файла webdriver.

Создаём костяк теста

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

package click.webelement;

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;

public class SvgUITest {

    final static String RED = "red";
    final static String YELLOW = "yellow";
    final static String GREEN = "green";
    final static String ON = "on";
    final static String OFF = "off";

    WebDriver driver;

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

    @BeforeEach
    public void setUp(){
        driver = new FirefoxDriver();
        driver.get("http://webelement.click/ru/selenium_and_svg_example_java");
    }

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

    @Test
    public void testThatShouldPassSVGUI(){

    }

    @Test
    public void testThatShouldFailSVGUI(){

    }

}

Здесь мы разметили наш код аннотациями, сообщив JUnit, что перед тем, как начнутся все тесты, нам необходимо указать путь к используемому вебдрайверу (в вашем случае, он, скорее всего, будет другим), также перед началом каждого теста, нам необходимо создать новый объект драйвера, чтобы "сбросить" всё накопленное, что могло бы добавить "шума" в наши тесты (например cookie). Кроме того, каждый наш тест должен начинаться с обращения к веб-странице, где размещен этот пример, поэтому целесообразно вынести его в этап подготовки к каждому конкретному тесту. В последнюю очередь (очередность тут на самом деле не важна) мы сообщили JUnit о том, что в нашем тестовом классе содержится два теста. По моей задумке один тест будет проходить набор шагов при "корректном" режиме работы системы, а второй - тот же набор шагов, но уже при "некорректном". Так как набор шагов будет одинаковым, второй тест должен упасть.

Кроме всего прочего мы определили набор констант в качестве полей класса SvgUITest. Использование констант является хорошей практикой в программировании в том случае если значения, установленные для них используются (или предполагаются использоваться) в вашем коде более одного раза. Преимущество такого подхода очевидно: во-первых в случае потребности изменить значение, вам придется изменить его только в одном месте - в месте определения константы; во-вторых снижается риск опечаток и человеческого фактора, т.к. современные средства разработки помогают пользователям писать правильный код (т.н. ассистенты). Да и этап компиляции кода позволяет отловить ошибки, которые не отловила IDE.

Создаём тест

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

  • На нулевом шаге проверить, что красный фонарь - первый, желтый второй, а зелёный - третий.

  • На первом шаге проверять, что горит красный. Затем нажимать на кнопку.

  • На втором шаге проверять, что горит жёлтый. Затем нажимать на кнопку.

  • На третьем шаге проверять, что горит зелёный. Затем нажимать на кнопку.

  • На четвёртом шаге проверять, что опять горит красный.

  • На каждом шаге проверять, что не горят два остальных цвета

Перед началом описания теста в коде, необходимо определиться что означает горят/не горят и как это определить. Если воспользоваться инструментами разработчика веб браузера, можно увидеть, что во-первых у каждого фонаря есть свой id. Кроме того при у каждого фонаря есть свой стиль, который состоит из определения цвета и определения состояния включен/выключен. Например красный включенный фонарь будет иметь атрибут class="red on", а зелёный выключенный - атрибут class="green off". Ниже представлен код метода, который мы позже обсудим.

    private void takeTestSteps(){
        for(int i = 1; i <= 4; i++){

            List<WebElement> lights = driver.findElements(By.xpath("//*[name()='circle' and contains(@id, '-light')]"));
            Assertions.assertEquals(3, lights.size(), "Expecting three lights available");

            List<String> assumedRedClasses = Arrays.asList(lights.get(0).getAttribute("class").split(" "));
            List<String> assumedYellowClasses = Arrays.asList(lights.get(1).getAttribute("class").split(" "));
            List<String> assumedGreenClasses = Arrays.asList(lights.get(2).getAttribute("class").split(" "));

            Assertions.assertTrue(assumedRedClasses.size() == 2
                    && assumedYellowClasses.size() == 2
                    && assumedGreenClasses.size() == 2,
                    "Only color and status are expected in class attribute");
            Assertions.assertTrue(assumedRedClasses.contains(RED), "First light is expected to be red");
            Assertions.assertTrue(assumedYellowClasses.contains(YELLOW), "Second light is expected to be yellow");
            Assertions.assertTrue(assumedGreenClasses.contains(GREEN), "Third light is expected to be green");

            switch (i % 3){
                case 1:
                    Assertions.assertTrue(assumedRedClasses.contains(ON), "Expecting red light on");
                    Assertions.assertTrue(assumedYellowClasses.contains(OFF), "Expecting yellow light off");
                    Assertions.assertTrue(assumedGreenClasses.contains(OFF), "Expecting green light off");
                    break;
                case 2:
                    Assertions.assertTrue(assumedRedClasses.contains(OFF), "Expecting red light off");
                    Assertions.assertTrue(assumedYellowClasses.contains(ON), "Expecting yellow light on");
                    Assertions.assertTrue(assumedGreenClasses.contains(OFF), "Expecting green light off");
                    break;
                case 3:
                    Assertions.assertTrue(assumedRedClasses.contains(OFF), "Expecting red light off");
                    Assertions.assertTrue(assumedYellowClasses.contains(OFF), "Expecting yellow light off");
                    Assertions.assertTrue(assumedGreenClasses.contains(ON), "Expecting green light on");
                    break;
            }

            WebElement switchButton = driver.findElement(By.xpath("//*[@id='switch-button']"));
            ((JavascriptExecutor)driver).executeScript("arguments[0].scrollIntoView(true)", switchButton);

            new Actions(driver).moveToElement(switchButton, 0, -40).click().build().perform();
        }
    }

Что происходит в этом методе? Как такового Selenium здесь немного. Как, впрочем, в типичном автоматическом тесте с использованием этого фреймворка. Большая часть кода описывает логику проверок, разбор информации на компоненты и т.д. Я отделил части кода ответственные за разное пустыми строками. Первое что надо отметить - это то, что метод, фактически состоит из цикла. Четыре шага нужно для того чтобы проверить "зацикливание" процесса, когда зелёный вновь переключается на красный.

На каждом шаге цикла мы начинаем с того, что выбираем три фонаря светофора используя xPath //*[name()='circle' and contains(@id, '-light')]. Так как элементы svg относятся к другому (нежели элементы html) пространству имен, мы не можем воспользоваться конструкцией //circle... Полученную выборку мы будем использовать для определения корректной последовательности цветов. А пока, в строке Assertions.assertEquals(3, lights.size(), "Expecting three lights available"); мы проверяем, что фонарей у светофора три (не больше и не меньше). Кстати, методы Assertions.* - становятся доступны нам только после подключения библиотек JUnit.

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

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

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

Инструкция switch вычисляет выражение i % 3. Эта операция деления по модулю. Она нужна для того, чтобы 4-й шаг цикла превратился бы в первый шаг для switch (т.к. 4 % 3 = 1). Вы, возможно, заметили, что цикл начинается с i = 1, что не является совсем типичным случаем. Это сделано для корректного вычисления выражения в свитче.

На каждом шаге switch проверяет релевантность состояния цветов шагу. Это делается проверкой включения класса on или off в список классов элемента. Логика всего теста подразумевает, что у элемента не может быть одновременно "включенного" и "выключенного" состояния. Это подразумевается засчет того, что сначала мы проверяем что элементу присвоен класс цвета, затем проверяем, что всего класса два, и наконец - что элементу присвоен класс включения/выключения.

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

После того, как мы подготовили общие шаги и добавили соответствующий метод (в нашем случае - private void takeTestSteps()) в тело класса SvgUITest, нам осталось добавить вызов этого метода в методы, аннотированные нами как тесты. В тест, который должен с демонстрационными целями свалиться, мы также должны добавить инструкцию переключения режима "правильности" работы модели. Тестовые методы после всего этого будут выглядеть так:

    @Test
    public void testThatShouldPassSVGUI(){
        takeTestSteps();
    }

    @Test
    public void testThatShouldFailSVGUI(){
        driver.findElement(By.id("buggy-chkbx")).click();
        takeTestSteps();
    }

Запустив такой код, вы увидите, что мы достигли поставленной цели. Один тест прошёл успешно, а второй упал. Именно там, где мы проверяем релевантность соответствия цветов номеру шага.

selenium svg result

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