Меня зовут Ахетов Даниил и уже более 5 лет я занимаюсь автоматизацией тестирования web-приложений на JavaScript. За это время я успел попробовать все самые популярные фреймворки для автоматизации тестирования UI, Playwright, WebDriver.io, Cypress и т.д. Нельзя сказать, что какой-то фреймворк лучше или хуже, у каждого есть свои уникальные возможности и особенности, которые нужно учитывать при подборе инструментов автоматизации тестирования в каждом конкретном проекте. И хоть все инструменты разные, но объединяет их одно, они все ищут элементы на странице по селектору. Это обязывает наделять html элементы уникальными атрибутами, будь то класс или data-атрибут. Очень часто я сталкивался с тем, что после рефакторинга кода приложения тесты падали, и с одной стороны это правильно, ведь тесты среагировали на изменение разметки, а с другой стороны фактической ошибки в приложении нет, потому что изменился не интерфейс, а структура DOM-дерева, но каждый раз разбирать такие падения было больно. Несомненно, специальные data-атрибуты частично решают эту проблему, но иногда про них забывают, а когда проект становится достаточно большим, команда начинает задумываться над сокращением размера index.html, чтобы переходя по ссылке пользователь как можно быстрее получил полезную для него информацию. Тут-то и начинается гонка за каждый байт и каждую миллисекунду и необходимость любого «лишнего» символа в html разметке ставится под сомнение. В такие моменты data-атрибуты для автотестов это первые кандидаты на удаление из конечного html. Уже довольно давно я думал о том как же находить элементы на странице не привязываясь к DOM-дереву, результатом этих поисков является плагин, который я написал для Cypress. О нем, и о подходе, который в нем применяется, пойдет речь в данной статье.

Дисклеймер

Хочу сразу оговориться, что плагин далек от идеала и есть проблема, которая сильно препятствует его коммерческому использованию. Это скорее прототип с открытым исходным кодом, который возможно натолкнет на создание чего-то более великого. Я специально старался не использовать в коде сложные конструкции и намеренно отказался от TypeScript, чтобы каждый смог понять механизмы работы и преобразовать плагин под свои конкретные нужды. Ну и, наверное, не стоит упоминать, что Cypress был выбран как пример, подобный плагин можно написать для любого существующего фреймворка.

Решение

Однажды в исходниках Appium я встретил метод, о котором в документации было мало что написано. На текущий момент, я даже не смог вновь найти этот метод. Заключался он в том, чтобы найти координаты заданного шаблона на исходной картинке и вернуть координаты центра, то есть передав методу картинку элемента мы могли понять где этот элемент на странице находится. Appium с помощью этого метода позволял провести проверку на «наличие» элемента и клик по координатам. Это было интересно, но все же хотелось получить полноценный элемент, чтобы как минимум можно было проверить текст, стили, размеры и так далее. Поиск шаблона в этом методе производился с помощью методов OpenCV - библиотеки компьютерного зрения.

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

Долгое время я пытался завести npm-пакеты вроде opencv-js, но получалось это мягко говоря не очень хорошо, из коробки это просто не заработало, а при попытке, что-то собрать из исходников получалось долго и безуспешно. Все npm пакеты которые я пробовал имели последнее обновление 2-3 летней давности, видимо это сильно влияло на успех. Тогда было принято решение использовать версию OpenCV для Python, стоит ли говорить, что все собралось за считанные секунды. Оставалось только написать логику поиска шаблона на Python и запустить скрипт в контексте Node.js, чтобы у автотеста была возможность передать нужные параметры в сам скрипт. Благо, подобные задачи в Node.js решаются очень просто. Сам Python-скрипт запускается с помощью пакета python-shell, а так как методы асинхронны, то обернуты они в child_process.execSync(), чтобы не нарушалась последовательность действий в автотесте. В итоге получилась довольно безумная конструкция, мы имеем Python-скрипт, который запускается в JS-скрипте утилитой python-shell, который, в свою очередь, запускается на сервере в блокирующем исполнение основного контекста процессе через child_process.execSync(). Зато не нарушается порядок вызовов в самом автотесте.

На момент написания статьи в npmjs ожил проект @techstark/opencv-js, и я обязательно еще раз его попробую в новых версиях своего плагина.

Логика же самого плагина довольно проста. Мы «вырезаем» элемент, который хотим найти из заранее подготовленного скриншота страницы, и передаем его в метод поиска шаблона. При этом скриншот, на котором мы будем искать шаблон, создается во время прохождения автотеста, то есть система координат страницы в браузере и скриншота будет едина. Далее нам возвращаются координаты, по которым мы уже стандартным методом document.elementFromPoint в JavaScript получаем DOM-ноду, с которой можем работать как с обычным элементом. Думаю не стоит говорить, что если один элемент «перекрывает» другой, то будет выбран либо родительский элемент, либо элемент с наибольшим z-index. Это как event capturing только с вызовом event.stopPropagation, для остановки погружения. В большинстве примеров, которые я встречал, достаточно средствами самого фреймворка от найденного элемента найти дочерний, даже по html тегу. Например в cypress это можно сделать так:

cy.get('@el').find('button').as('targetElement');
cy.get('@targetElement').should('be.visible');

Да, мы все равно пишем селектор, но так как мы ищем в рамках уже выбранного элемента, то он, скорее всего, будет уникальным в пределах небольшого html сниппета.

Стоит отметить, что в качестве метода сравнения шаблонов был выбран TM_CCOEFF.  В этой статье не буду вдаваться в подробности разновидностей поиска шаблона, это тема на отдельную статью, скажу только, что все они описаны в документации OpenCV. Можно только добавить, что вместо TM_CCOEFF, наверное, лучше все таки использовать TM_CCOEFF_NORMED, но для моих целей это было не принципиально.

В основе любого алгоритма поиска по шаблону лежит попиксельное сравнение в том или ином виде. При этом подходе необходимо учитывать разность DPI шаблона и изображения. Она может возникнуть от разных факторов, например, шаблон подготавливали из картинки большего или меньшего разрешения, чем скриншот на котором мы будем осуществлять поиск. Так же влияет и Device Pixel Ratio, понятно, что на сервере тесты запускаются, скорее всего, в X11 с ratio = 1, но вот на локальной машине монитор может быть и Retina, у которой тот же Pixel Ratio составляет минимум 2 единицы. Итого, мы можем получить работающие тесты на сервере, но вот локально они будут постоянно падать или наоборот.  Для этого в Python-скрипте поиска шаблона реализован цикл который проходит по различным масштабам изображения (от 20% до 100% от исходного размера), уменьшая его размер и выполняя шаблонное сопоставление на каждом шаге. В каждой итерации мы запоминаем значение наибольшей корреляции и в конечном итоге оставляем координаты и масштаб той итерации в которой значение корреляции, то есть совпадение шаблона, было максимальным. Сохранить масштаб при этом нам необходимо, чтобы помножить на него координаты шаблона, для нахождения их на исходном изображении. Таким нехитрым образом мы получаем алгоритм, который найдет шаблон сделанный, например при разрешении 1280x720 на картинке в 1920х1080, что позволяет нам иметь одну картинку элемента на несколько разрешений экрана которые выставляются при прохождении автотеста. И нам будет не важно где был сделан скриншот для шаблона.

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

// Ищем элемент по картинке и записываем его в алиас
cy.searchByImage('cypress/imageSelectors/todoLogo.png').as('todoElem');
// Проверяем, что элемент видим на странице, обращаясь к нему через алиас
cy.get('@todoElem').should('be.visible');

Что этот подход дает?

  • Больше не надо долго и мучительно подбирать уникальные селекторы для элемента

  • Нет необходимости указывать data-атрибуты или создавать специальные сборки для автотестов

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

  • Автотесты зависят от визуального изменения интерфейса, а не от DOM-структуры документа

Как это получить?

Плагин уже опубликован в виде npm пакета, вот ссылка на него, в ReadMe я постарался максимально подробно описать его подключение, требование к системе, в которой он будет запускаться, и сделал пример использования.

В чем же заключается проблема для коммерческого использования?

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

P.S.

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

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

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