Всем привет! Меня зовут Вова, я фронтэндер в Тинькофф. Наша команда отвечает за два продукта для юридических лиц. О размерах продукта я могу сказать цифрами: полный регресс каждого из продуктов двумя тестировщиками проходит от трех дней (без влияния внешних факторов).
Сроки значительные и напрашиваются на борьбу с ними. Способов борьбы несколько, основные из них:
Последний пункт и стал темой моей статьи.
Как мы знаем, в пирамиде тестирования три уровня: unit-тесты, интеграционные тесты и e2e-тесты. Думаю, с юнитами знакомы многие, как и с e2e, поэтому чуть подробнее остановлюсь на интеграционных тестах.
В рамках интеграционного тестирования мы проверяем работу всего приложения через взаимодействие с UI, однако основное отличие от e2e-тестов в том, что мы не делаем реальных запросов на бэк. Делается это с целью проверить только взаимодействие всех систем на фронте, чтобы снизить количество e2e-тестов в будущем.
Для написания интеграционных тестов мы используем Cypress. В этой статьей я не буду сравнивать его с другими фреймворками, скажу только, почему он оказался у нас:
Эти пункты были важны для нашей команды, так как опыт в написании интеграционных тестов у нас отсутствовал и был необходим очень простой старт. В данной статье я хочу рассказать про пройденный нами путь, про то, какие шишки набили, и поделиться рецептами по внедрению.
В самом начале для организации кода использовался Angular Workspace с одним приложением. После установки пакета Cypress в корне приложения появилась папка cypress с конфигурацией и тестами, на данном варианте мы остановились. При попытке подготовить в package.json скрипт, необходимый для запуска приложения и прогона поверх него тестов, мы столкнулись со следующими проблемами:
Проблему с index.html решили через отдельную конфигурацию сборки — назовем ее сypress, — в которой указали кастомный index.html. Как это реализовать? Находим в angular.json конфигурацию вашего приложения, открываем секцию build, добавляем там отдельную конфигурацию для Cypress и не забываем указывать эту конфигурацию для serve-режима.
Пример конфигурации для build:
Интеграция с serve:
Из основного: для cypress конфигурации мы указываем aot сборку и подменяем файлы с environment — это необходимо для создания prod-like сборки при тестировании.
Итак, с index.html разобрались, осталось поднять приложения, дождаться окончания сборки и прогнать поверх него тесты. Для этого используем библиотеку start-server-and-test и на ее основе напишем скрипты:
Как можно заметить, тут два типа скриптов: open и run. Режим open открывает GUI самого Cypress, где можно переключаться между тестами и использовать time-travel. Режим run — это просто прогон тестов и получение конечного результата этого прогона, отлично подходит для запуска в CI.
По результатам проделанной работы мы смогли получить стартовый каркас для написания первого теста и прогона его в CI.
У описанного подхода есть очень заметная проблема: если в репозитории два и более приложений, то подход с одной папкой нежизнеспособен. Так и произошло у нас. Но случилось это довольно интересным способом. В момент внедрения Cypress мы переезжали на NX, а этот красавец из коробки дает возможность работать с Cypress. Каков принцип работы в нем:
Теперь вы можете прогонять интеграционные тесты одной командой — ng e2e main-app-integrations. NX автоматически поднимет main-app, дождется ответа и прогонит тесты.
К сожалению, остались в стороне те, кто сейчас использует Angular Workspace, но ничего страшного, у меня есть рецепт и для вас. Файловую структуру будем использовать как и у NX:
Решение под Angular Workspace максимально похоже на решение для NX, за исключением того, что папку создаем руками и она не является одним из проектов в вашем монорепозитории. В качестве альтернативы можно напрямую использовать билдер от NX для Cypress (пример репозитория на NX с Cypress, там можно подсмотреть итоговое использование nx-cypress билдера — внимание на angular.json и проект
cart-e2e и products-e2e).
После первых пяти тестов мы задумались о скриншот-тестировании, ведь, по сути, все возможности для этого есть. Заранее скажу, что слово «скриншот-тестирование» вызывает большую боль внутри команды, так как путь к получению стабильных тестов был не самым простым. Далее я опишу основные проблемы, с которыми мы столкнулись, и их решение.
В качестве решения была взята библиотека cypress-image-snapshot. Внедрение не отняло много времени, и вот спустя 20 минут мы получили первый скриншот нашего приложения размером 1000 × 600 px. Радости было много, ведь интеграция и использование были слишком простыми, а полученная польза могла быть огромной.
После генерации пяти эталонных скриншотов мы запустили проверку в CI, как итог — билд развалился. Оказалось, что скриншоты, созданные с помощью команд open и run, отличаются. Решение было довольно простым: снимать скриншоты только в CI-режиме, для этого убрали снятие скриншотов в local-режиме, например так:
В данном решении мы смотрим на параметр env в Cypress, установить его можно разными путями.
Локально тесты начали проходить при повторном запуске, пытаемся снова запустить их в CI. Результат можно увидеть ниже:
Довольно просто заметить разницу в шрифтах на diff-скриншоте. Эталонный скриншот был сгенерирован на macOS, а в CI на агентах установлен Linux.
Подобрали один из стандартных шрифтов (вроде это был Ubuntu Font), который давал минимальный попиксельный diff, и применили этот шрифт для текстовых блоков (сделано в
index.html, который предназначался только для cypress-тестов). Затем повысили общий diff до 0,05% и попиксельный diff до 20%. С такими параметрами мы прожили неделю — до первого случая, когда потребовалось изменить текст в компоненте. В итоге билд остался зеленым, хотя скриншот мы не обновили. Текущее решение оказалось бесполезным.
Исходная проблема была в разных окружениях, решение в принципе напрашивается само собой — Docker. Для Cypress уже есть готовые docker-образы. Там есть разные вариации образов, нас интересует included, так как Cypress в нем уже включен в образ и не будет каждый раз заново происходить скачивание и распаковка Cypress binary (GUI Cypress запускается через бинарный файл, и скачивание вместе с распаковкой занимает больше времени, чем скачивание docker-образа).
На основе included docker-образа делаем свой docker-контейнер, для этого у себя мы сделали файл integration-tests.Dockerfile с подобным содержимым:
Хочется отметить обнуление ENTRYPOINT, это связано с тем, что он задан по умолчанию в образе cypress/included и указывает на команду cypress run, что не дает нам использовать другие команды. Также разбиваем наш dockerfile на слои, чтобы при каждом перезапуске тестов не выполнять повторно npm ci.
Добавляем .dockerignore файл (если его нет) в корень репозитория и в нем обязательно указываем node-modules/ и */node-modules/.
Для запуска в Docker наших тестов напишем bash-скрипт integration-tests.sh со следующим содержимым:
Краткое описание: билдим наш docker-контейнер integration-tests.Dockerfile и указываем volume на папку с тестами, чтобы была возможность получить созданные скриншоты из Docker.
После решения проблемы, описанной в предыдущей главе, в билдах наступило затишье, но примерно через день мы столкнулись со следующей проблемой (слева и справа — скриншоты одного компонента, сделанные в разное время):
Думаю, самые внимательные заметили, что не хватает заголовка в попапе. Причина очень простая — не успел загрузиться шрифт, так как не был подключен через assets, а находился на CDN.
Скачиваем шрифты с CDN, закидываем их в assets для cypress-конфигурации и в нашем кастомном
index.html для интеграционных тестов подключаем их. С таким решением мы прожили приличное время, пока у нас не изменился корпоративный шрифт. Второй раз проворачивать такую же историю желания не было.
Было решено начать предзагружать все необходимые шрифты для теста в
index.html для cypress-конфигурации, выглядело это примерно так:
Число падений тестов из-за не успевших загрузиться шрифтов снизилось до минимума, но не до нуля: все равно иногда шрифт не успевал загрузиться. На помощь пришло решение из KitchenSink самого Cypress — waitForResource.
В нашем случае, так как уже была подключена предзагрузка шрифтов, мы просто переопределили команду visit в Cypress, в итоге она не просто навигируется на страничку, но и ждет загрузку указанных шрифтов. Также хотелось бы дополнить, что waitForResource решает проблему не только шрифтов, но и любой загружаемой статики, например картинок (из-за них у нас также ломались скриншоты, и waitForResource отлично помогло). После применения данного решения проблем со шрифтами и любой загружаемой статикой не было.
Как раз с анимациями и связана наша головная боль, которая и остается по сей день. В какой-то момент начнут появляться скриншоты, на которых анимируется элемент, либо скриншот сделан перед началом анимации. Такие скриншоты нестабильны, и при каждом следующем сравнении с эталоном будут находиться отличия. Так по какому пути мы шли при решении вопроса, связанного с анимациями?
Самое простое, что нам пришло в голову на начальном этапе: перед созданием скриншота останавливать браузер на определенное время, чтобы анимации успели завершиться. Шли по цепочке 100ms, 200ms, 500ms и в итоге 1000ms. Оглядываясь назад, я понимаю, что это решение изначально было ужасным, но хотелось именно предостеречь вас от такого же решения. Почему ужасным? Время анимаций разное, агенты в CI тоже могут подтупливать иногда, из-за чего любое время ожидания стабилизации страницы от раза к разу было разным.
Даже с ожиданием в 1 секунду страница не всегда успевала стать стабильной. После небольшого ресерча нашли инструмент у Angular — Testability. Принцип основан на отслеживании стабильности ZoneJS:
Таким образом при создании скриншотов у нас вызывались две команды: cy.wait(1000) и cy.waitStableState().
С тех пор не было ни одного рандомно упавшего скриншота, но давайте вместе посчитаем, сколько времени тратилось на простаивание браузера. Предположим, у вас в тесте делается 5 скриншотов, для каждого есть стабильное время ожидания в 1 секунду и какое-то рандомное время, предположим 1,5 секунды в среднем (я не замерял среднее значение в реальности, поэтому взял из головы по собственным ощущениям). В итоге для создания скриншотов в тесте мы тратим дополнительно 12,5 секунды. Представим, что вы уже написали 20 тестовых сценариев, где в каждом тесте не менее 5 скриншотов. Получаем, что переплата за стабильность ~4 минуты при имеющихся 20 тестах.
Но даже не это самая большая проблема. Как уже обсуждалось выше, при локальном запуске тестов скриншоты не гоняются, а в CI — гоняются, и из-за ожиданий на каждый скриншот срабатывали колбеки в коде, например на debounce Time, что уже создавало рандомизацию в тестах, ведь в CI и локально они проходили по-разному.
Начнем с Angular-анимаций. Наш любимый фреймворк во время анимации на элементе DOM навешивает класс ng-animating. Это и стало ключом нашего решения, ведь теперь надо убедиться, что на элементе сейчас нет класса анимации. В итоге это вылилось в такую функцию:
Кажется, ничего сложного, но именно это легло в основу наших решений. На что хочется обратить внимание в таком подходе: делая скриншот, вы должны понимать, анимация какого элемента может сделать ваш скриншот нестабильным, и перед созданием скриншота добавить assertion, который проверит, что элемент не анимируется. Но анимации также могут быть и на CSS. Как говорит сам Cypress, любые assertion на элементе ждут окончания анимации на нем — подробнее тут и тут. То есть суть подхода в следующем: у нас есть анимируемый элемент, добавляем на него assertion — should(‘be.visible’)/should(‘not.be.visible’) — и Cypress сам дождется окончания анимации на элементе (возможно, кстати, решение с ng-animating не нужно и достаточно только проверок Cypress, но мы пока что используем утилиту — waitAnimation).
Как сказано в самой документации, Cypress проверяет изменение позиции элемента на странице, но не все анимации про изменение позиции, есть также fadeIn/fadeOut-анимации. В этих случаях принцип решения тот же: проверяем, что элемент виден / не виден на странице.
При переезде с решения cy.wait(1000) + cy.waitStableState() на waitAnimation и Cypress Assertion пришлось потратить ~2 часа времени на стабилизацию старых скриншотов, но как итог мы получили +20—30 секунд вместо +4 минут на время выполнения тестов. На данный момент мы тщательно подходим к ревью скриншотов: проверяем, что они выполнены не во время анимации элементов DOM и добавлены проверки в тесте на ожидание анимации. Например, мы часто добавляем отображение «скелетонов» на странице, пока данные не загрузились. Соответственно, на ревью сразу же прилетает требование, что при создании скриншотов в DOM не должен присутствовать скелетон, так как на нем находится анимация плавного исчезновения.
Проблема при таком подходе одна: не всегда удается предусмотреть все при создании скриншота и он все же может упасть в CI. Способ борьбы с этим только один — идти и тут же править создание такого скриншота, откладывать нельзя, иначе это будет накапливаться как снежный ком и в конечном счете вы просто выключите интеграционные тесты.
Возможно, вы заметили интересную особенность: разрешение скриншотов по умолчанию — 1000 × 600 px. К сожалению, есть проблема с размером окна браузера при запуске в Docker: даже если вы через Cypress поменяете размер viewport’а, это не поможет. Мы нашли решение для браузера Chrome (для Electron не удалось быстро найти работающее решение, а предложенное в данном issue у нас не завелось). Для начала надо сменить браузер для запуска тестов на Chrome:
Теперь делаем правки в plugins и получаем скриншоты размером 1440 × 900 px:
Тут все просто: если где-то отображается дата, связанная с текущей, снятый скриншот сегодня завтра упадет. Фиксим просто:
Теперь таймеры. Мы не стали заморачиваться и используем опцию blackout при создании скриншотов, например:
Используя приведенные выше рекомендации, вы сможете добиться максимальной стабильности тестов, но не 100%, ведь на тесты влияет не только ваш код, но и окружение, в котором они запускаются.
В итоге какой-то процент тестов будет изредка падать, например из-за проседаний производительности агентов в CI. В первую очередь стабилизируем тест с нашей стороны: добавляем необходимые assertion перед снятием скриншотов, но на период починки таких тестов можно использовать retry упавших тестов с помощью cypress-plugin-retries.
В прошлых главах мы научились запускать тесты одной командой и узнали про работу со скриншот-тестированием. Теперь можно взглянуть в сторону оптимизации CI. В нашем билде будет обязательно выполняться:
Взглянем на первый и второй пункт и поймем, что похожие шаги выполняются в другом вашем билде в CI — билд со сборкой приложения.
Основное отличие — это запуск не ng serve, а ng build. Таким образом, если сможем получить уже собранное приложение в билде с интеграционные тестами и поднять сервер с ним, то мы сможем сократить время выполнения билда с тестами.
Почему нам это потребовалось? Просто приложение у нас большое и выполнение
npm ci + npm start в aot-режиме на агенте в CI занимало ~15 минут, что в принципе требовало больших усилий от агента, и еще поверх этого запускались интеграционные тесты. Предположим, у вас уже написано 20+ тестов и на 19-м тесте у вас падает браузер, в котором прогоняются тесты, из-за большой нагрузки на агент. Как вы понимаете, перезапуск билда — это снова ожидание установки зависимостей и запуска приложения.
Далее я буду говорить только о скриптах на стороне приложения. Вам потребуется самим решить проблему с передачей артефактов между тасками в CI, поэтому в голове держим, что новый билд с интеграционными тестами будет иметь доступ к собранному приложению с таски по билду вашего приложения.
Нам нужна замена ng serve по поднятию сервера с нашим приложением. Вариантов много, начну с нашего первого — angular-http-server. В его настройке нет ничего сложного: устанавливаем зависимость, указываем, в какой папке лежит наша статика, указываем, на каком порту поднять приложение, и радуемся.
Этого решения нам хватило на целых 20 минут, а потом мы поняли, что какие-то запросы хотим проксировать на тестовый контур. Подключить проксирование для angular-http-server не получилось. Конечным решением стало поднятие сервера на Express. Для решения задачи использовался сам express и express-http-proxy. Раздавать нашу статику будем с помощью
express.static, в итоге получается скрипт, похожий на этот:
Интересным моментом здесь является то, что перед прослушиванием роута по baseHref приложения мы также обрабатываем все запросы и ищем запрос на index.html. Это сделано для случаев, когда в тестах осуществляется переход на страницу приложения, путь которой отличается от baseHref. Если не сделать данный трюк, то при переходе на любую страницу вашего приложения, кроме главной, будет прилетать 404 ошибка. Теперь добавим щепотку проксирования:
Чуть подробнее рассмотрим происходящее. Есть константы:
Проксируем же мы все запросы, начинающиеся на /common, причем при проксировании сохраняем тот же путь, который был у запроса, с помощью настройки proxyReqPathResolver. Если ее не использовать, то все запросы будут просто идти наhttps://qa-stand.ru.
Нам нужно было решить проблему с кастомным index.html, который мы использовали при ng serve приложения в режиме Cypress. Напишем простой скрипт на node.js. Исходными параметрами у нас был index.modern.html, требовалось превратить его в index.html и удалить оттуда ненужные скрипты:
Очень не хотелось для запуска тестов в CI делать еще раз npm ci всех зависимостей (ведь это уже делалось в таске с билдом приложения), поэтому появилась идея создать отдельную папку под все эти скрипты со своим package.json. Назовем папку, например, integration-tests-scripts и закинем туда три файла: server.js, create-index.js, package.json. Первые два файла были описаны выше, разберем теперь содержимое package.json:
В package.json присутствуют только зависимости, необходимые для прогона интеграционных тестов (с поддержкой typescript и скриншот-тестирования) и скрипты по запуску сервера, созданию index.html и известный из главы по запуску интеграционных тестов в Angular Workspace start-server-and-test.
Оборачиваем выполнение интеграционных тестов в новый Dockerfile — integration-tests-ci.Dockerfile:
Суть проста: копируем и разворачиваем папку integration-tests-scripts в корень приложения и копируем все, что необходимо для запуска тестов (у вас этот набор может отличаться). Основные отличия от предыдущего файла в том, что мы не копируем все приложение внутрь docker-контейнера, просто минимальная оптимизация времени выполнения тестов в CI.
Создаем файл integration-tests-ci.sh со следующим содержимым:
Во время запуска команды с тестами корневым станет package.json из папки integration-tests-scripts и в нем запустится команда main-app:integrations. Соответственно, так как эта папка развернется в корень, то и пути до папки со статикой вашего приложения нужно указывать с мыслью, что запускаться все будет из корня, а не из папки integration-tests-scripts.
Хочу также сделать небольшую ремарку: конечный bash-скрипт для запуска интеграционных тестов по мере его эволюции я называл по-разному. Так делать не нужно, это было сделано только для удобства чтения этой статьи. У вас должен всегда оставаться один файл, например integration-tests.sh, который вы уже развиваете. Если у вас несколько приложений в репозитории и их способы подготовки различаются, можно разруливать либо переменными в bash, либо разными файлами под каждое приложение — зависит от ваших потребностей.
Информации было много — думаю, теперь стоит подвести итог на основе написанного выше.
Подготовка средств для локального написания и прогона тестов с щепоткой скриншот-тестирования:
Прогоняем тесты поверх собранного приложения:
Сроки значительные и напрашиваются на борьбу с ними. Способов борьбы несколько, основные из них:
- Распил приложения на более мелкие продукты со своими релизными циклами.
- Покрытие продукта тестами в соответствии с тестовой пирамидой.
Последний пункт и стал темой моей статьи.
Пирамида тестирования
Как мы знаем, в пирамиде тестирования три уровня: unit-тесты, интеграционные тесты и e2e-тесты. Думаю, с юнитами знакомы многие, как и с e2e, поэтому чуть подробнее остановлюсь на интеграционных тестах.
В рамках интеграционного тестирования мы проверяем работу всего приложения через взаимодействие с UI, однако основное отличие от e2e-тестов в том, что мы не делаем реальных запросов на бэк. Делается это с целью проверить только взаимодействие всех систем на фронте, чтобы снизить количество e2e-тестов в будущем.
Для написания интеграционных тестов мы используем Cypress. В этой статьей я не буду сравнивать его с другими фреймворками, скажу только, почему он оказался у нас:
- Очень подробная документация.
- Легкий debugging тестов (у Cypress для этого сделан специальный GUI с time-travel по шагам в тесте).
Эти пункты были важны для нашей команды, так как опыт в написании интеграционных тестов у нас отсутствовал и был необходим очень простой старт. В данной статье я хочу рассказать про пройденный нами путь, про то, какие шишки набили, и поделиться рецептами по внедрению.
Начало пути
В самом начале для организации кода использовался Angular Workspace с одним приложением. После установки пакета Cypress в корне приложения появилась папка cypress с конфигурацией и тестами, на данном варианте мы остановились. При попытке подготовить в package.json скрипт, необходимый для запуска приложения и прогона поверх него тестов, мы столкнулись со следующими проблемами:
- В index.html были зашиты некоторые скрипты, которые не нужны в интеграционных тестах.
- Для запуска интеграционных тестов необходимо было убедиться, что сервер с приложением запущен.
Проблему с index.html решили через отдельную конфигурацию сборки — назовем ее сypress, — в которой указали кастомный index.html. Как это реализовать? Находим в angular.json конфигурацию вашего приложения, открываем секцию build, добавляем там отдельную конфигурацию для Cypress и не забываем указывать эту конфигурацию для serve-режима.
Пример конфигурации для build:
"build": {
...
"configurations": {
… // Другие конфигурации
"cypress": {
"aot": true,
"index": "projects/main-app-integrations/src/fixtures/index.html",
"fileReplacements": [
{
"replace": "projects/main-app/src/environments/environment.ts",
"with": "projects/main-app/src/environments/environment.prod.ts"
}
]
}
}
}
Интеграция с serve:
"serve": {
...
"configurations": {
… // Другие конфигурации
"cypress": {
"browserTarget": "main-app:build:cypress"
}
}
}
Из основного: для cypress конфигурации мы указываем aot сборку и подменяем файлы с environment — это необходимо для создания prod-like сборки при тестировании.
Итак, с index.html разобрались, осталось поднять приложения, дождаться окончания сборки и прогнать поверх него тесты. Для этого используем библиотеку start-server-and-test и на ее основе напишем скрипты:
"main-app:cy:run": "cypress run",
"main-app:cy:open": "cypress open",
"main-app:integrations": "start-server-and-test main-app:serve:cypress http://localhost:8808/app/user/ main-app:cy:run",
"main-app:integrations:open": "start-server-and-test main-app:serve:cypress http://localhost:8808/app/user/ main-app:cy:open"
Как можно заметить, тут два типа скриптов: open и run. Режим open открывает GUI самого Cypress, где можно переключаться между тестами и использовать time-travel. Режим run — это просто прогон тестов и получение конечного результата этого прогона, отлично подходит для запуска в CI.
По результатам проделанной работы мы смогли получить стартовый каркас для написания первого теста и прогона его в CI.
Монорепозиторий
У описанного подхода есть очень заметная проблема: если в репозитории два и более приложений, то подход с одной папкой нежизнеспособен. Так и произошло у нас. Но случилось это довольно интересным способом. В момент внедрения Cypress мы переезжали на NX, а этот красавец из коробки дает возможность работать с Cypress. Каков принцип работы в нем:
- У вас есть приложение, например main-app, рядом с ним создается приложение main-app-e2e.
- Переименуйте main-app-e2e в main-app-integrations — вы восхитительны.
Теперь вы можете прогонять интеграционные тесты одной командой — ng e2e main-app-integrations. NX автоматически поднимет main-app, дождется ответа и прогонит тесты.
К сожалению, остались в стороне те, кто сейчас использует Angular Workspace, но ничего страшного, у меня есть рецепт и для вас. Файловую структуру будем использовать как и у NX:
- Создаем рядом с вашим приложением папку main-app-integrations.
- Создаем в ней папку src и заносим в нее содержимое папки cypress.
- Не забываем перенести cypress.json (изначально он появится в корне) в папку main-app-integrations.
- Правим cypress.json, указывая пути до новых папок с тестами, плагинами и вспомогательными командами (параметры integrationFolder, pluginsFile и supportFile).
- Cypress умеет работать с тестами в любых папках, для указания папки используется параметр
project, поэтому меняем команду с cypress run/open на cypress run/open -–project ./projects/main-app-integrations/src.
Решение под Angular Workspace максимально похоже на решение для NX, за исключением того, что папку создаем руками и она не является одним из проектов в вашем монорепозитории. В качестве альтернативы можно напрямую использовать билдер от NX для Cypress (пример репозитория на NX с Cypress, там можно подсмотреть итоговое использование nx-cypress билдера — внимание на angular.json и проект
cart-e2e и products-e2e).
Visual Regressing
После первых пяти тестов мы задумались о скриншот-тестировании, ведь, по сути, все возможности для этого есть. Заранее скажу, что слово «скриншот-тестирование» вызывает большую боль внутри команды, так как путь к получению стабильных тестов был не самым простым. Далее я опишу основные проблемы, с которыми мы столкнулись, и их решение.
В качестве решения была взята библиотека cypress-image-snapshot. Внедрение не отняло много времени, и вот спустя 20 минут мы получили первый скриншот нашего приложения размером 1000 × 600 px. Радости было много, ведь интеграция и использование были слишком простыми, а полученная польза могла быть огромной.
После генерации пяти эталонных скриншотов мы запустили проверку в CI, как итог — билд развалился. Оказалось, что скриншоты, созданные с помощью команд open и run, отличаются. Решение было довольно простым: снимать скриншоты только в CI-режиме, для этого убрали снятие скриншотов в local-режиме, например так:
Cypress.Commands.overwrite(
'matchImageSnapshot',
(originalFn, subject, fileName, options) => {
if (Cypress.env('ci')) {
return originalFn(subject, fileName, options);
}
return subject;
},
);
В данном решении мы смотрим на параметр env в Cypress, установить его можно разными путями.
Шрифты
Локально тесты начали проходить при повторном запуске, пытаемся снова запустить их в CI. Результат можно увидеть ниже:
Довольно просто заметить разницу в шрифтах на diff-скриншоте. Эталонный скриншот был сгенерирован на macOS, а в CI на агентах установлен Linux.
Неправильное решение
Подобрали один из стандартных шрифтов (вроде это был Ubuntu Font), который давал минимальный попиксельный diff, и применили этот шрифт для текстовых блоков (сделано в
index.html, который предназначался только для cypress-тестов). Затем повысили общий diff до 0,05% и попиксельный diff до 20%. С такими параметрами мы прожили неделю — до первого случая, когда потребовалось изменить текст в компоненте. В итоге билд остался зеленым, хотя скриншот мы не обновили. Текущее решение оказалось бесполезным.
Правильное решение
Исходная проблема была в разных окружениях, решение в принципе напрашивается само собой — Docker. Для Cypress уже есть готовые docker-образы. Там есть разные вариации образов, нас интересует included, так как Cypress в нем уже включен в образ и не будет каждый раз заново происходить скачивание и распаковка Cypress binary (GUI Cypress запускается через бинарный файл, и скачивание вместе с распаковкой занимает больше времени, чем скачивание docker-образа).
На основе included docker-образа делаем свой docker-контейнер, для этого у себя мы сделали файл integration-tests.Dockerfile с подобным содержимым:
FROM cypress:included:4.3.0
COPY package.json /app/
COPY package-lock.json app/
WORKDIR /app
RUN npm ci
COPY / /app/
ENTRYPOINT []
Хочется отметить обнуление ENTRYPOINT, это связано с тем, что он задан по умолчанию в образе cypress/included и указывает на команду cypress run, что не дает нам использовать другие команды. Также разбиваем наш dockerfile на слои, чтобы при каждом перезапуске тестов не выполнять повторно npm ci.
Добавляем .dockerignore файл (если его нет) в корень репозитория и в нем обязательно указываем node-modules/ и */node-modules/.
Для запуска в Docker наших тестов напишем bash-скрипт integration-tests.sh со следующим содержимым:
docker build -t integrations -f integration-tests.Dockerfile .
docker run --rm -v $PWD/projects/main-app-integrations/src:/app/projects/main-app-integrations/src integrations:latest npm run main-app:integrations
Краткое описание: билдим наш docker-контейнер integration-tests.Dockerfile и указываем volume на папку с тестами, чтобы была возможность получить созданные скриншоты из Docker.
Снова шрифты
После решения проблемы, описанной в предыдущей главе, в билдах наступило затишье, но примерно через день мы столкнулись со следующей проблемой (слева и справа — скриншоты одного компонента, сделанные в разное время):
Думаю, самые внимательные заметили, что не хватает заголовка в попапе. Причина очень простая — не успел загрузиться шрифт, так как не был подключен через assets, а находился на CDN.
Неправильное решение
Скачиваем шрифты с CDN, закидываем их в assets для cypress-конфигурации и в нашем кастомном
index.html для интеграционных тестов подключаем их. С таким решением мы прожили приличное время, пока у нас не изменился корпоративный шрифт. Второй раз проворачивать такую же историю желания не было.
Правильное решение
Было решено начать предзагружать все необходимые шрифты для теста в
index.html для cypress-конфигурации, выглядело это примерно так:
<link
rel="preload"
href="...."
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
Число падений тестов из-за не успевших загрузиться шрифтов снизилось до минимума, но не до нуля: все равно иногда шрифт не успевал загрузиться. На помощь пришло решение из KitchenSink самого Cypress — waitForResource.
В нашем случае, так как уже была подключена предзагрузка шрифтов, мы просто переопределили команду visit в Cypress, в итоге она не просто навигируется на страничку, но и ждет загрузку указанных шрифтов. Также хотелось бы дополнить, что waitForResource решает проблему не только шрифтов, но и любой загружаемой статики, например картинок (из-за них у нас также ломались скриншоты, и waitForResource отлично помогло). После применения данного решения проблем со шрифтами и любой загружаемой статикой не было.
Анимации
Как раз с анимациями и связана наша головная боль, которая и остается по сей день. В какой-то момент начнут появляться скриншоты, на которых анимируется элемент, либо скриншот сделан перед началом анимации. Такие скриншоты нестабильны, и при каждом следующем сравнении с эталоном будут находиться отличия. Так по какому пути мы шли при решении вопроса, связанного с анимациями?
Первое решение
Самое простое, что нам пришло в голову на начальном этапе: перед созданием скриншота останавливать браузер на определенное время, чтобы анимации успели завершиться. Шли по цепочке 100ms, 200ms, 500ms и в итоге 1000ms. Оглядываясь назад, я понимаю, что это решение изначально было ужасным, но хотелось именно предостеречь вас от такого же решения. Почему ужасным? Время анимаций разное, агенты в CI тоже могут подтупливать иногда, из-за чего любое время ожидания стабилизации страницы от раза к разу было разным.
Второе решение
Даже с ожиданием в 1 секунду страница не всегда успевала стать стабильной. После небольшого ресерча нашли инструмент у Angular — Testability. Принцип основан на отслеживании стабильности ZoneJS:
Cypress.Commands.add('waitStableState', () => {
return cy.window().then(window => {
const [testability]: [Testability] = window.getAllAngularTestabilities();
return new Cypress.Promise(resolve => {
testability.whenStable(() => {
resolve();
}, 3000);
});
});
});
Таким образом при создании скриншотов у нас вызывались две команды: cy.wait(1000) и cy.waitStableState().
С тех пор не было ни одного рандомно упавшего скриншота, но давайте вместе посчитаем, сколько времени тратилось на простаивание браузера. Предположим, у вас в тесте делается 5 скриншотов, для каждого есть стабильное время ожидания в 1 секунду и какое-то рандомное время, предположим 1,5 секунды в среднем (я не замерял среднее значение в реальности, поэтому взял из головы по собственным ощущениям). В итоге для создания скриншотов в тесте мы тратим дополнительно 12,5 секунды. Представим, что вы уже написали 20 тестовых сценариев, где в каждом тесте не менее 5 скриншотов. Получаем, что переплата за стабильность ~4 минуты при имеющихся 20 тестах.
Но даже не это самая большая проблема. Как уже обсуждалось выше, при локальном запуске тестов скриншоты не гоняются, а в CI — гоняются, и из-за ожиданий на каждый скриншот срабатывали колбеки в коде, например на debounce Time, что уже создавало рандомизацию в тестах, ведь в CI и локально они проходили по-разному.
Текущее решение
Начнем с Angular-анимаций. Наш любимый фреймворк во время анимации на элементе DOM навешивает класс ng-animating. Это и стало ключом нашего решения, ведь теперь надо убедиться, что на элементе сейчас нет класса анимации. В итоге это вылилось в такую функцию:
export function waitAnimation(element: Chainable<JQuery>): Chainable<JQuery> {
return element.should('be.visible').should('not.have.class', 'ng-animating');
}
Кажется, ничего сложного, но именно это легло в основу наших решений. На что хочется обратить внимание в таком подходе: делая скриншот, вы должны понимать, анимация какого элемента может сделать ваш скриншот нестабильным, и перед созданием скриншота добавить assertion, который проверит, что элемент не анимируется. Но анимации также могут быть и на CSS. Как говорит сам Cypress, любые assertion на элементе ждут окончания анимации на нем — подробнее тут и тут. То есть суть подхода в следующем: у нас есть анимируемый элемент, добавляем на него assertion — should(‘be.visible’)/should(‘not.be.visible’) — и Cypress сам дождется окончания анимации на элементе (возможно, кстати, решение с ng-animating не нужно и достаточно только проверок Cypress, но мы пока что используем утилиту — waitAnimation).
Как сказано в самой документации, Cypress проверяет изменение позиции элемента на странице, но не все анимации про изменение позиции, есть также fadeIn/fadeOut-анимации. В этих случаях принцип решения тот же: проверяем, что элемент виден / не виден на странице.
При переезде с решения cy.wait(1000) + cy.waitStableState() на waitAnimation и Cypress Assertion пришлось потратить ~2 часа времени на стабилизацию старых скриншотов, но как итог мы получили +20—30 секунд вместо +4 минут на время выполнения тестов. На данный момент мы тщательно подходим к ревью скриншотов: проверяем, что они выполнены не во время анимации элементов DOM и добавлены проверки в тесте на ожидание анимации. Например, мы часто добавляем отображение «скелетонов» на странице, пока данные не загрузились. Соответственно, на ревью сразу же прилетает требование, что при создании скриншотов в DOM не должен присутствовать скелетон, так как на нем находится анимация плавного исчезновения.
Проблема при таком подходе одна: не всегда удается предусмотреть все при создании скриншота и он все же может упасть в CI. Способ борьбы с этим только один — идти и тут же править создание такого скриншота, откладывать нельзя, иначе это будет накапливаться как снежный ком и в конечном счете вы просто выключите интеграционные тесты.
Размер скриншотов
Возможно, вы заметили интересную особенность: разрешение скриншотов по умолчанию — 1000 × 600 px. К сожалению, есть проблема с размером окна браузера при запуске в Docker: даже если вы через Cypress поменяете размер viewport’а, это не поможет. Мы нашли решение для браузера Chrome (для Electron не удалось быстро найти работающее решение, а предложенное в данном issue у нас не завелось). Для начала надо сменить браузер для запуска тестов на Chrome:
- Не для NX делаем с помощью аргумента --browser chrome при запуске команды cypress open/run и для run-команды указываем параметр --headless.
- Для NX в конфигурации проекта в angular.json с тестами указываем параметр browser: chrome, и для конфигурации, которая будет запускаться в CI, указываем headless: true.
Теперь делаем правки в plugins и получаем скриншоты размером 1440 × 900 px:
module.exports = (on, config) => {
on('before:browser:launch', (browser, launchOptions) => {
if (browser.name === 'chrome' && browser.isHeadless) {
launchOptions.args.push('--disable-dev-shm-usage');
launchOptions.args.push('--window-size=1440,1200');
return launchOptions;
}
return launchOptions;
});
};
Даты
Тут все просто: если где-то отображается дата, связанная с текущей, снятый скриншот сегодня завтра упадет. Фиксим просто:
cy.clock(new Date(2025, 11, 22, 0).getTime(), ['Date']);
Теперь таймеры. Мы не стали заморачиваться и используем опцию blackout при создании скриншотов, например:
cy.matchImageSnapshot('salary_signing-several-payments', {
blackout: ['.timer'],
});
Flaky-тесты
Используя приведенные выше рекомендации, вы сможете добиться максимальной стабильности тестов, но не 100%, ведь на тесты влияет не только ваш код, но и окружение, в котором они запускаются.
В итоге какой-то процент тестов будет изредка падать, например из-за проседаний производительности агентов в CI. В первую очередь стабилизируем тест с нашей стороны: добавляем необходимые assertion перед снятием скриншотов, но на период починки таких тестов можно использовать retry упавших тестов с помощью cypress-plugin-retries.
Прокачиваем CI
В прошлых главах мы научились запускать тесты одной командой и узнали про работу со скриншот-тестированием. Теперь можно взглянуть в сторону оптимизации CI. В нашем билде будет обязательно выполняться:
- Команда npm ci.
- Поднятие приложения в aot-режиме.
- Запуск интеграционных тестов.
Взглянем на первый и второй пункт и поймем, что похожие шаги выполняются в другом вашем билде в CI — билд со сборкой приложения.
Основное отличие — это запуск не ng serve, а ng build. Таким образом, если сможем получить уже собранное приложение в билде с интеграционные тестами и поднять сервер с ним, то мы сможем сократить время выполнения билда с тестами.
Почему нам это потребовалось? Просто приложение у нас большое и выполнение
npm ci + npm start в aot-режиме на агенте в CI занимало ~15 минут, что в принципе требовало больших усилий от агента, и еще поверх этого запускались интеграционные тесты. Предположим, у вас уже написано 20+ тестов и на 19-м тесте у вас падает браузер, в котором прогоняются тесты, из-за большой нагрузки на агент. Как вы понимаете, перезапуск билда — это снова ожидание установки зависимостей и запуска приложения.
Далее я буду говорить только о скриптах на стороне приложения. Вам потребуется самим решить проблему с передачей артефактов между тасками в CI, поэтому в голове держим, что новый билд с интеграционными тестами будет иметь доступ к собранному приложению с таски по билду вашего приложения.
Сервер со статикой
Нам нужна замена ng serve по поднятию сервера с нашим приложением. Вариантов много, начну с нашего первого — angular-http-server. В его настройке нет ничего сложного: устанавливаем зависимость, указываем, в какой папке лежит наша статика, указываем, на каком порту поднять приложение, и радуемся.
Этого решения нам хватило на целых 20 минут, а потом мы поняли, что какие-то запросы хотим проксировать на тестовый контур. Подключить проксирование для angular-http-server не получилось. Конечным решением стало поднятие сервера на Express. Для решения задачи использовался сам express и express-http-proxy. Раздавать нашу статику будем с помощью
express.static, в итоге получается скрипт, похожий на этот:
const express = require('express');
const appStaticPathFolder = './dist';
const appBaseHref = './my/app';
const port = 4200;
const app = express();
app.use((req, res, next) => {
const accept = req
.accepts()
.join()
.replace('*/*', '');
if (accept.includes('text/html')) {
req.url = baseHref;
}
next();
});
app.use(appBaseHref, express.static(appStaticPathFolder));
app.listen(port);
Интересным моментом здесь является то, что перед прослушиванием роута по baseHref приложения мы также обрабатываем все запросы и ищем запрос на index.html. Это сделано для случаев, когда в тестах осуществляется переход на страницу приложения, путь которой отличается от baseHref. Если не сделать данный трюк, то при переходе на любую страницу вашего приложения, кроме главной, будет прилетать 404 ошибка. Теперь добавим щепотку проксирования:
const proxy = require('express-http-proxy');
app.use(
'/common',
proxy('https://qa-stand.ru', {
proxyReqPathResolver: req => '/common' + req.url,
}),
);
Чуть подробнее рассмотрим происходящее. Есть константы:
- appStaticForlderPath — папка, где находится статика вашего приложения.
- appBaseHref — возможно, у вашего приложения есть baseHref, если нет — можно указать ‘/’.
Проксируем же мы все запросы, начинающиеся на /common, причем при проксировании сохраняем тот же путь, который был у запроса, с помощью настройки proxyReqPathResolver. Если ее не использовать, то все запросы будут просто идти наhttps://qa-stand.ru.
Кастомизация index.html
Нам нужно было решить проблему с кастомным index.html, который мы использовали при ng serve приложения в режиме Cypress. Напишем простой скрипт на node.js. Исходными параметрами у нас был index.modern.html, требовалось превратить его в index.html и удалить оттуда ненужные скрипты:
const fs = require('fs');
const appStaticPathFolder = './dist';
fs.copyFileSync(appStaticPathFolder + '/index.modern.html', appStaticPathFolder + '/index.html');
fs.readFile(appStaticPathFolder + '/index.html', 'utf-8', (err, data) => {
const newValue = data
.replace(
'<script type="text/javascript" src="/auth.js"></script>',
'',
)
.replace(
'<script type="text/javascript" src="/analytics.js"></script>',
'',
);
fs.writeFileSync(appStaticPathFolder + '/index.html', newValue, 'utf-8');
});
Скрипты
Очень не хотелось для запуска тестов в CI делать еще раз npm ci всех зависимостей (ведь это уже делалось в таске с билдом приложения), поэтому появилась идея создать отдельную папку под все эти скрипты со своим package.json. Назовем папку, например, integration-tests-scripts и закинем туда три файла: server.js, create-index.js, package.json. Первые два файла были описаны выше, разберем теперь содержимое package.json:
{
"name": "cypress-tests",
"version": "0.0.0",
"private": true,
"scripts": {
"create-index": "node ./create-index.js",
"main-app:serve": "node ./server.js",
"main-app:cy:run": "cypress run --project ./projects/main-app-integrations ",
"main-app:integrations": "npm run create-index && start-server-and-test main-app:serve http://localhost:4200/my/app/ main-app:cy:run"
},
"devDependencies": {
"@cypress/webpack-preprocessor": "4.1.0",
"@types/express": "4.17.2",
"@types/mocha": "5.2.7",
"@types/node": "8.9.5",
"cypress": "4.1.0",
"cypress-image-snapshot": "3.1.1",
"express": "4.17.1",
"express-http-proxy": "^1.6.0",
"start-server-and-test": "1.10.8",
"ts-loader": "6.2.1",
"typescript": "3.8.3",
"webpack": "4.41.6"
}
}
В package.json присутствуют только зависимости, необходимые для прогона интеграционных тестов (с поддержкой typescript и скриншот-тестирования) и скрипты по запуску сервера, созданию index.html и известный из главы по запуску интеграционных тестов в Angular Workspace start-server-and-test.
Запуск
Оборачиваем выполнение интеграционных тестов в новый Dockerfile — integration-tests-ci.Dockerfile:
FROM cypress/included:4.3.0
COPY integration-tests-scripts /app/
WORKDIR /app
RUN npm ci
COPY projects/main-app-integrations /app/projects/main-app-integrations
COPY dist /app/dist
COPY tsconfig.json /app/
ENTRYPOINT []
Суть проста: копируем и разворачиваем папку integration-tests-scripts в корень приложения и копируем все, что необходимо для запуска тестов (у вас этот набор может отличаться). Основные отличия от предыдущего файла в том, что мы не копируем все приложение внутрь docker-контейнера, просто минимальная оптимизация времени выполнения тестов в CI.
Создаем файл integration-tests-ci.sh со следующим содержимым:
docker build -t integrations -f integration-tests-ci.Dockerfile .
docker run --rm -v $PWD/projects/main-app-integrations/src:/app/projects/main-app-integrations/src integrations:latest npm run main-app:integrations
Во время запуска команды с тестами корневым станет package.json из папки integration-tests-scripts и в нем запустится команда main-app:integrations. Соответственно, так как эта папка развернется в корень, то и пути до папки со статикой вашего приложения нужно указывать с мыслью, что запускаться все будет из корня, а не из папки integration-tests-scripts.
Хочу также сделать небольшую ремарку: конечный bash-скрипт для запуска интеграционных тестов по мере его эволюции я называл по-разному. Так делать не нужно, это было сделано только для удобства чтения этой статьи. У вас должен всегда оставаться один файл, например integration-tests.sh, который вы уже развиваете. Если у вас несколько приложений в репозитории и их способы подготовки различаются, можно разруливать либо переменными в bash, либо разными файлами под каждое приложение — зависит от ваших потребностей.
Итог
Информации было много — думаю, теперь стоит подвести итог на основе написанного выше.
Подготовка средств для локального написания и прогона тестов с щепоткой скриншот-тестирования:
- Ставим зависимость для Cypress.
- Подготавливаем папку с тестами:
- Angular Single Application — оставляем все в папке cypress.
- Angular Workspace — создаем папку имя приложения-integrations рядом с приложением, на которое будут гоняться тесты, и переносим в нее все из папки cypress.
- NX — переименовываем проект из имя приложения-e2e в имя-приложения-integrations.
- Кастомная cypress-сборка для поднятия приложения — делаем в build-разделе конфигурацию Cypress, указываем там aot, подмену на свой отдельный index.html, подмену файла environment на prod-файл и указываем в serve-разделе сборку Cypress (данный пункт необходим, если вам нужны какие-то отличия от prod-сборки, в противном случае можно пропустить).
- Скрипты по запуску и прогону тестов:
- Angular Single Application — готовим скрипт с serve-ом cypress-сборки и запуску тестов, комбинируем все это с помощью start-server-and-test.
- Angular Workspace — аналогично с Angular Single Application, только указываем путь до тестов при запуске cypress run/open.
- NX — запускаем тесты с помощью команд ng e2e.
- Подмешиваем скриншот-тестирование:
- Добавляем зависимость cypress-image-snapshot.
- Переопределяем стандартную команду по сравнению скриншотов для запуска ее только в CI.
- В тестах не делаем скриншотов на рандоме. Если скриншоту предшествует анимация, обязательно ждем ее — например, добавляем Cypress Assertion на анимируемый элемент.
- Дату мокируем через cy.clock либо используем опцию blackout при снятии скриншота.
- Любую подгружаемую статику в runtime ожидаем через кастомную команду cy.waitForResource (картинки, шрифты и т. д.).
- Оборачиваем все это в Docker:
- Готовим Dockerfile.
- Создаем bash-файл.
Прогоняем тесты поверх собранного приложения:
- В CI учимся прокидывать между билдами артефакты собранного приложения (остается на вас).
- Готовим папку integration-tests-scripts:
- Скрипт по поднятию сервера вашего приложения.
- Скрипт по изменению вашего index.html (если вас устраивает изначальный index.html — можно скипать).
- Добавляем в папку package.json с необходимыми скриптами и зависимостями.
- Готовим новый Dockerfile.
- Создаем bash-файл.
Полезные ссылки
- Angular Workspace + Cypress + CI — в данном примере я создал базу для Angular Workspace приложения с накрученным CI на основе скриптов, описанных в статье (без поддержки typescript).
- Cypress — обратите внимание на раздел trade-offs.
- Start-server-and-test — запуск сервера, ожидание ответа и запуск тестов.
- Cypress-image-snapshot — библиотека для скриншот-тестирования.
- Cypress recipes — готовые наработки по Cypress, чтобы не изобретать свои велосипеды.
- Flaky Tests — статья про нестабильные тесты, в конце есть ссылка на статью от Google.
- Github Action — Cypress дает возможность использовать предустановленные конфиги для GitHub Action, в README куча примеров, из интересного — встроенная поддержка wait-on. Также есть пример с docker.