Привет! Меня зовут Ксения Якиль. Я пишу core-сервисы на C и Go в бэкенд-отделе Badoo и Bumble. Наш бэкенд — это высоконагруженная распределённая система, обслуживающая пользователей по всему миру. Она оперирует большими массивами данных и делает всю ту магию, благодаря которой люди находят друг друга.
В этой статье я не буду концентрироваться на специфике наших сервисов, а расскажу, как мы реализовали автоматизированные интеграционные тесты в распределённой системе, поделюсь общими принципами и нюансами создания фреймворка для них. В тексте будут встречаться отсылки к реализации на Go, так как мы использовали именно этот язык, но понять основную мысль это не помешает.
Знакомьтесь, сервис М!
В нашем ведении находился один из очень важных сервисов — M. Он был первоисточником информации о пользователях и их симпатиях друг к другу. М был самодостаточным, большим, надёжным и даже был покрыт юнит- и функциональными тестами.
Сервис состоит из фронта (Front) и нескольких шардов (S1…SN):
Но с увеличением количества задач в одиночку М перестал справляться так хорошо, как раньше. Поэтому у него появились товарищи — другие сервисы: мы выделили отдельные логические части M и обернули их в сервисы на Go (Search и Supervisor), добавили Kafka и Consul.
И стало так:
За короткий промежуток времени из простого сервиса на С выросла довольно сложная структура, состоящая уже из пяти компонентов и ещё большего числа инстансов. В итоге мы получили распределённую систему и ряд вопросов в подарок:
Работает ли функционал, в котором участвуют несколько сервисов?
Поднимается ли система в заданной конфигурации?
Что будет, если один из сервисов вернёт некорректный ответ?
Что сделает наша система, если один из сервисов будет недоступен: вернёт ожидаемые ошибки, повторит отправку, выберет другой инстанс и отправит запрос туда или вернет закешированные данные?
Мы знаем, как должна вести себя система. Но совпадают ли ожидания и реальность?
Проверять это вручную долго и дорого, но не проверить нельзя — стоимость ошибки на продакшене велика, расследование таких ошибок занимает много времени. В общем, нужна автоматизация.
Появилось и другое узкое место: мы начали параллельно разрабатывать компоненты и очень сильно загрузили отдел тестирования. Это привело к увеличению сроков доставки новых фич на продакшен.
Поэтому мы решили создать автоматизированные интеграционные тесты для сервисов М и Ко и убить одним выстрелом двух зайцев: автоматически выявлять ошибки интеграции до продакшена и сократить сроки доставки фич на прод за счёт совместного с отделом тестирования написания интеграционных тестов.
Мы понимали, что наряду со всеми очевидными плюсами у интеграционных тестов есть не менее очевидные минусы: увеличение времени прогона тестов, нестабильность, сложность написания. Но этого было недостаточно, чтобы остановить нас.
Требования к фреймворку
Какие требования мы предъявляли к интеграционному фреймворку?
Легковесность: минимум абстракций и простота добавления новых тестов.
Обозримое (по возможности небольшое) время прохождения тестов. Требовались быстро поднять инфраструктуру и осуществить прогон тестов.
Запуск разных конфигураций системы. Фреймворк должен позволять настраивать каждый сервис, запускать разные наборы сервисов (подсистемы) и на них отдельно прогонять тесты. Путь от простого к сложному: сначала убеждаемся, что работает небольшая подсистема, потом усложняем её, проверяем и так далее.
Реализация на языке Go, поскольку наш отдел разработки пишет на нём. Нам Go очень нравится, а тестировщики быстро его освоили и тоже пишут на нем фреймворк и интеграционные тесты.
С высоты МКС (схематичный план)
Фреймворк для интеграционных тестов, в отличие от юнит-тестирования, перед каждым тестом должен поднимать инфраструктуру, предоставлять доступ к ней во время тестов, а также очищать её после. Нам было важно иметь возможность работать с инфраструктурой во время прохождения тестов, реализовывать много сценариев и проверять работоспособность системы в различных конфигурациях.
Модули нашего фреймворка предоставляют всё необходимое для генерации данных, ожидания выполнения запросов, проверки ответов, работы с инфраструктурой и многое другое. Поскольку фреймворк написан на Go, мы решили использовать go testing, а сами тесты поместили в отдельные файлы.
Для настройки и очистки окружения мы используем модуль Testify. Он позволяет создать suite, в котором определены функции:
SetupSuite. Вызывается до прохождения всех тестов для данного suite. Именно здесь мы будем осуществлять подготовку окружения.
TearDownSuite. Вызывается после прохождения всех тестов для suite. Тут мы почистим за собой инфраструктуру.
SetupTest. Вызывается перед каждым тестом для suite. Здесь мы можем осуществлять какую-то локальную подготовку к тесту.
TearDownTest. Вызывается после прохождения каждого теста в suite. Поскольку в рамках теста мы можем поднимать дополнительные сервисы или менять конфигурацию текущих, то в этой функции очень удобно возвращать окружение к дефолтному состоянию для текущего suite.
Собираем инфраструктуру
Инфраструктура должна предоставлять много возможностей:
Настраивать разную конфигурацию наших сервисов.
Поднимать сторонние сервисы (наподобие Kafka и Consul). Если использовать инстансы внешних сервисов на devel, то проведение интеграционного тестирования может влиять на его состояние. Это приведёт к нестабильному и неожиданному для наших коллег поведению системы. Кроме того, на результаты наших интеграционных тестов смогут влиять действия других отделов — придётся тратить больше времени на расследование падений. Повысить стабильность и воспроизводимость тестов можно с помощью изоляции двух сред. Поэтому мы хотели использовать отдельные запущенные инстансы в своей тестовой среде. В качестве бонуса это даёт возможность использовать сервисы любой версии и конфигурации, быстрее проверять гипотезы и не согласовывать изменения с другими отделами.
Работать с этой инфраструктурой: остановить Kafka/Consul/свои сервисы, исключить их из сети или включить в сеть. Нужна большая вариативность.
Запускать на разных машинах, например на машинах разработчиков, QA-инженеров и CI.
Воспроизводить падения тестов. Если тестировщик увидел на своей машине, что тест не прошёл, разработчик должен с минимальными усилиями получить эту ошибку на своей машине. Мы хотели избежать различий в библиотеках и зависимостях на разных машинах (в том числе и на серверах для CI).
Мы решили использовать Docker и обернули сервисы в контейнеры: тесты будут создавать свою сеть (Docker network) для каждого прогона и включать контейнеры в неё. Это хорошая изоляция для тестов из коробки.
Запуск в контейнере
Во фреймворке мы запускаем сервис в контейнере с помощью модуля testcontainers-go, который по факту представляет собой прослойку между Docker и нашими тестами на Go.
Отправляем запрос в этот модуль с характеристикой нашего сервиса. Получаем структуру контейнера и полный спектр возможностей: запускать и останавливать контейнер, узнавать его статус, включать в сеть, исключать из сети и так далее. Вся эта реализация — под капотом у testcontainers-go.
Для других языков программирования есть свои модули. Скорее всего, принцип их работы примерно такой же (но это не точно).
Рабочее окружение
Недостаточно просто поднять сервис в контейнере. Нужно подготовить для него тестовое окружение.
Создаём иерархию каталогов на хосте.
Копируем все необходимые данные для нашего сервиса (скрипты, файлы, снепшоты и т.д.) в соответствующие директории.
Создаём дефолтный файл конфигурации и тоже помещаем его в эту иерархию.
Монтируем корень этой иерархии на хосте в Docker-контейнер.
Таким образом, наш сервис имеет доступ ко всем подготовленным данным. На момент его запуска он уже будет обладать дефолтным файлом конфигурации и всеми необходимыми скриптами, если они у него есть и нужны для работы.
Конфигурация
Здесь мы использовали простое решение.
Через Entrypoint задаём переменные окружения, аргументы запуска и подготовленный файл конфигурации. Когда контейнер поднимется, он выполнит всё, что указано в Entrypoint.
После этого сервис можно считать сконфигурированным. Пример:
Адрес сервиса
Итак, сервис поднялся в контейнере. У него есть рабочее окружение и определённая конфигурация для теста. Как найти другие сервисы?
Внутри Docker network всё просто.
При создании контейнера мы генерируем ему уникальное имя и к этому имени обращаемся как к адресу: используем имя контейнера как hostname.
Порты мы знаем заранее, так как на предыдущем этапе подготавливали файлы конфигурации и указывали порты для наших сервисов.
Запуск самих тестов у нас происходит вне контейнера для уменьшения оверхеда. Но можно запускать их и в контейнере — тогда тесты узнают адреса сервисов, как описано выше.
Если тесты запускаются на локальной машине, они не могут обращаться к сервису по имени его контейнера, так как адресация по имени контейнера внутри Docker network — это абстракция самого Docker. Нам нужно найти номер порта на локальном хосте, который соответствует порту сервиса в Docker network. После поднятия контейнера мы получим соответствие внутреннего порта сервиса (inner port) порту на локальном хосте (external port). Последний и будем использовать в тестах.
Внешние сервисы
Наверняка в вашей инфраструктуре присутствуют сторонние сервисы, например базы данных и service discovery. Их конфигурация в идеале должна совпадать с той, что на продакшене. Если сервис простой (например, Consul в конфигурации одного процесса) мы его тоже можем запустить с помощью testcontainers-go. Но если сервис многокомпонентный (например, Kafka из нескольких брокеров, где требуется ZooKeeper), то можно не страдать и использовать для этого Docker Compose.
Как правило, в процессе интеграционного тестирования не требуется обширный доступ к работе с внешними сервисами, так что Docker Compose хорошо подходит для наших целей.
Фаза загрузки
Контейнер поднят. Но означает ли это, что сервис готов принимать наши запросы? В общем случае — нет. У многих сервисов есть фаза инициализации. Она может занимать продолжительное время. Если мы не дожидаемся окончания загрузки сервиса и запускаем тестирование, то получаем нестабильное поведение тестов.
Что делать?
Самое простое решение — использовать sleep. После запуска контейнера ждём некоторое время. Как только оно прошло, считаем, что сервисы готовы к работе. Это плохое решение, поскольку запуск тестов происходит на разных машинах, а скорость загрузки сервисов может изменяться как в большую, так и в меньшую сторону.
Открывать порты сервиса по мере готовности. Как только сервис прошёл фазу загрузки и готов принимать запросы клиентов, он открывает порты. Для тестового окружения это знак разрешения на запуск тестов. Однако есть нюанс: при создании контейнера Docker сразу открывает external port для сервиса, даже если последний ещё не начал слушать соответствующий internal port в контейнере. Поэтому в тестах сразу будет установлено соединение и попытка чтения из соединения приведёт к EOF. Когда сервис откроет internal port, тестовый фреймворк сможет отправить запрос. Только после этого мы будем считать, что сервис готов к работе.
Запрашивать статус сервиса. Cервис сразу открывает порты, на запрос статуса отвечает «Готов», если уже загрузился, и «Не готов», если нет. В тестах мы будем периодически спрашивать сервис о его статусе и, как только получим ответ «Готов», перейдём к фазе тестирования.
Регистрировать в стороннем сервисе или базе данных. Мы регистрируем сервисы в Consul. Можно использовать:
Факт появления сервиса в Consul как сигнал о готовности. Состояние сервиса можно отслеживать с помощью блокирующего запроса с тайм-аутом. Как только сервис зарегистрируется, Consul пришлет ответ на запрос с информацией об изменении статуса сервиса.
Анализ состояния сервиса с помощью проверки его check’a. Фреймворк для интеграционного тестирования получает информацию о новом сервисе из пункта 1 и начинает отслеживать изменения его статуса. Когда статусы всех сервисов будут “passing”, считаем, что они готовы к работе.
Второй и третий подходы подразумевают совершение периодически повторяющихся действий, до тех пор пока условие не выполнится. Между повторами есть фаза ожидания. Она короче, чем при использовании первого подхода, что позволяет не зависеть от работы конкретной машины и автоматически подстраиваться под скорость загрузки сервиса.
Во всех подходах время ожидания готовности сервиса ограничено максимально разрешённым временем запуска сервиса в любой среде.
Поднятие всех сервисов
Мы обсудили, как подготавливать всё необходимое для работы сервиса, как его запускать и узнавать о его готовности к работе. Поднимать мы можем как свои, так и сторонние сервисы, знаем адреса сервисов как внутри тестовой среды, так и из тестов.
В какой последовательности осуществлять запуск? Идеальный вариант — не иметь строгой последовательности. Это позволяет запускать сервисы параллельно и значительно сократить время создания инфраструктуры (время запуска контейнера + время загрузки сервиса). Чем меньше связей, тем проще добавлять новый сервис в инфраструктуру.
Каждый сервис должен быть готов к тому, что в момент его запуска в тестовой среде может не оказаться сторонних сервисов, которые ему нужны. Поэтому сервис должен уметь ждать их появления. Конечно, стоит исключить дедлок, когда сервис А ожидает доступности сервиса B, и наоборот. В такой ситуации проблемы могут возникнуть и на продакшене.
Инфраструктура во время тестирования
Во время прохождения тестов мы хотим работать с нашей инфраструктурой: залезть в неё и поиграться. Когда, если не сейчас?
Изменение конфигурации сервиса
Для этого достаточно остановить сервис, настроить его таким образом, как мы делали на этапе подготовки инфраструктуры, и поднять. Нужно иметь в виду, что за каждое изменение конфигурации приходится платить временем из-за оверхеда по причине двойного старта — для смены конфигурации во время теста и в конце теста при откате к предыдущей конфигурации. Стоит несколько раз подумать, хотим мы менять настройку сервиса именно здесь или лучше сгруппировать тесты на одну и ту же конфигурацию системы в отдельном suite.
Добавление нового сервиса
Нам уже ничего не стоит добавить новый сервис. Мы научились создавать сервисы на этапе настройки инфраструктуры. Здесь точно такой же сценарий: подготавливаем окружение для нового сервиса, запускаем контейнер и работаем с ним во время прохождения тестов.
Работа с сетью
Включение контейнеров в сеть и их исключение из неё, приостановка (pause/unpause) работы контейнеров, iptables позволяют нам эмулировать сетевые ошибки и проверять реакцию системы на них.
Инфраструктура после тестирования
Если в рамках одного теста мы добавили новый сервис, не нужно передавать его по наследству следующему тесту: нужно быть вежливыми и убрать за собой. Это касается и данных. Тесты могут запускаться в произвольном порядке и не должны влиять друг на друга, прогоны должны быть воспроизводимыми.
Если было изменение конфигурации сервиса, делаем откат на предыдущую (дефолтную) конфигурацию.
Если было добавление нового сервиса, удаляем его.
Если были любые изменения в сети (iptables, приостановка контейнеров и т. д.), отменяем их.
Если были добавлены или изменены данные, запускаем очистку. Тут важно иметь механизм определения её завершения, чтобы обязательно его дождаться. Например, в случае очистки данных в стороннем сервисе базы данных недостаточно просто отправить запрос на удаление — нужно удостовериться, что он отработан (а не застрял в очереди, в то время как мы уже новый тест запустили и он успел обратиться к данным, которые вот-вот будут удалены).
После завершения test suite работа всех сервисов в инфраструктуре тоже завершается, контейнеры убиваются, тестовая сеть опускается. Если test suite не успел завершиться по истечении тайм-аута или в результате ошибки, действуем точно так же. Только в случае явного указания фреймворку оставить контейнеры после прогона (например, для отладки), инфраструктура остаётся.
Ускорение тестов
Ждать вечность, пока пройдут интеграционные тесты, как правило, никому не хочется. Хотя в это время можно выпить кофе и сделать ещё много чего интересного.
Что мы можем сделать для ускорения тестов?
Группировать read-only-тесты и запускать их параллельно в рамках одного теста (в Go при помощи горутин это делается максимально просто). Эти тесты должны работать на изолированном множестве данных.
Предоставлять обширную настройку сервиса. Тогда в тестах мы сможем выставить меньшие значения тайм-аута, retry и ожидания со стороны сервиса, что сократит и время прохождения тестов.
Запускать сервисы в необходимой и достаточной конфигурации. Например, если на некоторых инстансах в продакшене сервис запущен с четырьмя шардами, а для конкретного теста достаточно только факта многошардовости, то достаточно будет и двух шардов.
Запускать несколько тестовых инфраструктур параллельно (если позволяют ресурсы). По сути, это параллельный прогон 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 выглядит примерно так:
go test -count=1 -race -v ./testsuite $(TESTSFLAGS) -autotests.timeout=15m
Поскольку сервисы коллег из других отделов мы хотели тоже перевести на наш фреймворк, core-функционал фреймворка был вынесен в общий core-репозиторий.
Отладка
Ура! Мы научились подготавливать инфраструктуру и прогонять тесты. Приятно видеть «зелёный» результат интеграции. Но так бывает не всегда, поэтому начинаем разбираться с падениями. Первое, что приходит на ум, — изучить логи сервисов.
Предположим, suite содержит огромное количеством тестов, и один из них не получил от сервиса ожидаемого ответа. Где-то в недрах лога сервиса нам нужно найти кусочек, который соответствует времени прохождения упавшего теста. В этом помогает простой и удобный инструмент — маркеры.
Добавляем в сервис команду log_notice, при получении которой он записывает в свой лог сообщение из запроса.
Перед началом теста отправляем log_notice с названием теста во все поднятые сервисы. По окончании теста делаем то же самое.
Теперь у нас есть маркеры внутри лога — можно легко восстановить ход событий и воспроизвести поведение сервиса при необходимости.
Как быть, если сервис не смог подняться и не успел сделать запись в лог? Скорее всего, он записал в stderr/stdout дополнительную информацию. Команда docker logs позволяет получать данные из стандартных потоков ввода-вывода — это поможет нам понять, что случилось.
А теперь предположим, что данных из логов не достаточно для локализации ошибки. Время обратиться к более серьёзным методам!
Указываем в конфигурации фреймворка необходимость оставлять инфраструктуру после прогона всех тестов в suite. Благодаря этому мы получаем полный доступ к системе. Можно узнать статус сервиса, получить данные из него, отправлять различные запросы, анализировать файлы сервиса на диске, а так же использовать gdb/strace/tcpdump и профилирование. Дальше мы строим гипотезу, пересобираем образ, запускаем тесты и итеративно находим корень проблемы.
Чтобы отладка не превращалась в мучительный отлов багов, тесты должны быть максимально воспроизводимыми. Например, если данные генерируются с помощью random, в случае ошибки нужно выводить информацию о seed и/или о том, какие данные были запрошены.
QA
Как с интеграционным фреймворком работает тестировщик? Ему не нужно самостоятельно вручную поднимать все сервисы. Интеграционные тесты делают это за него и помогают создать нужную инфраструктуру. Если suite на запланированную инфраструктуру ещё не написан, он быстро добавляет его сам.
После того как тестовая среда настроена, QA-инженер реализует сложные сценарии в тесте. Во время работы у него есть доступ к логам и всем файлам сервиса — это удобно для отладки и для понимания происходящего с системой.
Помимо проверки прохождения тестов на текущей ветке кода, есть возможность указать конкретные версии сервисов и прогнать интеграцию для них.
Чтобы ускорить работу, сначала наши разработчики пишут positive-тесты, а затем тестировщики проверяют более сложные кейсы. Совместная разработка тестов и фреймворка в действии.
CI
Оказалось, что встроить интеграционные тесты в CI очень просто. Мы используем TeamCity. Код фреймворка находится в репозитории с кодом сервисов. Сначала собираются сервисы, создаются образы, далее происходят сборка фреймворка и наконец его запуск.
Мы научили TeamCity понимать по выводу тестового фреймворка, какие тесты прошли, а какие нет. После окончания прогона отображается, сколько и каких тестов не прошло. Данные всех сервисов после прогона каждого suite сохраняются и публикуются в TeamCity в качестве артефактов для конкретной сборки и прогона.
Итоги
Ниже — результаты проделанной работы.
Жить стало спокойнее. Меньше проблем с интеграцией просачивается на продакшен. Как следствие — более стабильный прод.
Мы научились поднимать разную инфраструктуру и покрывать больше сценариев за меньшее время.
Мы работаем с инфраструктурой во время прохождения тестов. Это даёт больше возможностей для реализации разных тест-кейсов.
Мы ловим больше багов на этапе разработки. Positive-сценарии пишут сами разработчики, отлавливая часть ошибок и сразу их решая. Уменьшается round-trip бага.
Мы избавили тестировщиков от написания positive-кейсов. QA-инженеры могут сосредоточиться на более сложных сценариях.
Перестали блокироваться на этапе тестирования, когда параллельно разрабатываются задачи для разных сервисов и переводятся на QA-инженеров примерно в одно время.
Мы написали MVP фреймворка для интеграционного тестирования довольно быстро — за пару недель. Задача оказалась не слишком трудоёмкой.
Мы используем фреймворк уже больше года.
В общем, фреймворк экономит наше время и даёт больше уверенности. Мы улучшаем его и расширяем область применения, добавляем интеграционные тесты для других сервисов компании.
Однако интеграционное тестирование имеет ряд минусов, которые стоит учитывать.
Увеличение времени прогона тестов. Системы сложные, запросы выполняются в нескольких сервисах.
Нестабильность, так как система состоит из асинхронных компонентов. С этим можно и нужно бороться. Мы тоже это делаем: доля нестабильных тестов у нас близка к нулю.
Сложность написания тестов. Нужно понимать, как работает система в целом, каково её ожидаемое поведение и как её можно сломать.
Возможность расхождения инфраструктуры в тестах и на продакшене. Если не все сервисы на проде в контейнерах, то тестовое окружение не на 100% совпадает с продакшеном. У нас как раз часть сервисов на проде не в контейнерах, но мы пока не сталкивались с проблемами из-за их тестирования в контейнерах.
И, наконец, главный вопрос, на который нужно ответить: необходим ли вам фреймворк для интеграционных тестов?
Если в вашем проекте увеличивается (или уже заметно увеличилось) количество сервисов, множатся связи между ними и требуется автоматизация тестирования, значит, стоит попробовать реализовать интеграционные тесты.
Надеюсь, что эта статья дала вам представление о тех задачах, которые предстоит решать на этом пути, и о методах их решения.
Успехов и удачи!