Всем привет, меня зовут Александр Матюшенко, я инженер по автотестированию в одной из платформенных команд в Альфа-Онлайн. Долго откладывал написание этой статьи по разным причинам: начиная от занятости, заканчивая собственной ленью. Но вот наконец-то решился.
Всё началось с конференции, на которой я рассказывал о данном функционале и пообещал сделать статью на Хабр, дабы хоть немного заполнить отсутствие информации в сети по поводу скриншот-тестирования. Постараюсь изложить кратко и лить поменьше воды, так как это не доклад и не нужно заполнять чем-то выделенное время.
Если очень кратко, то причиной добавления скриншот-тестирования в нашем проекте стала нехватка ресурсов на постоянное проведение регресса фронт доработок, особенно связанных с изменением вёрстки. Во-первых это большой объём тестирования, а во-вторых, много специфичных тестовых данных и случаев, о которых знают только ответственные команды.
В общем, я больше хочу рассказать, как внедрить функционал скриншот-тестирования в проект 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); }
То есть действия в тесте:
-
Авторизуемся.
-
Открываем необходимый функционал.
-
Ждём загрузки этого функционала.
-
При необходимости выключаем динамически меняющиеся элементы.
-
Делаем нужный скриншот, указав в методе допустимую разницу в пикселях (в данном примере это 0).
Когда тест написан:
-
Запускаем его. Создаётся эталонный скриншот.
-
Сверяем созданный эталонный скриншот. Если он корректный, то добавляем его в git index.
-
Повторно запускаем тест. Создаётся актуальный скриншот и сравнивается с эталонным. Если тест прошёл, значит он написан корректно.
Вроде ничего сложного, как мне кажется.
Вот как хранятся эталонные скриншоты в проекте:
При этом вся эта огромная структура сформировалась самостоятельно, нет необходимости вручную складывать эталонные скриншоты по разным местам.
И, для примера, отчёт Allure:
В него прикрепляются эталонный и актуальный скриншоты, а также изображение разницы, на котором красным цветом помечается разница при сравнении.
Несколько важных заметок по использованию функционала
№1. Перед снятием скриншота дождитесь загрузки страницы.
Если после открытия страницы сразу сделать скриншот, то в большинстве случаев это не увенчается успехом. Функционал может не успеть загрузиться или не завершатся анимации, при их наличии.
Здесь в помощь методы ожидания элементов в selenide, к примеру shouldBe(visible)
и обычный sleep()
. Меня могут закидать нецензурной бранью, но sleep()
во многих случаях — лучшее решение.
Ещё на подумать: есть идея сделать ретраи для актуальных скриншотов. То есть скриним, сверяем с эталонным, если разница превышает допустимую норму, то ждём секунду и повторяем. И так несколько раз. Но есть сомнения, что это хорошее решение.
№2. Запускайте тесты на одной машине.
Думаю не будет новостью, что отображение верстки может отличаться на разных ОС и разрешениях экрана, что связано с системными шрифтами и масштабом экрана.
Следовательно, создавать эталонные скриншоты и делать тестовые прогоны необходимо на одной и той же машине. Как вариант — запускать тесты на Selenoid, как раз у нас он и используется.
На этом всё. Если есть вопросы — задавайте в комментариях.
ссылка на оригинал статьи https://habr.com/ru/articles/850748/
Добавить комментарий