Всем привет, меня зовут Александр Матюшенко, я инженер по автотестированию в одной из платформенных команд в Альфа-Онлайн. Долго откладывал написание этой статьи по разным причинам: начиная от занятости, заканчивая собственной ленью. Но вот наконец-то решился.
Всё началось с конференции, на которой я рассказывал о данном функционале и пообещал сделать статью на Хабр, дабы хоть немного заполнить отсутствие информации в сети по поводу скриншот-тестирования. Постараюсь изложить кратко и лить поменьше воды, так как это не доклад и не нужно заполнять чем-то выделенное время.
Если очень кратко, то причиной добавления скриншот-тестирования в нашем проекте стала нехватка ресурсов на постоянное проведение регресса фронт доработок, особенно связанных с изменением вёрстки. Во-первых это большой объём тестирования, а во-вторых, много специфичных тестовых данных и случаев, о которых знают только ответственные команды.
В общем, я больше хочу рассказать, как внедрить функционал скриншот-тестирования в проект 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, как раз у нас он и используется.
На этом всё. Если есть вопросы — задавайте в комментариях.
Iknwpwd
Технических решений для скриншот тестов уже довольно много, лично практикую такие тесты с 14го года, те лет 10 уже существуют подобные библиотеки. Интересно было бы послушать не о том как завести очередную приблуду в парке приблуд и сильно этому радоваться, а реальному опыту использования на отрезке времени и сопутсвующим проблемам внедрения такого вида тестов в целом.
Например, как кто и когда решает, что сейчас было изменение вёрстки и надо запустить именно такой вид тестов, это человек или автоматизированно тоже. Включены ли тесты в CI и что является триггеромдля запуска.
Как кем и когда пополняется база эталонных скринов?
Где все это хранится и каков объем занимаемогл места, кто отвечает за доступность шары с эталонными скриптами?
Ну вы поняли, хотелось бы больше практики обслуживания такого слоя тестов, а не "мы взяли тулзу у яндекса", ну или хотя-бы тогда уж сравнения тулз что-ли...на той же Java их несколько...