Система под контролем: как автоматизировать интеграционные тесты |
20.11.2020 00:00 |
Оригинальная публикация Привет! Меня зовут Ксения Якиль. Я пишу core-сервисы на C и Go в бэкенд-отделе Badoo и Bumble. Наш бэкенд — это высоконагруженная распределённая система, обслуживающая пользователей по всему миру. Она оперирует большими массивами данных и делает всю ту магию, благодаря которой люди находят друг друга. В этой статье я не буду концентрироваться на специфике наших сервисов, а расскажу, как мы реализовали автоматизированные интеграционные тесты в распределённой системе, поделюсь общими принципами и нюансами создания фреймворка для них. В тексте будут встречаться отсылки к реализации на Go, так как мы использовали именно этот язык, но понять основную мысль это не помешает.
Знакомьтесь, сервис М!В нашем ведении находился один из очень важных сервисов — M. Он был первоисточником информации о пользователях и их симпатиях друг к другу. М был самодостаточным, большим, надёжным и даже был покрыт юнит- и функциональными тестами. Сервис состоит из фронта (Front) и нескольких шардов (S1…SN): Но с увеличением количества задач в одиночку М перестал справляться так хорошо, как раньше. Поэтому у него появились товарищи — другие сервисы: мы выделили отдельные логические части M и обернули их в сервисы на Go (Search и Supervisor), добавили Kafka и Consul. И стало так:
За короткий промежуток времени из простого сервиса на С выросла довольно сложная структура, состоящая уже из пяти компонентов и ещё большего числа инстансов. В итоге мы получили распределённую систему и ряд вопросов в подарок:
Мы знаем, как должна вести себя система. Но совпадают ли ожидания и реальность? Проверять это вручную долго и дорого, но не проверить нельзя — стоимость ошибки на продакшене велика, расследование таких ошибок занимает много времени. В общем, нужна автоматизация. Появилось и другое узкое место: мы начали параллельно разрабатывать компоненты и очень сильно загрузили отдел тестирования. Это привело к увеличению сроков доставки новых фич на продакшен. Поэтому мы решили создать автоматизированные интеграционные тесты для сервисов М и Ко и убить одним выстрелом двух зайцев: автоматически выявлять ошибки интеграции до продакшена и сократить сроки доставки фич на прод за счёт совместного с отделом тестирования написания интеграционных тестов.
Требования к фреймворкуКакие требования мы предъявляли к интеграционному фреймворку?
С высоты МКС (схематичный план)Фреймворк для интеграционных тестов, в отличие от юнит-тестирования, перед каждым тестом должен поднимать инфраструктуру, предоставлять доступ к ней во время тестов, а также очищать её после. Нам было важно иметь возможность работать с инфраструктурой во время прохождения тестов, реализовывать много сценариев и проверять работоспособность системы в различных конфигурациях. Модули нашего фреймворка предоставляют всё необходимое для генерации данных, ожидания выполнения запросов, проверки ответов, работы с инфраструктурой и многое другое. Поскольку фреймворк написан на Go, мы решили использовать go testing, а сами тесты поместили в отдельные файлы. Для настройки и очистки окружения мы используем модуль Testify. Он позволяет создать suite, в котором определены функции:
Собираем инфраструктуруИнфраструктура должна предоставлять много возможностей:
Мы решили использовать Docker и обернули сервисы в контейнеры: тесты будут создавать свою сеть (Docker network) для каждого прогона и включать контейнеры в неё. Это хорошая изоляция для тестов из коробки. Запуск в контейнереВо фреймворке мы запускаем сервис в контейнере с помощью модуля testcontainers-go, который по факту представляет собой прослойку между Docker и нашими тестами на Go. Отправляем запрос в этот модуль с характеристикой нашего сервиса. Получаем структуру контейнера и полный спектр возможностей: запускать и останавливать контейнер, узнавать его статус, включать в сеть, исключать из сети и так далее. Вся эта реализация — под капотом у testcontainers-go. Для других языков программирования есть свои модули. Скорее всего, принцип их работы примерно такой же (но это не точно). Рабочее окружениеНедостаточно просто поднять сервис в контейнере. Нужно подготовить для него тестовое окружение.
Таким образом, наш сервис имеет доступ ко всем подготовленным данным. На момент его запуска он уже будет обладать дефолтным файлом конфигурации и всеми необходимыми скриптами, если они у него есть и нужны для работы. КонфигурацияЗдесь мы использовали простое решение. Через Entrypoint задаём переменные окружения, аргументы запуска и подготовленный файл конфигурации. Когда контейнер поднимется, он выполнит всё, что указано в Entrypoint. После этого сервис можно считать сконфигурированным. Пример: Адрес сервисаИтак, сервис поднялся в контейнере. У него есть рабочее окружение и определённая конфигурация для теста. Как найти другие сервисы? Внутри Docker network всё просто.
Запуск самих тестов у нас происходит вне контейнера для уменьшения оверхеда. Но можно запускать их и в контейнере — тогда тесты узнают адреса сервисов, как описано выше. Если тесты запускаются на локальной машине, они не могут обращаться к сервису по имени его контейнера, так как адресация по имени контейнера внутри Docker network — это абстракция самого Docker. Нам нужно найти номер порта на локальном хосте, который соответствует порту сервиса в Docker network. После поднятия контейнера мы получим соответствие внутреннего порта сервиса (inner port) порту на локальном хосте (external port). Последний и будем использовать в тестах. Внешние сервисыНаверняка в вашей инфраструктуре присутствуют сторонние сервисы, например базы данных и service discovery. Их конфигурация в идеале должна совпадать с той, что на продакшене. Если сервис простой (например, Consul в конфигурации одного процесса) мы его тоже можем запустить с помощью testcontainers-go. Но если сервис многокомпонентный (например, Kafka из нескольких брокеров, где требуется ZooKeeper), то можно не страдать и использовать для этого Docker Compose. Как правило, в процессе интеграционного тестирования не требуется обширный доступ к работе с внешними сервисами, так что Docker Compose хорошо подходит для наших целей. Фаза загрузкиКонтейнер поднят. Но означает ли это, что сервис готов принимать наши запросы? В общем случае — нет. У многих сервисов есть фаза инициализации. Она может занимать продолжительное время. Если мы не дожидаемся окончания загрузки сервиса и запускаем тестирование, то получаем нестабильное поведение тестов. Что делать?
Второй и третий подходы подразумевают совершение периодически повторяющихся действий, до тех пор пока условие не выполнится. Между повторами есть фаза ожидания. Она короче, чем при использовании первого подхода, что позволяет не зависеть от работы конкретной машины и автоматически подстраиваться под скорость загрузки сервиса. Во всех подходах время ожидания готовности сервиса ограничено максимально разрешённым временем запуска сервиса в любой среде. Поднятие всех сервисовМы обсудили, как подготавливать всё необходимое для работы сервиса, как его запускать и узнавать о его готовности к работе. Поднимать мы можем как свои, так и сторонние сервисы, знаем адреса сервисов как внутри тестовой среды, так и из тестов. В какой последовательности осуществлять запуск? Идеальный вариант — не иметь строгой последовательности. Это позволяет запускать сервисы параллельно и значительно сократить время создания инфраструктуры (время запуска контейнера + время загрузки сервиса). Чем меньше связей, тем проще добавлять новый сервис в инфраструктуру. Каждый сервис должен быть готов к тому, что в момент его запуска в тестовой среде может не оказаться сторонних сервисов, которые ему нужны. Поэтому сервис должен уметь ждать их появления. Конечно, стоит исключить дедлок, когда сервис А ожидает доступности сервиса B, и наоборот. В такой ситуации проблемы могут возникнуть и на продакшене. Инфраструктура во время тестированияВо время прохождения тестов мы хотим работать с нашей инфраструктурой: залезть в неё и поиграться. Когда, если не сейчас? Изменение конфигурации сервиса Для этого достаточно остановить сервис, настроить его таким образом, как мы делали на этапе подготовки инфраструктуры, и поднять. Нужно иметь в виду, что за каждое изменение конфигурации приходится платить временем из-за оверхеда по причине двойного старта — для смены конфигурации во время теста и в конце теста при откате к предыдущей конфигурации. Стоит несколько раз подумать, хотим мы менять настройку сервиса именно здесь или лучше сгруппировать тесты на одну и ту же конфигурацию системы в отдельном suite. Добавление нового сервиса Нам уже ничего не стоит добавить новый сервис. Мы научились создавать сервисы на этапе настройки инфраструктуры. Здесь точно такой же сценарий: подготавливаем окружение для нового сервиса, запускаем контейнер и работаем с ним во время прохождения тестов. Работа с сетью Включение контейнеров в сеть и их исключение из неё, приостановка (pause/unpause) работы контейнеров, iptables позволяют нам эмулировать сетевые ошибки и проверять реакцию системы на них. Инфраструктура после тестированияЕсли в рамках одного теста мы добавили новый сервис, не нужно передавать его по наследству следующему тесту: нужно быть вежливыми и убрать за собой. Это касается и данных. Тесты могут запускаться в произвольном порядке и не должны влиять друг на друга, прогоны должны быть воспроизводимыми.
После завершения test suite работа всех сервисов в инфраструктуре тоже завершается, контейнеры убиваются, тестовая сеть опускается. Если test suite не успел завершиться по истечении тайм-аута или в результате ошибки, действуем точно так же. Только в случае явного указания фреймворку оставить контейнеры после прогона (например, для отладки), инфраструктура остаётся. Ускорение тестовЖдать вечность, пока пройдут интеграционные тесты, как правило, никому не хочется. Хотя в это время можно выпить кофе и сделать ещё много чего интересного. Что мы можем сделать для ускорения тестов?
В тестах мы запускаем мок на определённом адресе. Этот адрес уже поднятые сервисы в текущей инфраструктуре узнают через конфиг или service discovery (Consul в нашем случае) и могут отправлять на него запросы. Мок получает запрос и вызывает handler, который мы указали. На Go в тесте это выглядит примерно так: Handler из примера считает, что получает запрос статистики, обрабатывает его согласно логике тестов, формирует ответ и указывает серверу, какое действие нужно с ним выполнить: отправить сразу или с задержкой; не отправлять вовсе; завершить соединение. Контроль над действиями сервера, будь то завершение соединения или медленная отправка, даёт дополнительную возможность проверить реакцию тестируемых сервисов на сбои в работе сети. Сервер выполняет запрошенные действия, пакует ответ от handler и отправляет его клиенту. Мок (сервер) уничтожается в defer по окончании теста. Мы используем моки для всех наших сервисов — это помогает выиграть много времени при тестировании. РеализацияНаш фреймворк располагается в том же репозитории, что и тестируемые сервисы, — лежит в отдельной директории autotests. Внутри неё есть несколько модулей: Service позволяет настроить всё необходимое для каждого сервиса, чтобы его запустить и остановить, сконфигурировать, получить информацию о его данных. Mock содержит реализацию мока-сервера для каждого нестороннего сервиса. В suite находится общая реализация. Он умеет работать с сервисами, ожидать их загрузки, проверять работоспособность и многое другое.Environment хранит информацию о текущем тестовом окружении (какие сервисы запущены), отвечает за сеть. Также есть вспомогательные модули и те, что оказывают помощь в генерации данных. Помимо модулей самого фреймворка, на момент написания статьи у нас были созданы 21 test suites, в том числе и smoke test suite. Каждый создаёт свою инфраструктуру с необходимым набором сервисов. Тесты находятся в файлах внутри test suite. Запуск конкретного test suite выглядит примерно так:
Поскольку сервисы коллег из других отделов мы хотели тоже перевести на наш фреймворк, core-функционал фреймворка был вынесен в общий core-репозиторий. ОтладкаУра! Мы научились подготавливать инфраструктуру и прогонять тесты. Приятно видеть «зелёный» результат интеграции. Но так бывает не всегда, поэтому начинаем разбираться с падениями. Первое, что приходит на ум, — изучить логи сервисов. Предположим, suite содержит огромное количеством тестов, и один из них не получил от сервиса ожидаемого ответа. Где-то в недрах лога сервиса нам нужно найти кусочек, который соответствует времени прохождения упавшего теста. В этом помогает простой и удобный инструмент — маркеры.
Теперь у нас есть маркеры внутри лога — можно легко восстановить ход событий и воспроизвести поведение сервиса при необходимости. Как быть, если сервис не смог подняться и не успел сделать запись в лог? Скорее всего, он записал в stderr/stdout дополнительную информацию. Команда docker logs позволяет получать данные из стандартных потоков ввода-вывода — это поможет нам понять, что случилось. А теперь предположим, что данных из логов не достаточно для локализации ошибки. Время обратиться к более серьёзным методам! Указываем в конфигурации фреймворка необходимость оставлять инфраструктуру после прогона всех тестов в suite. Благодаря этому мы получаем полный доступ к системе. Можно узнать статус сервиса, получить данные из него, отправлять различные запросы, анализировать файлы сервиса на диске, а так же использовать gdb/strace/tcpdump и профилирование. Дальше мы строим гипотезу, пересобираем образ, запускаем тесты и итеративно находим корень проблемы. Чтобы отладка не превращалась в мучительный отлов багов, тесты должны быть максимально воспроизводимыми. Например, если данные генерируются с помощью random, в случае ошибки нужно выводить информацию о seed и/или о том, какие данные были запрошены. QAКак с интеграционным фреймворком работает тестировщик? Ему не нужно самостоятельно вручную поднимать все сервисы. Интеграционные тесты делают это за него и помогают создать нужную инфраструктуру. Если suite на запланированную инфраструктуру ещё не написан, он быстро добавляет его сам. После того как тестовая среда настроена, QA-инженер реализует сложные сценарии в тесте. Во время работы у него есть доступ к логам и всем файлам сервиса — это удобно для отладки и для понимания происходящего с системой. Помимо проверки прохождения тестов на текущей ветке кода, есть возможность указать конкретные версии сервисов и прогнать интеграцию для них. Чтобы ускорить работу, сначала наши разработчики пишут positive-тесты, а затем тестировщики проверяют более сложные кейсы. Совместная разработка тестов и фреймворка в действии. CIОказалось, что встроить интеграционные тесты в CI очень просто. Мы используем TeamCity. Код фреймворка находится в репозитории с кодом сервисов. Сначала собираются сервисы, создаются образы, далее происходят сборка фреймворка и наконец его запуск. Мы научили TeamCity понимать по выводу тестового фреймворка, какие тесты прошли, а какие нет. После окончания прогона отображается, сколько и каких тестов не прошло. Данные всех сервисов после прогона каждого suite сохраняются и публикуются в TeamCity в качестве артефактов для конкретной сборки и прогона. ИтогиНиже — результаты проделанной работы.
В общем, фреймворк экономит наше время и даёт больше уверенности. Мы улучшаем его и расширяем область применения, добавляем интеграционные тесты для других сервисов компании. Однако интеграционное тестирование имеет ряд минусов, которые стоит учитывать.
И, наконец, главный вопрос, на который нужно ответить: необходим ли вам фреймворк для интеграционных тестов? Если в вашем проекте увеличивается (или уже заметно увеличилось) количество сервисов, множатся связи между ними и требуется автоматизация тестирования, значит, стоит попробовать реализовать интеграционные тесты. Надеюсь, что эта статья дала вам представление о тех задачах, которые предстоит решать на этом пути, и о методах их решения. Успехов и удачи! |