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

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

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

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

Алгоритм

Я приверженец простых решений. Как сказал один мой друг: «Сложно делать простые вещи». По моему мнению, проект для написания тестов или фреймворк, как его ни назови, является также полноценным программным продуктом, у которого есть пользователи — тестировщики, которые пишут в нём тесты. И в каждом программном продукте необходимо заботиться об удобстве использования. Поэтому идеей было сделать простой и удобный функционал.

Алгоритм:

Поверхностно выглядит просто. Да и на деле так же просто работает.

  • После создания скриншота проверяется, есть ли уже в проекте эталонный скриншот.

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

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

Как реализован функционал

Для разработки функционала использовалась библиотека aShot от Yandex. Сам проект написан на Java, в нём пишутся как функциональные E2E тесты, так и скриншот-тесты.

Шаг 1

Для начала необходимо получить путь теста, по которому будут сохраняться скриншоты.

В проекте используется фреймворк TestNG, но нет сложности сделать то же самое на JUnit, используя соответствующий лисенер фреймворка. В данном случае путь теста получаем в лисенере IInvokedMethodListener. Выглядит это так:

public class TestMethodCapture implements IInvokedMethodListener {

  @Override
  public void beforeInvocation(IInvokedMethod method, ITestResult testResult) {
      TestsConfig.TEST_NAME.set(method.getTestMethod().getMethodName());
      TestsConfig.TEST_PATH.set(method.getTestMethod().getTestClass().getName());
  }
}

Здесь TEST_NAME и TEST_PATH это глобальные переменные ThreadLocal<String>, так как тесты запускаются параллельно.

Далее создаем класс Screenshoter. В конструкторе класса создаём каталоги и инициализируем файлы для скриншотов:

public Screenshoter() {
  this(TestsConfig.TEST_NAME.get());
}

public Screenshoter(String screenshotName) {
    String png = ".png";
    String openingBracket = " [";
    String closingBracket = "]";

    testName = screenshotName;
    testPath = TestsConfig.TEST_PATH.get().replace(DOT, SLASH) + SLASH;
    createScreenshotsFolders(testPath);
    String fullPath = testPath + testName
             + openingBracket + TestsConfig.BROWSER + closingBracket 
             + openingBracket + TestsConfig.BROWSER_MODE.get() + closingBracket;
    actualFile = new File(TestsConfig.SCREENSHOTS_ACTUAL_FOLDER + fullPath + png);
    expectedFile = new File(TestsConfig.SCREENSHOTS_EXPECTED_FOLDER + fullPath + png);
    diffFile = new File(TestsConfig.SCREENSHOTS_DIFF_FOLDER + fullPath + png);
    windowDpr = getWindowDpr();
}

Здесь SCREENSHOTS_ACTUAL_FOLDER, SCREENSHOTS_EXPECTED_FOLDER и SCREENSHOTS_DIFF_FOLDER это рут каталоги, куда надобно сохранять скриншоты. Они указываются в настройках проекта. 

SCREENSHOTS_EXPECTED_FOLDER должен быть в ресурсах проекта, так как это эталонные скриншоты, которые должны добавляться в git index и храниться в проекте. Актуальные скриншоты и изображения разницы нет необходимости хранить в проекте, они должны быть временными, поэтому лучше их хранить в каталоге build.

В настройках проекта это выглядит вот так:

screenshotsExpectedFolder=src/test/resources/screenshots/
screenshotsActualFolder=build/screenshots/actual/
screenshotsDiffFolder=build/screenshots/diff/

Реализация метода createScreenshotsFolders() выглядит следующим образом:

private void createScreenshotsFolders(String testPath) {
    createFolder(Paths.get(TestsConfig.SCREENSHOTS_EXPECTED_FOLDER + testPath));
    createFolder(Paths.get(TestsConfig.SCREENSHOTS_ACTUAL_FOLDER + testPath));
    createFolder(Paths.get(TestsConfig.SCREENSHOTS_DIFF_FOLDER + testPath));
}

private void createFolder(Path path) {
    try {
        if (!Files.exists(path)) {
                Files.createDirectories(path);
        }
    } catch (IOException e) {
            e.printStackTrace();
    }
}

getWindowDpr() — это получение масштаба пикселей экрана, необходимый при создании скриншота:

private float getWindowDpr() {
    Object output = Selenide.executeJavaScript("return window.devicePixelRatio");
    return Float.parseFloat(String.valueOf(output));
}

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

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

Также в название скриншота (переменная fullPath) добавляется метаинформация. В данном случае это браузер и режим браузера(desktop, mobile). Но это является уже надобностью данного проекта (можно настроить под себя).

Шаг 2

Далее нам нужны методы по снятию скриншота. В aShot нужно задавать стратегию снятия скриншота. Было выделено 4 стратегии, которые будут использоваться в тестовых шагах. А именно: 

  • скриншот страницы целиком со скролом;

  • скриншот видимой области страницы;

  • скриншот элемента со скролом;

  • скриншот элемента без скрола.

Выглядят они следующим образом:

    public void makePageScreenshotWithScroll() {
        actualScreenshot = new AShot()
                .shootingStrategy(viewportPasting(scaling(windowDpr), 200))
                .takeScreenshot(getWebDriver());
        saveExpectedScreenshotToFile();
        saveScreenshotToFile(actualScreenshot.getImage(), actualFile);
    }

    public void makePageScreenshotWithoutScroll() {
        actualScreenshot = new AShot()
                .shootingStrategy(scaling(windowDpr))
                .takeScreenshot(getWebDriver());
        saveExpectedScreenshotToFile();
        saveScreenshotToFile(actualScreenshot.getImage(), actualFile);
    }

    public void makeElementScreenshotWithScroll(By locator) {
        actualScreenshot = new AShot()
                .shootingStrategy(viewportPasting(scaling(windowDpr), 200))
                .coordsProvider(new WebDriverCoordsProvider())
                .takeScreenshot(getWebDriver(), $(locator));
        saveExpectedScreenshotToFile();
        saveScreenshotToFile(actualScreenshot.getImage(), actualFile);
    }

    public void makeElementScreenshotWithoutScroll(By locator) {
        actualScreenshot = new AShot()
                .shootingStrategy(scaling(windowDpr))
                .coordsProvider(new WebDriverCoordsProvider())
                .takeScreenshot(getWebDriver(), $(locator));
        saveExpectedScreenshotToFile();
        saveScreenshotToFile(actualScreenshot.getImage(), actualFile);
    }

Метод saveExpectedScreenshotToFile() сохраняет эталонный скриншот, если его ещё нет в проекте, либо забирает его из файла, если он есть. Метод выглядит следующим образом:

private void saveExpectedScreenshotToFile() {
    if (!expectedFile.exists()) {
        expectedScreenshot = actualScreenshot;
        saveScreenshotToFile(actualScreenshot.getImage(), expectedFile);
        Assert.fail(String.format("""
                          Создался эталонный скриншот по пути: %s%s.
                          Необходимо его сверить и добавить в git index.""",
                                  testPath, testName));
    } else {
        try {
            expectedScreenshot = new Screenshot(ImageIO.read(expectedFile));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Метод saveScreenshotToFile() сохраняет скриншот по указанному пути:

  private void saveScreenshotToFile(RenderedImage screenshot, File targetFile) {
      try {
          ImageIO.write(screenshot, "png", targetFile);
      } catch (Exception e) {
          e.printStackTrace();
      }
  }

С вызова этих методов начинается алгоритм.

  • Сначала делаем скриншот по выбранной стратегии и запускается проверка вида «Есть ли эталонный скриншот в проекте?».

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

  • В случае если эталонный скриншот уже есть в проекте, то он забирается из файла, а текущий сделанный скриншот сохраняется как актуальный.

Шаг 3

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

public void compareScreenshots(int allowableDiff) throws TestFailedException {
    ImageDiffer imageDiffer = new ImageDiffer().withDiffMarkupPolicy(
            new PointsMarkupPolicy().withDiffColor(Color.RED));
    diffImage = imageDiffer.makeDiff(expectedScreenshot, actualScreenshot);
    int diffSize = diffImage.getDiffSize();
    saveScreenshotToFile(diffImage.getMarkedImage(), diffFile);
    attachImg("expected", expectedFile);
    attachImg("actual", actualFile);
    attachImg("diff", diffFile);
    if (diffSize > allowableDiff) {
        Assert.fail(String.format("""
                                          Скриншоты не совпадают.
                                          Допустимое значение разницы: %spx
                                          Фактическое значение разницы: %spx""",
                                  allowableDiff, diffSize));
    }
}

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

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

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

Также все три изображения добавляются в отчет Allure.

Шаг 4

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

В нашем проекте были выделены следующие необходимые шаги:

  • скриншот всей страницы;

  • скриншот видимой области экрана;

  • скриншот элемента;

  • скриншот элемента, который отображается поверх другого контента (модальные окна, боковые панели, алерты и т.п.).

Вот как выглядит их реализация:

    @Step("Сделать и сверить с эталонным скриншот всей страницы")
    public void pageScreenshot(int allowedDiff) {
        windowScrollToPoint();
        Screenshoter screenshoter = new Screenshoter();
        screenshoter.makePageScreenshotWithScroll();
        screenshoter.compareScreenshots(allowedDiff);
    }
    
    @Step("Сделать и сверить с эталонным скриншот видимой области экрана")
    public void viewScreenshot(int allowedDiff) {
        Screenshoter screenshoter = new Screenshoter();
        screenshoter.makePageScreenshotWithoutScroll();
        screenshoter.compareScreenshots(allowedDiff);
    }
    
    @Step("Сделать и сверить с эталонным скриншот элемента")
    public void elementScreenshot(By locator, int allowedDiff) {
        windowScrollToPoint();
        Screenshoter screenshoter = new Screenshoter();
        screenshoter.makeElementScreenshotWithScroll(locator);
        screenshoter.compareScreenshots(allowedDiff);
    }

    @Step("Сделать и сверить с эталонным скриншот сайдпанели")
    public void sidepanelScreenshot(By locator, int allowedDiff) {
        windowScrollToPoint();
        Screenshoter screenshoter = new Screenshoter();
        screenshoter.makeElementScreenshotWithoutScroll(locator);
        screenshoter.compareScreenshots(allowedDiff);
    }

    private void windowScrollToPoint() {
        Selenide.executeJavaScript("window.scrollTo(0,0);");
    }

Также есть аналогичные перегруженные  шаги, которые принимают название скриншота в параметре и передают его в конструктор класса Screenshoter.

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

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

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

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

@Step("Скрыть видимость элементов: {locators}")
public ScreenshotSteps disableElements(By... locators) {
    for (By locator : locators) {
        $(locator).shouldBe(Condition.exist);
        String xpath = locator.toString().replace("By.xpath: ", "");
        Selenide.executeJavaScript(
                "function hideElementsByXPath(xpathExpression) {\n"
                    + "const result = document.evaluate(xpathExpression, document, "
                    + "null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);\n"
                    + "let element = result.iterateNext();\n"
                    + "while (element) {\n"
                    + "element.style.opacity = 0;\n"
                    + "element = result.iterateNext();\n"
                    + "}}"
                    + String.format("hideElementsByXPath(\"%s\");", xpath));
    }
    return this;
}

Шаг 5

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

@Test(description = "Отображение панели курса металлов", groups = {DESKTOP, MOBILE})
public void displayCurrenciesListMetalSidePanel() {
    browser.openPage(MAIN_PAGE);
    passportPage.loginWithoutSms(Users.UserDefault.LOGIN);
    browser.openSidePanel(CURRENCY_LIST_METAL_SIDEPANEL).wait(2);
    screenshot
            .disableElements(currenciesListSidePanel.getCourseUpdateInfo(),
                              currenciesListSidePanel.getCurrencyRowBuy(),
                              currenciesListSidePanel.getCurrencyRowSell())
            .sidepanelScreenshot(currenciesListSidePanel.getPanelBody(), 0);
}

То есть действия в тесте:

  1. Авторизуемся.

  2. Открываем необходимый функционал.

  3. Ждём загрузки этого функционала.

  4. При необходимости выключаем динамически меняющиеся элементы.

  5. Делаем нужный скриншот, указав в методе допустимую разницу в пикселях (в данном примере это 0).

Когда тест написан:

  1. Запускаем его. Создаётся эталонный скриншот.

  2. Сверяем созданный эталонный скриншот. Если он корректный, то добавляем его в git index.

  3. Повторно запускаем тест. Создаётся актуальный скриншот и сравнивается с эталонным. Если тест прошёл, значит он написан корректно. 

Вроде ничего сложного, как мне кажется.

Вот как хранятся эталонные скриншоты в проекте:

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

И, для примера, отчёт Allure:

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

Несколько важных заметок по использованию функционала

№1. Перед снятием скриншота дождитесь загрузки страницы.

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

Здесь в помощь методы ожидания элементов в selenide, к примеру shouldBe(visible) и обычный sleep(). Меня могут закидать нецензурной бранью, но sleep() во многих случаях — лучшее решение. 

Ещё на подумать: есть идея сделать ретраи для актуальных скриншотов. То есть скриним, сверяем с эталонным, если разница превышает допустимую норму, то ждём секунду и повторяем. И так несколько раз. Но есть сомнения, что это хорошее решение.

№2. Запускайте тесты на одной машине.

Думаю не будет новостью, что отображение верстки может отличаться на разных ОС и разрешениях экрана, что связано с системными шрифтами и масштабом экрана.

Следовательно, создавать эталонные скриншоты и делать тестовые прогоны необходимо на одной и той же машине. Как вариант — запускать тесты на Selenoid, как раз у нас он и используется.

На этом всё. Если есть вопросы — задавайте в комментариях.

Комментарии (0)