Как заставить тесты «видеть» дефекты: о внедрении функционала скриншот-тестирования в проект E2E автотестов

от автора

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

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

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

В общем, я больше хочу рассказать, как внедрить функционал скриншот-тестирования в проект 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, как раз у нас он и используется.

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


ссылка на оригинал статьи https://habr.com/ru/articles/850748/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *