Unit-тестирование скриншотами: преодолеваем звуковой барьер. Расшифровка доклада

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


Под катом расскажем, что из этого вышло, какие задачи решали, и как мы добились того, чтобы тестирование скриншотами практически не влияло на общее время прохождения тестов. Этот пост — расшифровка доклада, который прозвучал на HolyJS 2017 Moscow. Видео можно посмотреть по ссылке, а почитать и посмотреть слайды — далее.


qf5fbmtprfkhvfrlakihvaaygwi.jpeg


Всем привет, меня зовут Роман. Я работаю в Avito. Занимаюсь многими вещами, в том числе open-source, автор нескольких проектов: CSSTree, basis.js, rempl, мейнтейнер CSSO и других.


Сегодня я расскажу про юнит-тестирование скриншотами. Этот доклад — история про поиск инженерных решений. Я не дам рецептов на все случаи жизни. Но поделюсь направлением мысли: куда пойти, чтобы сделать всё хорошо.


Велосипеды — это не всегда плохо. Те, кто меня знает, помнят, что я часто пытаюсь делать что-то новое, несмотря на то, что есть много готового. К чему это приводит? Если не сдаваться, то можно найти решения, причём совсем не там, где вы их искали.


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


Современные фронтендеры при возникновении какой-то проблемы обычно сразу идут на NPM, на StackOverflow и пытаются использовать готовые решения. Но не всегда npm install может помочь. «В нас пропал дух авантюризма»: мы редко пытаемся делать что-то самостоятельно, копать вглубь.


1mzrqeo_sp_58-sgqktlt-78ozc.png


Будем это исправлять.


Unit-тестирование: инструменты

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


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


Какие есть варианты?


  • Готовые сервисы;
  • Готовые инструменты, например Gemini;
  • Можно написать свое.


Сервисы нам не подходят по определенным причинам: мы не хотим использовать внешние сервисы, хотим чтобы все было внутри.


Что насчёт готовых инструментов? Они есть, их несколько, но они обычно ориентированы на то, чтобы ходить по урлам и «щелкать» определенные блоки. Это не совсем подходило нам — мы хотели тестировать именно компоненты и блоки, их состояние, делая скриншоты.


Есть инструмент Gemini от Яндекса, хорошая штука, но она похожа на космический корабль. Сложно завести, настраивать, приходится писать много кода. Возможно, это не проблема. Но для меня проблемой стало то, что взяв простой тест из readme, скопировав его сто раз, я получил такую цифру: 100 изображений 282×200 проверяются примерно две минуты. Это очень долго.


В итоге стали делать своё. Про это и будет сегодняшний доклад. Забегу вперёд: покажу что у нас получилось.


ro-utwfhqkac_ahezhpqtjtecl4.png


Так, имея некий тест разметки компонента на React, мы добавляем одну строку, в которой делаем скриншот и вызываем «магический» метод toMatchSnapshotImage(). То есть одна дополнительная строчка в тесте — и мы помимо прочего проверяем состояние компонента скриншотом.


В цифрах: если сравниваются два одинаковых скриншота размером 800×600, то, в нашем решении, сравнение занимает примерно 0 мс. Если скриншоты немного отличаются, и надо посчитать пиксели, которые отличаются, это занимает примерно 100 мс. Обновление скриншотов, получение картинки, когда мы инициализируем «базу» эталонных скриншотов, занимает примерно 25 мс на скриншот. Много это или мало — увидим позже.


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


cnx63mz_bje5qcntoa3gtdwlv4g.png


Генерация разметки

Начнём с генерации разметки. Она разбивается на несколько шагов. Сначала генерируем HTML компонента. Затем определяем, какие есть зависимые части: какие стили он использует, какие ему нужны изображения, и так далее. Пытаемся всё это собрать в единый HTML-документ, в котором нет ссылок на локальные ресурсы или файлы.


Генерация HTML


Генерация HTML сильно зависит от стека, который вы используете. В нашем случае это React. Берем готовую библиотеку react-dom/server, позволяющую генерировать статическую строку, тот самый HTML, который нам необходим.


vtarcxtfd3mal3tf-4dqgr2nmwc.png


То есть подключаем react-dom/server, вызываем метод renderToStaticMarkup() — получаем HTML.


Генерация CSS


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


Решение, опять же, зависит от стека. В нашем случае мы используем Jest в качестве тест раннера, Babel для преобразования JavaScript и CSS Modules, чтобы описывать стили.


Для начала делаем поиск CSS-файлов.


bsu25o5oiyrgz4tauwajowwojfu.png


CSS Modules подразумевает, что CSS подключается в JavaScript как обычный модуль, то есть используется либо import, либо require().


Технически, нужно перехватить все такие вызовы и преобразовать их так, чтобы сохранить пути, которые были запрошены.


Для этого мы написали плагин для Babel. У Jest есть возможность настраивать трансформацию JavaScript (возможно, вы уже это делаете, если используете Jest). С помощью настройки transform добавляются скрипты для трансформации ресурсов, которые соответствуют правилу. В нашем случае нужны JavaScript-файлы.


krzvsfn4xpukxd79krq6gjvc8n0.png


Скрипт создает трансформер, используя babel-jest. К прочим настройкам нам нужно добавить собственный плагин, который будет делать необходимое.


zn-hstzemnllba0tugxhgropu6y.png


Задача плагина состоит из двух частей. Сначала делается поиск всех import, которые приводятся к require(), чтобы было проще потом искать подключения CSS. После этого все require() заменяются на специальную функцию:


avk21k1ioyfpslko7qqezfydfru.png


Такая функция инициализирует глобальный массив для хранения путей, добавляет новые пути в этот массив и возвращает оригинальный экспорт, который был заменен. Код плагина составляет 52 строки. Решение можно упростить, но пока не было необходимости.


На момент генерации HTML-разметки компонента в массиве includedCssModules будут все пути, которые запрашивались через require(). Всё что нам остается — это преобразовать пути в контент этих файлов.


Обработка CSS

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


Инлайн ресурсов


Чтобы заинлайнить ресурсы, мы написали еще один плагин. (Можно использовать готовый, но в данном случае оказалось проще написать свой).


ocvtsg_oppewry_iwnpnybwfiq4.png


Как это всё выглядит? Помните, мы добавляли плагин в jest-transform? Здесь такая же история, только мы используем специальный плагин для CSS Modules, а именно css-modules-transform для babel-jest, у которого есть возможность настроить препроцессинг CSS: некий скрипт, который будет преобразовывать CSS перед тем, как он будет использоваться.


Итак, добавляем в processCss путь к нашему плагину и пишем сам плагин. Для него используется парсер CSSTree. Дело не только в том, что я его автор ;) — он быстрый, детальный и позволяет, например, искать пути и урлы без сложных RegExp'ов. Также он толерантен к ошибкам: если в CSS будут непонятные части, то ничего не сломается, просто эти части останутся не разобранными. Но такое случается редко.


hu-quf675t6h5me_mhdq2tyueym.png


Плагин ищет в CSS урлы и заменяет их на инлайн-ресурсы.


Что здесь происходит? В первой строке мы получаем AST, то есть разбираем CSS-строку в дерево. Дальше обходим это дерево, находим узлы типа Url, выбираем из них значение и используем как путь к файлу, который нужно заинлайнить. В конце просто вызываем translate, то есть преобразуем трансформированное дерево обратно в строку.


8kddqb2we-4k2gynkgl8bbmuhl4.png


Реализация инлайн ресурсов не такая сложная, как может показаться:


  • берем URI, разрешаем его относительно CSS-файла, в котором он используется (потому что пути относительные);
  • читаем из файла бинарные данные;
  • вычисляем по расширению mime-тип;
  • генерируем Data URI ресурса.


Всё! Мы заинлайнили ресурсы. Описанные функции — это 26 строк кода, которые делают всё необходимое.


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


Избавляемся от динамики

Следующим шагом нам нужно избавиться от динамики. Как заморозить анимацию и где она бывает?


Динамика появляется в:


  • CSS Transitions;
  • CSS Animations;
  • каретка в полях ввода, которая мигает и в любой момент может пропасть или появиться;
  • анимированные GIF и ряд других моментов.


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


CSS Transition


Обнуляем все transitions-delay и transition-duration.


cfzygd8qlgvm6hfptwlxgeolmpg.png


В этом случае все transition будут гарантированно находиться в конечном состоянии.


CSS Animation


Похожим образом поступаем с CSS анимациями.


v9dvqujnyze2c4azhmyays60kog.png


Здесь можно увидеть такой хак:


9bxg01mvp-59fmo4mmzzwnqwie0.png


Обратите внимание на значение animation-delay: –0.0001s. Дело в том, что без этого в Safari у анимаций не будет финального состояния.


И последнее: мы пригнали анимацию в конец (финальное состояние), но анимации отличаются от транзиций тем, что могут повторяться. Поэтому мы ставим состояние анимации в паузу, устанавливая animation-play-state в paused. Таким образом анимации ставятся на паузу, то есть перестают проигрываться.


0ckjj7jvzxbkrkgj6fnyzfrzqtc.png


Каретка


Следующий момент — каретка в полях. Проблема в том, что она мигает: в какой то момент мы видим вертикальную черту, в какой то момент — нет. Это может оказать влияние на результирующий скриншот.


В последние месяцы в браузерах появилось такое свойство как caret-color: сначала в Chrome, потом в Firefox и Safari (Technology Preview). Чтобы «выключить» каретку, мы можем сделать её прозрачной (задать в качестве цвета значение transparent). Таким образом каретка всегда будет невидимой и не будет влиять на результат.


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


znchcb4rzvtnpkmm22y4lcnp628.png


GIF


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


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


qqcdjiimfoxgcpdg-v-utu9pcis.png


GIF состоит из нескольких блоков: в начале есть сигнатура описания размера изображения и таблица индексированных цветов. Затем идут последовательно блоки: Image Descriptor Block, который отвечает за графику, и Extension Block, в котором можно хранить палитру, некоторый текст, комментарии, копирайты и прочее. В конце файла идет Trailer, специальный блок, который говорит, что GIF закончился.


Таким образом, нужно пройти эти блоки и отфильтровать (удалить) все Image Descriptor Block, кроме первого. Вот ссылка на Gist с кодом, который делает необходимое. Я написал его за пару часов, отладил, пока работает отлично, проблем не нашли.


В итоге: GIF изображения статичны, анимации выключены, все пути к CSS есть. Осталось склеить. Что может быть проще, казалось бы?


Склейка CSS

Давайте посмотрим, как устроен Jest. Обычно он работает в параллель, запускает несколько потоков, которые выполняют тесты. Каждый файл с тестами запускается в один из потоков, и каждый файл — это отдельный контекст, который не шарит данные между другими контекстами. И проблема в том, что у нас трансформация CSS, где мы получали доступ к исходному коду CSS файлов, находится вне контекста теста, и мы не можем получить доступ к этому контенту. Читать CSS из файла мы тоже не можем, потому что CSS уже преобразован, хранится в самом JavaScript, в каком-то окружении, контексте, воркере.


Как пошарить CSS между тестами? Мы сделали небольшой хак. Каждый воркер создает временный файл в формате JSON, где ключ — путь к CSS, а само значение — уже преобразованный CSS. Каждый поток читает этот файл, берет оттуда нужное и делает конкатенацию внутри контекста теста.


dxvvzpfzftqsj64ydbi8_uhmbis.png


Здесь мы читаем некоторый временный файл, парсим его в JSON, добавляем в него нужный контент. Filename — это ключ, CSS — преобразованное значение. И записываем обратно преобразованную карту.


lifpeuxgzs2j5kzjnrpaxlgy9py.png


Когда мы генерируем CSS для скриншота, мы читаем из этого файла, используем includedCssModules (массив путей CSS), получаем контент нужных файлов и делаем join().


Осталось собрать всё вместе.


Финальная сборка

h-gwqbzatviljdzo0wtdl9ks5vg.png


Генерируем финальный HTML. Сначала задаем стили, которые отключают динамику (анимации). Во втором style подключаются все склеенные CSS, которые мы нашли. У каждого теста будет свой набор этих стилей, потому что когда мы делаем require() компонента, он подтягивает свои зависимости, которые и будут в нашем списке. Как итог подключаются только используемые CSS файлы, а не весь CSS в проекте. HTML, соответственно, мы получали раньше — это код самого компонента.


В итоге мы добились своего. Мы можем генерировать HTML компонент в нужном состоянии плюс CSS, который ему необходим.


Итак, вся разметка собрана, анимация выключена — все готово, чтобы сделать скриншот. Решения не идеальны, можно сделать лучше, но для этого нужно дальше копаться внутри Jest, Babel, CSS Modules и так далее, чтобы получить более элегантные и стабильные решения. Но в целом нас это устраивает, и мы можем двигаться дальше.


Скриншоты

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


Также все современные браузеры поддерживают WebDriver. Если вы используете, например, Selenium, то все делается относительно несложно. Есть библиотеки, хелперы, которые упрощают написание тестов для таких сред.


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


t9g2femtlpqt1wky20zgjkm84am.png


Здесь подключается Puppeteer, запускается браузер, и когда необходимо сделать скриншот, мы вызываем функцию screenshot() с некоторым HTML. Эта функция создает новую страницу, вставляет в нее переданный HTML, делает скриншот, закрывает страницу и отдает нам результат скриншота. Работает. Несложно. Но оказалось, не все так просто.


Дело в том, что когда мы запускаемся код локально, у нас всё хорошо работает. У нас изображения эталонное и новое совпадает, потому что мы создаем новое изображение в той же версии браузера, той же системе, где мы делали эталон. Но когда мы начали запускать все это на CI, где у нас уже не Мас, не Windows, а Linux, своя версия Chrome, свои правила антиалиасинга, свои шрифты и прочее, изображения получились другие. То есть начали получать разный результат.


Что делать? Есть несколько решений. Некоторые решения пытаются эту разницу побороть с помощью математики. Они сравнивают не попиксельно, а пиксель и соседние пиксели — то есть нестрогое сравнение с определенным допуском. Это дорого и как-то странно, хотелось бы просто сравнить попиксельно.


Мы пошли в сторону другого решения: сделать внешний микросервис, куда можно отправить POST-запрос с HTML-кодом, а на выходе получить изображение, скриншот, который нам необходим.


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


Также не требуется локальных настроек для нового проекта. Не нужно запускать браузеры, настраивать Puppeteer и прочие вещи, мы просто делаем POST-запрос и получаем изображение. Получается даже быстрее, как ни странно, хотя есть сетевые издержки. Мы отправляем в сервис запрос, там есть и кэши, и разогретый браузер, который очень быстро отдает изображение. Плюс PNG достаточно маленькие, хорошо жмутся, сетевой трафик не очень большой.


Минусы тоже есть. Сервис может в любой момент упасть, нужно следить за его «здоровьем». Все знают, что браузер может «отъедать» много памяти, даже если посещаются простые страницы. На сервис может резко хлынуть нагрузка, он может не справиться, его ресурсы ограничены. И если сервис падает, он не может отдать изображения — наши тесты не проходят.


Соответственно, если все одновременно решат проверить скриншоты (запустить), то может получиться так, что либо придётся долго ждать, либо сервис перестанет отвечать, потому что большая нагрузка. Последнее решается более-менее: у нас есть локальное облако, где можно создать несколько инстансов сервиса и распределить нагрузку. Но тем не менее, такая проблема существует. Как выглядит смерть сервиса?


3gd7skpu3mlzkcxk1woyzasd1xy.png


Есть определенный инстанс сервиса, который работает, у него ограниченное количество памяти (в данном случае 1 Гб), он может «съесть» всю доступную память и перестать отвечать. В таких случаях помогает только перезагрузка.


У решения с микросервисом есть и другая сторона. Когда мы сделали создание скриншотов по коду, появилась идея научить сервис отдавать скриншоты не только по коду, но и по URL плюс некий селектор. Что делает сервис в этом случае: заходит на страницу, и либо делает полный скриншот страницы, либо того блока, на который матчится переданный селектор. Это оказалось удобно и полезно для других задач, совсем не связанных с тестированием. Например, сейчас мы экспериментируем: вставляем в документацию, в нашу базу знаний скриншоты страниц, инструкции к нашим сервисам, частям сайта, используя для изображений () в качестве адреса URL к сервису. Получается, что когда мы заходим в документацию, у нас всегда актуальные скриншоты. И не нужно постоянно их обновлять. Это очень интересное решение, которое получилось само собой.


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


Сравнение изображений

Итак, переходим непосредственно к тестированию скриншотами. Мы получили код, получили по этому коду скриншоты, осталось их сравнить.


Напомню, мы используем Jest для тестирования компонентов. Это важно.
В Jest есть «киллер-фича» — сравнение снэпшотов.


imyp7evx32m8n-jd7zrej0kwqh0.png


Мы делаем разметку объектов, какие-то данные, и можем вызвать метод toMatchSnapshot() для этих данных. Как работает метод:


  • приводит проверяемое значение к строке, используя различные методы вроде JSON.stringify();
  • eсли тест новый, результат сохраняется в локальную базу результатов для последующего сравнения;
  • если тест запускается повторно, то из базы достается предыдущий снэпшот и сравниваются две строки, равны они или нет. Если не равны, то будет ошибка, то есть тест не пройден. Если тест был удалён, Jest отмечает, что в базе есть неактуальный снэпшот, чтобы не было мусора.


Используя метод toMatchSnapshot() мы можем проверить изменилась ли у нас разметка (HTML) компонентов или нет, и нам не нужно писать код для того чтобы снепшоты сравнивать, обновлять, хранить и прочее. Магия!


Но вернёмся к сравнению изображений. Изображения у нас бинарные, это не строковое представление. Встроенных инструментов для сравнения изображений в Jest пока нет. На GitHub есть тикет на эту тему, они ждут pull request. Может, сами сделают со временем. Но на данный момент есть плагин от American Express — jest-image-snapshot. Он хорошо подойдет для старта, чтобы сразу начать сравнивать бинарные изображения. Выглядит это так:


evnlkmvjer32swia1n4wxzkiyfk.png


Мы подключаем этот модуль и расширяем expect новым методом toMatchImageSnapshot(), который берется из jest-image-snapshot.


ne6_qih-hrouczmdmanj5vzcmgq.png


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


Когда изображение получено, делаем вызов toMatchImageSnapshot(). Происходит примерно та же история, что и с toMatchSnapshot(). Если тест новый, то изображение сохраняется как есть в файл. Если не новый, то читаем файл, который у нас есть, смотрим, что пришло и сравниваем (об этом — чуть позже), равны они или нет.


Какие проблемы есть у этого плагина? Когда мы начинали его использовать, он был недостаточно быстрым. Недавно он стал быстрее, что уже хорошо. Также он не учитывает режимы Jest. (Тут интересно, что Jest работает в нескольких режимах: по умолчанию он просто дописывает новые снэпшоты в вашу базу или на диск, есть режим апдейта, когда снепшоты перезаписываются или удаляются более не актуальные. Есть режим CI, в котором файлы не создаются, не обновляются и не удаляются, сравнивается только то, что есть и что нам пришло). Так вот, плагин jest-image-snapshot это не учитывает, он всегда пишет файлы, независимо от того, какой режим у нас включен, что плохо для CI, потому что CI может ложно пройти тест, для которого просто нет снэпшота.


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


Но ключевая проблема — производительность. Когда мы сравнивали изображения 800×600, мы получали 1,5–2 секунды на изображение. Насколько это плохо? Например, у нас сейчас больше 300 изображений. Мы ожидаем, что в скором времени счёт скриншотов будет идти на тысячи, время будет постоянно расти. А ведь 300 скриншотов — это уже пять минут.


Мы сделали форк jest-image-snapshot и начали чинить проблемы. В первую очередь — сравнение изображений. Мы начинали с того, что у нас было примерно 300 скриншотов и инициализация (когда мы говорим: «делай скриншоты для всех компонент и просто сохрани как эталон») происходила за 10–20 секунд, достаточно быстро. В то же время проверка делалась 4,5 минуты. Получается, что проверка занимает в несколько раз больше, чем сделать сам скриншот. И это при том, что время указано для трех потоков Jest, в одном потоке получается примерно 12–15 минут.


Почему так медленно? Каждый раз, когда jest-image-snapshot получает новое изображение, он сравнивает их попиксельно через blink-diff, специальную библиотеку, которая умеет сравнивать два PNG.


Посчитаем, что необходимо, чтобы сравнить два изображения 800×600. Одно такое изображение — это 480 тысяч пикселей. Два изображения — в два раза больше. Каждый пиксель — 4 байта (RGB и прозрачность). Получаем примерно 2 Мб на изображение. Чтобы получить ответ, равны изображения или нет, нам нужно выделить около 4 Мб памяти. 300 изображений — 300 раз выделяем по 4 Мб, а потом освобождаем.


Как вы понимаете, это удар по Garbage Collector и прочим вещам, то есть не очень хорошо. Помимо этого нам нужно пройтись по массивам, сравнить поэлементно. То есть здесь уже играет значение количество пикселей: нужно перебрать по полмиллиона элементов в двух массивах и сравнить.


8nskpwbwzsrvhccvtzr-3lijvds.png


Вот как работают библиотеки сравнения изображений. Например, blink-diff, с которого мы начинали, тратит примерно 1,5–2 сек при сравнении изображений (800×600). Есть более быстрая реализация сравнения — библиотека pixelmatch. Она работает втрое быстрее. Кстати, jest-image-snapshot уже перевели на неё (но после того, как мы форкнули). Есть looks-same от команды Gemini, которая умеет делать дополнительную магию, но использует тот же pixelmatch по капотом и скорость сравнения сопоставимая.


Как же сделать быстрее? Тут помогло важное наблюдение: при повторных прогонах тестов (а именно в этом случае необходимо сравнение) большая часть изображений совпадает. Поэтому можно начать сравнивать файлы без их декодирования. PNG хорошо сжимается, размер файлов не очень большой (например, для 800×600 это обычно несколько десятков килобайт, а не 2 мегабайта, как в разжатом виде). А если файлы побайтно не равны, тогда уже сравнивать попиксельно.


Как быстро сравнить два файла? Конечно, хеш!


55hulnrwyxdblatqcofeobgpgra.png


Используем встроенный модуль node.js — crypto, который умеет делать хеши. Пишем небольшую функцию, которая делает md5 или sha1, неважно. Добавляем бинарные данные, и получаем хеши в hex, например.


Посмотрим на время. Для 4 Кб изображений получается примерно 58 тыс. операций сравнения в секунду, что неплохо. Для 137 Кб получается уже около 2 тысяч. Это, кажется, неплохие цифры.


Но это неправильно. Получение хеша — это overkill. Мы по сути делаем сравнение двух буферов, считая от них хеш. А что такое буфер? Это массив чисел, и чтобы их сравнить, нужно пройтись по этим массивам побайтно (или не побайтно) и сравнить равны они или не равны. Чтобы посчитать хеш, нужно также пройти оба массива и дополнительно выполнить множество операций для вычисления хеша. Более простое решение такое:


fprnqmi3b9_jxnzjem2__3aioky.png


У экземпляров Buffer есть метод equals() (еще есть метод compare(), который возвращает –1, 1 и 0, что полезно для сортировки). Суть в том, что мы получаем два буфера, читая файлы, и вызываем метод equals(). Реализация этого метода очень быстрая: если у вас 4 Кб изображение — можно выполнить около 3 млн операций сравнения в секунду, а если изображение 137 Кб — 148 тысяч операций. Это в 50–70 раз быстрее, чем использовать хэш.


Попиксельное сравнение


Итак, мы ответили на вопрос, равны файлы или нет. И теперь умеем это делать очень быстро. А если они не равны, то нужно сравнивать попиксельно. Для этого есть библиотеки, но почему бы не сделать сравнение самостоятельно? Цифры библиотек мы видели, но хотелось попробовать самим, насколько это будет быстрее.


mc7ffpsyy4717o1p5pif8jy-byk.png


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


Итак, функция сравнения получает два буфера (actualImage и expectedImage), и первым делом осуществляет декодирование с помощью библиотеки fast-png. Далее создает массивы Uint32Array для этих же буферов. Кажется, что делается дополнительное копирование памяти, но на самом деле нет. Дело в том, что когда типизированные массивы создаются с переданным им буфером, они не создают копию памяти. Они используют тот же фрагмент памяти, что и переданный буфер, но у них получается другой интерфейс доступа к данным. В данном случае в actual.data, например, будет храниться массив байт, а когда создается Uint32Array, то получается четырехбайтная адресация к тому же массиву. Таким образом, можно сравнивать гораздо быстрее, не по одному байту, а по четыре сразу. Это и будут наши пиксели: мы проходим по массиву, сравниваем, равны пиксели или нет, и сравниваем, сколько неравенств произошло.


Чтобы потом посчитать процент не совпавших пикселей, мы возвращаем высоту и ширину изображения, которое мы декодировали (считаем: count / (width * height)).


Посчитаем время сравнения двух изображений, когда нужно посчитать количество пикселей. Для 800×600 это ~100 мс, в несколько раз быстрее, чем дают библиотеки, которые мы могли бы использовать для этой операции.


Генерация diff-изображений

Следующий вопрос: как увидеть разницу невооруженным глазом? Как это вообще работает?


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


vt7-gqtkpcrt1coerzqa3_l2p58.png


Эта функция похожа на предыдущую, только здесь добавляется несколько новых строк. Сначала делается аллокация нового буфера, мы делаем alloc() того же размера, что и актуальное изображение (они равны по длине). Создаем для него Uint32Array. Помимо этого в цикле появилось две дополнительных секции. В случае, когда пиксели не равны, мы просто пишем туда красный пиксель. Это четыре байта. Самый левый — прозрачность, делаем его максимально непрозрачным. Потом синий, зеленый и в конце красный. Красный — полностью красный пиксель. Дальше мы обесцвечиваем и засвечиваем пиксели, и в конце дополнительно возвращаем все три буфера.


Как засветить пиксели?


uweddbopu8l5vibtzyq-yjtcvgo.png


Мы берем пиксель, берем три компонента — красный, зеленый, синий, — складываем вместе, делим на максимальную сумму — получается трижды по 255. Получается яркость серого. Дальше преобразуем эту яркость, чтобы она была в верхней четверти диапазона. В таком случае изображение получается менее контрастным и лучше видно разницу изображений. Финальным шагом берем gray (это та самая градация серого) и обратно складываем в пиксель. Помимо этого сохраняем прозрачность из оригинального пикселя.


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

© Habrahabr.ru