Стабильность и скорость ― это то, что ожидается от автотестов. В этой статье я расскажу о нашей стратегии по оптимизации тестовой пирамиды, о том, почему мы сделали выбор в пользу Cypress и какие выработали подходы к написанию тестов, а также о запуске тестов на инфраструктуре AWS.
Введение в Cypress
Если отбросить капитанское определение, что Cypress — это JavaScript-фреймворк для тестирования, то важно отметить, что при работе с ним мы видим на экране браузер. Он не обязательно открыт, он может быть headless, но он есть, и он открывает особое приложение самого Cypress, которое состоит из нескольких фреймов: в одном фрейме открывается продукт, который мы тестируем, в другом фрейме запускаются тесты. Код тестов пишется на JavaScript, поэтому они могут выполняться непосредственно в браузере — ведь это нативный для него язык.
Так с помощью JavaScript API производятся все манипуляции, которые делаются в тестах, то есть заполнение форм, клики и тому подобное.
Преимущества Cypress
Нет Selenium WebDriver
Очевидное отличие Cypress от тех библиотек и фреймворков, которые мы использовали раньше — это отсутствие основного действующего элемента, Selenium.
Selenium WebDriver — это third-party сервис на Java, который обращается к браузеру по WebDriver протоколу. Это накладывает ограничения на работу с браузером в рамках протокола. Сетевое взаимодействие также вносит свой вклад во время выполнения тестов.
Изначально Selenium был создан не специально для тестов, а как общий инструмент автоматизации для браузера. Cypress, в отличие от него, сфокусирован на решении конкретной задачи, а именно, на создании end-to-end (е2е) тестов для интерфейса web-приложений.
Все в одном
Cypress не нужно собирать из кусочков — он принес все достаточно современные "батарейки" с собой:
Синтаксис BDD (унаследовано из Mocha): describe(), context(), it().
А также хуки: before(), beforeEach().
Использовать такой DSL привычно для тех, кто уже писал юнит-тесты на JavaScript.Библиотека ассертов (унаследовано из Chai). Например:
expect(name).to.not.equal("Jane") — ожидание того, что элемент не существует — это не то же самое, что ожидание неудачи при проверке существования элемента. Если элемента нет, то это хорошо, это не нужно перепроверять, а нужно идти дальше.
Такую задачу должен решать тестовый фреймворк, и этого нам очень не хватало в старой самописной библиотеке, при использовании которой многое ложится на плечи разработчика теста.Перехват, отслеживание (spy) и подмена (mock) запросов браузера к бэкенду.
Development experience
Главное преимущество Cypress — это отличный development experience. Написать первый тест для своего проекта (неважно, на каком языке написан сам проект) можно минут за 10. Потребуется добавить одну зависимость в package.json (npm install cypress), прочитать документацию про то, куда складывать файлы (cypress/integration/login.spec.js), и написать код в 5 строчек:
describe('Login', () => {
it('should log in with credentials', () => {
cy.visit('/login');
cy.get('[name=login_name]').type(Cypress.env('login'));
cy.get('[name=passwd]').type(Cypress.env('password'));
cy.get('[name=send]').click();
cy.get('.main-header').should('be.visible');
});
});
Получаем реальный тест, который посещает страницу логина, заполняет форму, нажимает на кнопку и видит результат.
На скриншоте браузера было видно, что все шаги теста логируются. Но это не просто лог — это навигация, по которой после прохождения теста можно вернуться в любую точку и увидеть, что происходило в браузере. Например, можно посмотреть снэпшоты до и после Ajax-запроса.
Приятной мелочью является то, что каждый cy.get() убеждается, что страница загрузилась, и делает несколько попыток, чтобы найти элемент. С каждым годом интерфейсы веб-приложений становятся все сложнее. Результирующий HTML формируется не на стороне сервера, а на стороне браузера. Делается это асинхронно и с использованием различных библиотек компонентов. В какой момент тот или иной элемент интерфейса появится на экране, сказать уже становится сложнее.
Одна из Best Practices говорит, что не нужно никогда писать таймаут типа "подождать 2 секунды". Абсолютно все таймауты должны ждать чего-то осязаемого, например, окончания Ajax-запроса. Можно подписаться на событие, которое случается в коде продукта. Например, когда нам через веб-сокет прилетает событие с бэкенда, то срабатывает определенный listener на фронтенде.
Вся документация Cypress и Best Practices находятся на одном сайте docs.cypress.io — хотелось бы отдельно отметить высокое качество этой документации, а также мастер классов, которые команда разработки Cypress проводит и публикует в открытом доступе.
Кроме того, один из паттернов, от которых стоит отказаться — это PageObject. Долгое время он считался обязательным, но для новых тестов он нам уже не понадобится.
Чуть позже мы вернемся к нашим собственным наработанным Best Practices, а сейчас хочется немного отвлечься и поговорить про тестовую пирамиду и то, ради чего мы все это делаем и какова наша цель.
Тестовая пирамида
Когда говорят про тестовую пирамиду, то обычно приводят в пример анти-паттерн "перевернутая пирамида" или "стаканчик мороженого". То есть на нижнем уровне в таком примере количество юнит тестов стремится к нулю. Лично мне этот случай кажется невероятным для зрелого проекта: ведь в этом случае разработчики должны были полность отказаться писать самые простейшие тесты — откуда тогда взялись сложные е2е тесты?
Как бы то ни было, к нам это не относится — у нас несколько тысяч PHPUnit-тестов с покрытием около 12% строк кода.
В то же время у нас есть еще несколько тысяч е2е-тестов с Selenium, которые проверяют все возможные конфигурации продукта, занимают кучу времени (подмножество, запускаемое на каждый коммит, мы смогли оптимизировать до 40-60 минут), имеют довольно слабый уровень доверия (с вероятностью 30-40% тесты упадут, хотя коммит не содержит причины этого падения) и покрывают около 30% строк кода.
Получается, наше положение выглядит, как песочные часы — нам не хватает среднего слоя в тестировании, где интеграционные тесты проверяют компоненты системы независимо друг от друга. Это горлышко песочных часов и хочется заполнить с помощью Cypress. При этом еще хочется что-то сделать с существующими е2е тестами, чтобы "заострить" вершину пирамиды. То есть, важный акцент здесь в том, что Cypress не является заместителем старого фреймворка: мы не хотим просто взять и переписать все тесты на Cypress — иначе мы так и останемся на шарике мороженого. Цель тестов — по-прежнему, проверять регрессию в продукте, но проверять на другом уровне, чтобы выполняться быстрее и получать результат раньше, а также быть легче в сопровождении.
Наш подход к написанию тестов
Проект, о котором идет речь, это контрольная панель Plesk. Она предоставляет пользователям интерфейс для управления хостингом веб сайтов. Функциональность панели доступна не только через UI, но и через API и CLI, которые используются для автоматизации.
Мы начали с того, что сделали следующие предположения:
Тесты на Cypress относятся чисто к UI. Мы не относим сюда тесты, у которых шаги выполняются через API или CLI.
Мы не проводим никакой дополнительной валидации, кроме той, что выполняется средствами UI. Например, если мы проверяем создание домена, то мы не отправляем запросы для проверки Web-сервера или DNS, мы считаем тест пройденным, если в UI появилось сообщение на зеленом фоне о том, что домен создан успешно. Такой подход избавляет нас от предварительной подготовки и написания тестовых сценариев.
На первом этапе мы автоматизируем только позитивные сценарии. Негативные сценарии не представляют ценности для клиента, но при этом тесты для их проверки занимают драгоценное время. Так что переносим такие сценарии в нижнюю часть пирамиды – они, как правило, легко проверяются юнит-тестами.
Наш опыт работы с Cypress в сочетании с официальными рекомендациями привел нас к использованию следующего набора практик:
Сбрасывать состояние продукта
Мы сбрасываем состояние продукта до исходного перед запуском каждого набора тестов (Cypress рекомендует делать это перед запуском каждого теста, но мы используем облегченный вариант). Мы создаем дамп базы данных и восстанавливаем его перед прогоном каждого набора тестов (test suite / spec). Это занимает порядка 5 секунд.
before(cy.resetInstance);
// => test_helper --reset-instance
// => cat /var/lib/psa/dumps/snapshot.sql | mysql
Такой откат к прежнему состоянию может в общем случае привести Plesk в нерабочее состояние, т.к. помимо общей базы есть еще, например, базы отдельных утилит, а также бэкапы, которые хранятся в файловой системе. Но нас это устраивает, т.к. мы используем Cypress только для тестирования UI.
Использовать фикстуры
Вместо реальных объектов в качестве пререквизитов теста мы используем фикстуры — сохраненные структуры, которые содержат необходимое состояние базы данных. Например, для выполнения некоторых тестов необходимо наличие домена. Вместо создания реального домена мы воссоздаем все необходимые записи в базе данных, но не трогаем файловую систему и другие системные сервисы. Это занимает меньше секунды (для сравнения, создание полноценного домена заняло бы около 30 секунд).
cy.setupData(subscription).as('subscription');
// => test_helper --setup-data < {domains: [{ id: 1, name: "example.com" }]}
Такие объекты не будут выполнять полноценные пользовательские сценарии, но для тестирования UI их будет достаточно.
Использовать прямые URL
Мы не используем навигацию и попадаем в нужные места UI по прямым URL-ам. Мы вызываем свою специальную команду login, которая создает сессию, а затем переходим прямо на нужную страницу.
beforeEach(() => {
cy.login();
cy.visit('/admin/my-profile/');
});
В старом фреймворке мы бы использовали PageObject для входа в главное меню, а затем переходили бы из него к нужному элементу. Здесь же этого не требуется, так как мы тестируем только необходимую страницу. Единственное дублирование — это команда login, но это не выглядит проблемой.
Фронтенд без бэкенда
Иногда бывает сложно создать условия для определенного состояния, которое мы хотим протестировать. Например, для проверки доступных обновлений. Намного легче подсунуть заготовленный ответ для Ajax-запроса, чем готовить инфраструктуру для обновлений.
const lastChecked = 'Jan 29, 2021 04:42 PM';
cy.intercept('POST', '/admin/home/check-for-updates', {
status: 'success',
lastChecked,
newVersion: null,
whatsNewUrl: null,
}).as('checkForUpdates');
cy.get('[data-name="checkForUpdates"]').click();
cy.wait('@checkForUpdates');
cy.get('[data-name="lastCheckedDate"]').should('contain', lastChecked);
Пока не все данные доставляются через Ajax, а фронтенд не является полноценным SPA, но мы движемся в этом направлении. И именно такой подход в тестировании фронтенда с помощью заготовленных ответов бэкенда кажется нам наиболее перспективным, так как он позволит вообще не запускать бэкенд и ускорить выполнение тестов.
Стабильность тестов
Скорее всего, когда вы начнете писать Cypress тесты, то каждый второй тест будет неожиданно становиться flaky, то есть иногда проходить, а иногда ― нет. Чтобы избежать такой нестабильности, мы используем следующие практики.
Дожидаться выполнения Ajax-запроса
Многие формы в нашем продукте отправляются с помощью Ajax-запросов без перехода страницы. Чтобы тест гарантированно прошел, необходимо перехватить этот запрос и дождаться его завершения. Так как в Cypress мы проверяем только то, что происходит в UI, мы дожидаемся нужного нам сообщения.
В примере ниже мы перехватываем запрос по созданию клиента, сразу после нажатия на кнопку дожидаемся выполнения запроса и только после этого проверяем сообщение о том, что клиент создан.
cy.intercept('POST', '/admin/customer/create').as('customerCreate');
cy.get('[name=send]').click();
cy.wait('@customerCreate');
cy.get('.msg-box.msg-info').should('be.visible');
Дожидаться исчезновения индикатора загрузки
Кое-где в нашем интерфейсе фоновые операции, например, обновление списка, сопровождаются анимированным индикатором загрузки ("крутилкой"). Именно на таких страницах после окончания Ajax-запроса случается ошибка "element has been detached from the DOM" при попытке Cypress кликнуть на элементы списка. Поэтому мы добавляем после Ajax-запроса дополнительную строку, которая проверяет, что индикатор загрузки не виден.
cy.get('.ajax-loading').should('not.be.visible');
Мы надеемся, что проблема будет исправлена на стороне Cypress и нам больше не придется за этим следить.
Ajax-запросы после окончания теста
После окончания каждого теста браузер остается на последней открытой странице. Cypress сбрасывает состояние браузера (включая куки) и запускает следующий тест, где первым шагом мы сбрасываем состояние продукта (включая сессии бэкенда).
До того момента, когда следующий тест сделает первый вызов "cy.visit()", предыдущая страница остается открытой и может отправлять Ajax-запросы (например, периодическое обновление), которые будут падать из-за ошибки авторизации (куки нет, сессии нет).
В качестве workaround можно переходить на пустую страницу, чтобы браузер сбрасывал все активные Ajax-запросы. Для этого добавляем в support/index.js
afterEach(() => {
cy.window().then(win => {
win.location.href = 'about:blank';
});
});
Первые результаты
За 3 человеко-месяца (3 итерации) мы получили следующие результаты:
335 тестов на Cypress (разбиты на 84 спеки)
Пайплайн полностью выполняется за 35-40 минут, из которых сами тесты занимают 20 минут
Запуск пайплайна на каждый пулл-реквест в блокирующем режиме (то есть нельзя мержить без успешного прохождения тестов)
Уровень доверия выше 95% (то есть вероятность flaky падения ниже 5%)
Покрытие интерфейса 35% (ниже расскажу подробнее)
Пайплайн для запуска тестов
За время разработки пайплайн для запуска тестов прошел несколько этапов развития. Требованием было уложиться в час, иначе ожидание мержа в пулл реквесте слишком затягивается.
Как и для большинства наших задач, пайплайн запускается в Jenkins и хранится в Jenkinsfile вместе с кодом проекта.
Линейный пайплайн
В первом заходе получился простой линейный пайплайн.
Мы запускаем Docker-контейнер с Plesk в фоновом режиме и ждем, когда он будет доступен в локальной сети. Потом запускаем другой контейнер с Cypress и кодом тестов, он подключается к Plesk и выполняет все тесты, а мы ждем его завершения (не делаем detach).
Мы запускали тесты на машине с 12 ядрами, которая используется у нас для сборки Plesk и ряда его служб. В течении рабочего дня у нас бывает до 20-30 сборок. В результате Load Average достигал 20, и многие соседние процессы "вставали". Мы добавили ограничение на количество исполняемых сборок до 3-5. Но и этого оказалось недостаточно, соседи по железу продолжали жаловаться на нагрузку.
Тогда мы унесли запуск тестов на выделенный сервер в AWS с 4 ядрами внутри VPC с доступом в нашу офисную сеть. Тем самым мы избавились от недовольных соседей, но сборки тестов так и продолжили подолгу ждать в очереди, время от времени не укладываясь в таймаут.
Пайплайн с параллельными шагами
Чтобы как-то ускорить процесс, мы решили воспользоваться Jenkins EC2 Fleet plugin, который предоставляет Jenkins slave ноду по требованию из Autoscaling Group в AWS и уничтожает неактивные ноды после некоторого простоя. Такой подход позволяет тратить деньги на аренду ресурсов только тогда, когда они необходимы.
Переход на spot-инстансы позволил нам существенно сэкономить: вместо $150 в месяц за ondemand c5.xlarge, мы стали тратить около $60 за c5.xlarge и более мощные c5.2xlarge.
А главное, мы можем делать столько одновременных запусков, сколько нам нужно.
Разворачивание новой ноды занимает порядка 2 минут. Мы сделали в пайплайне несколько шагов параллельными, чтобы за это время успеть собрать продукт и быть готовыми к его установке в Docker на новой ноде.
Однако число наших тестов росло, и время пайплайна неизбежно росло вместе с ним, так что нужно было искать новые шаги для его ускорения.
Пайплайн с параллельными тестами
В Cypress есть платная фича – параллельный запуск тестов с помощью Cypress Dashboard. Но мы пошли простым и бесплатным путем — перечисляем файлы с тестами при запуске контейнера, при этом первый запускает все четные файлы, второй — все нечетные.
cypress run --spec $(find 'cypress/integration' -type f -name '*.js' | awk '(NR - ${RUNNER}) % ${TOTAL_RUNNERS} == 0' | tr '\n' ',')
Получилась матричная сборка, где для каждой оси (axis) запускается свой контейнер с Plesk и свой контейнер с определенным набором тестов.
В итоге мы укладываемся в приемлемые 35-40 минут для всего пайплайна, а время одной пачки тестов занимает примерно 20 минут.
В дальнейшем с увеличением количества тестов нам, очевидно, потребуется больше параллельных потоков, и мы планируем использовать Amazon ECS для запуска контейнеров, чтобы не ограничиваться EC2 инстансом.
Измерение URL coverage
В нашем проекте используется много различных языков программирования и анализ code coverage является больной темой, потому что сбор данных требует специальных билдов и объединения отчетов с нескольких конфигураций.
Для анализа тестового покрытия UI мы решили воспользоваться продуктовой аналитикой и сравнить данные, полученные от тестовых инсталляций, с данными от реальных пользователей. У нас уже был сервис, аналогичный Google Analytics, для сбора пользовательских метрик, а тестовые данные складывались отдельно и никем не использовались. Из множества метрик мы отфильтровали события о посещенных URL-ах (страницах) продукта, начали сохранять эти данные в удобном для нас виде в базу данных и составлять отчет по посещенным адресам.
По полученным данным, за счет всего автоматического и ручного тестирования внутри компании мы покрываем около 60% URL-ов, которые посещают реальные пользователи в течении месяца. Наши старые тесты покрывают около 25%, а новые тесты на Cypress уже достигли 35%.
Эта статистика помогает нам планировать дальнейшее тестирование – например, более посещаемые страницы мы планируем автоматизировать в первую очередь.
Следующие шаги
Ускорить сборку Docker
Одна из проблем, над которой мы хотим поработать – ускорение сборки контейнеров Docker. Как уже было сказано выше, мы создаем временный сервер в AWS (slave node) для каждой сборки Docker, и эта сборка на данный момент занимает в среднем 8 минут. Но поскольку каждый временный сервер – новый, то мы совершенно не используем преимущества кэширования, а хотелось бы ими воспользоваться. Поэтому сейчас мы исследуем возможность использования BuildKit. Альтернативными решениями могут стать Kaniko или AWS CodeBuild.
Сократить количество е2е тестов
Мы хотим уменьшить количество старых е2е тестов, чтобы вершина тестовой пирамиды стала более острой. Сейчас около трети этих тестов относятся к UI. Кроме этого, тесты содержат проверки через командную строку (CLI), причем иногда логика тестов в UI и CLI дублируется.
Основная идея: перенести все UI-тесты в Cypress, а в старом фреймворке оставить только CLI-тесты с детальными проверками. Поэтому для каждого UI-теста из старого фреймворка мы делаем следующее:
Заменяем UI-шаги на CLI (если это возможно).
Удаляем, если уже есть аналогичный тест с CLI.
Если проверка возможна только через UI – уносим ее в Cypress.
Например, при создании домена проверяется то, что он резолвится, и что на нем работают определенные скрипты. Эти проверки останутся только для создания домена через CLI. А тест на UI в Cypress будет проверять только появление сообщения о создании домена.
В результате мы избавимся от дублирования тестовых сценариев, сократим нагрузку на сервера с Selenium и в перспективе совсем от них избавимся, когда тестирование UI будет делать только Cypress.
Заключение
Cypress подкупает быстрым стартом к написанию тестов и удобными средствами для отладки. При этом не стоит забывать, что скорость и стабильность тестов зависят от того, как они написаны: легковесные фикстуры и подготовленные ответы бэкенда дают большое ускорение, а сброс состояния позволяет избежать случайного влияния тестов друг на друга.
Cypress позволяет организовать как полноценные e2e тесты с прохождением пользовательских сценариев на реальном продукте, так и интеграционное тестирование отдельных компонентов фронтенда. Лучше заранее выбрать цель и договориться о правилах игры для всей команды.
Кроме того, стоит отметить, что запуск тестов потребляет довольно много ресурсов CPU, поэтому стоит заранее оценить количество параллельных запусков и спланировать масштабирование инфраструктуры.