В проектах, связанных с разработкой микросервисной архитектуры, CI/CD переходит из разряда приятной возможности в категорию острой необходимости. Автоматическое тестирование является неотъемлемой частью непрерывной интеграции, грамотный подход к которой способен подарить команде множество приятных вечеров с семьёй и друзьями. В противном же случае, проект рискует быть никогда не завершенным.
Можно покрыть весь код микросервиса юнит-тестами с мок-объектами, но это лишь частично решает задачу и оставляет множество вопросов и сложностей, особенно при тестировании работы с данными. Как всегда, наиболее острые – тестирование консистентности данных в реляционной БД, тестирование работы с облачными сервисами и неверные предположения при написании мок-объектов.
Все это и немного больше решается тестированием целого микросервиса в Docker-контейнере. Несомненным преимуществом для обеспечения валидности тестов является то, что тестам подвергаются те же самые Docker-образы, что идут в продакшен.
Автоматизация такого подхода представляет ряд проблем, решение которых будет описано чуть ниже:
- конфликты параллельных задач в одном докер-хосте;
- конфликты идентификаторов в БД при итерациях теста;
- ожидание готовности микросервисов;
- объединение и вывод логов во внешние системы;
- тестирование исходящих HTTP-запросов;
- тестирование веб-сокетов (с помощью SignalR);
- тестирование аутентификации и авторизации OAuth.
Это статья по мотивам моего выступления на SECR 2019. Так что для тех, кому лень читать, вот запись выступления.
В статье я расскажу, как при помощи скрипта запустить в Docker тестируемый сервис, базу данных и сервисы Amazon AWS, затем тесты на Postman и после их завершения остановить и удалить созданные контейнеры. Тесты выполняются при каждом изменении кода. Таким образом, мы убеждаемся, что каждая версия корректно работает с базой данных и сервисами AWS.
Один и тот же скрипт запускают как сами разработчики на своих Windows-десктопах, так и сервер Gitlab CI под Linux.
Чтобы внедрение новых тестов было оправдано, оно не должно требовать установки дополнительных инструментов ни на компьютере разработчика, ни на сервере, где тесты запускаются при коммите.Docker решает эту задачу.
Тест должен работать на локальном сервере по следующим причинам:
- Сеть не бывает абсолютно надежной. Из тысячи запросов один может не пройти;
Автоматический тест в таком случае не пройдет, работа остановится, придется искать причину в логах; - Слишком частые запросы не допускаются некоторыми сторонними сервисами.
Кроме того, задействовать стенд нежелательно, потому что:
- Сломать стенд может не только плохой код, работающий на нем, но и данные, которые правильный код не может обработать;
- Как бы мы ни старались возвращать назад все изменения, сделанные тестом, в ходе самого теста, что-то может пойти не так (иначе, зачем тест?).
О проекте и организации процесса
Наша компания разрабатывала микросервисное веб-приложение, работающее в Docker в облаке Amazon AWS. На проекте уже использовались юнит-тесты, однако часто возникали ошибки, которые юнит-тесты не обнаруживали. Требовалось тестировать целый микросервис вместе с базой данных и сервисами Amazon.
На проекте применяется стандартный процесс непрерывной интеграции, включающий тестирование микросервиса при каждом коммите. После назначения задачи разработчик вносит изменения в микросервис, сам его тестирует вручную и запускает все имеющиеся автоматические тесты. При необходимости разработчик изменяет тесты. Если проблемы не обнаружены, делается коммит в ветку данной задачи. После каждого коммита на сервере автоматически запускаются тесты. Мерж в общую ветку и запуск автоматических тестов на ней происходит после успешного ревью. Если тесты на общей ветке прошли, сервис автоматически обновляется в тестовом окружении на Amazon Elastic Container Service (стенде). Стенд необходим всем разработчикам и тестировщикам, и ломать его нежелательно. Тестировщики на этом окружении проверяют фикс или новую фичу, выполняя ручные тесты.
Архитектура проекта
Приложение состоит из более чем десяти сервисов. Некоторые из них написаны на .NET Core, а некоторые на NodeJs. Каждый сервис работает в Docker-контейнере в Amazon Elastic Container Service. У каждого своя база Postgres, а у некоторых еще и Redis. Общих баз нет. Если нескольким сервисам нужны одни и те же данные, то эти данные в момент их изменения передаются каждому из этих сервисов через SNS (Simple Notification Service) и SQS (Amazon Simple Queue Service), и сервисы сохраняют их в свои обособленные базы.
SQS и SNS
SQS позволяет по протоколу HTTPS класть сообщения в очередь и читать сообщения из очереди.
Если несколько сервисов читают одну очередь, то каждое сообщение приходит только одному из них. Такое полезно при запуске нескольких экземпляров одного сервиса для распределения нагрузки между ними.
Если нужно, чтобы каждое сообщение доставлялось нескольким сервисам, у каждого получателя должна быть своя очередь, и для дублирования сообщений в несколько очередей нужен SNS.
В SNS вы создаете topic и подписываете на него, например, SQS-очередь. В topic можно отправлять сообщения. При этом сообщение отправляется в каждую очередь, подписанную на этот topic. В SNS нет метода для чтения сообщений. Если в процессе отладки или тестирования требуется узнать, что отправляется в SNS, то можно создать SQS-очередь, подписать ее на нужный topic и читать очередь.
API Gateway
Большинство сервисов недоступно напрямую из интернета. Доступ осуществляется через API Gateway, который проверяет права доступа. Это тоже наш сервис, и для него тоже есть тесты.
Уведомления в реальном времени
Приложение использует SignalR, чтобы показывать пользователю уведомления в реальном времени. Это реализовано в сервисе уведомлений. Он доступен напрямую из интернета и сам работает с OAuth, потому что встроить поддержку Web-сокетов в Gateway оказалось нецелесообразно, по сравнению с интеграцией OAuth и сервиса уведомлений.
Известный подход к тестированию
Юнит-тесты подменяют мок-объектами такие вещи, как база данных. Если микросервис, например, пытается создать запись в таблице с внешним ключом, а записи, на которую этот ключ ссылается, не существует, то запрос не может быть выполнен. Юнит-тесты не могут это обнаружить.
В статье от Microsoft предлагается использовать in-memory базу и внедрять мок-объекты.
In-memory база – эта одна из СУБД, которые поддерживает Entity Framework. Она создана специально для тестов. Данные в такой базе хранятся только до завершения процесса, использующего ее. В ней не нужно создавать таблицы, и целостность данных не проверяется.
Мок-объекты моделируют замещаемый класс лишь настолько, насколько разработчик теста понимает его работу.
Как добиться автоматического запуска Postgres и выполнения миграции при запуске теста, в статье от Microsoft не указано. Мое решение делает это и, кроме того, в сам микровервис не добавляется никакой код специально для тестов.
Переходим к решению
В процессе разработки стало понятно, что юнит-тестов недостаточно, чтобы своевременно находить все проблемы, поэтому было решено подойти к этому вопросу с другой стороны.
Настройка тестового окружения
Первая задача – развернуть тестовое окружение. Шаги, которые необходимы для запуска микросервиса:
- Настроить тестируемый сервис на локальное окружение, в переменных окружения указываются реквизиты для подключения к базе и AWS;
- Запустить Postgres и выполнить миграцию, запустив Liquibase.
В реляционных СУБД прежде, чем записывать данные в базу, нужно создать схему данных, проще говоря, таблицы. При обновлении приложения таблицы нужно привести к виду, используемому новой версией, причем, желательно, без потери данных. Это называется миграция. Создание таблиц в изначально пустой базе – частный случай миграции. Миграцию можно встроить в само приложение. И в .NET, и в NodeJS есть фреймворки для миграции. В нашем случае в целях безопасности микросервисы лишены права менять схему данных, и миграция выполняется с помощью Liquibase. - Запустить Amazon LocalStack. Это реализация сервисов AWS для запуска у себя. Для LocalStack есть готовый образ в Docker Hub.
- Запустить скрипт для создания в LocalStack необходимых сущностей. Shell-скрипты используют AWS CLI.
Для тестирования на проекте используется Postman. Он был и раньше, но его запускали вручную и тестировали приложение, уже развернутое на стенде. Этот инструмент позволяет делать произвольные HTTP(S)-запросы и проверять соответствие ответов ожиданиям. Запросы объединяются в коллекцию, и можно запустить всю коллекцию целиком.
Как устроен автоматический тест
Во время теста в Docker работает все: и тестируемый сервис, и Postgres, и инструмент для миграции, и Postman, а, вернее, его консольная версия – Newman.
Docker решает целый ряд проблем:
- Независимость от конфигурации хоста;
- Установка зависимостей: докер скачивает образы с Docker Hub;
- Возврат системы в исходное состояние: просто удаляем контейнеры.
Docker-compose объединяет контейнеры в виртуальную сеть, изолированную от интернета, в которой контейнеры находят друг друга по доменным именам.
Тестом управляет shell-скрипт. Для запуска теста под Windows используем git-bash. Таким образом, достаточно одного скрипта и для Windows и для Linux. Git и Docker установлены у всех разработчиков на проекте. При установке Git под Windows устанавливается git-bash, так что он тоже у всех есть.
Скрипт выполняет следующие шаги:
- Построение докер-образов
docker-compose build
- Запуск БД и LocalStack
docker-compose up -d <контейнер>
- Миграция БД и подготовка LocalStack
docker-compose run <контейнер>
- Запуск тестируемого сервиса
docker-compose up -d <сервис>
- Запуск теста (Newman)
- Остановка всех контейнеров
docker-compose down
- Постинг результатов в Slack
У нас есть чат, куда попадают сообщения с зеленой галочкой или красным крестиком и ссылкой на лог.
В этих шагах задействованы следующие Docker-образы:
- Тестируемый сервис – тот же образ, что и для продакшена. Конфигурация для теста – через переменные окружения.
- Для Postgres, Redis и LocalStack используются готовые образы из Docker Hub. Для Liquibase и Newman тоже есть готовые образы. Мы строим свои на их остове, добавляя туда наши файлы.
- Для подготовки LocalStack используется готовый образ AWS CLI, и на его основе создается образ, содержащий скрипт.
Используя volumes, можно не строить Docker-образ только для добавления файлов в контейнер. Однако, volumes не годятся для нашего окружения, потому что задачи Gitlab CI сами работают в контейнерах. Из такого контейнера можно управлять докером, но volumes монтируют папки только с хост-системы, а не из другого контейнера.
Проблемы, с которыми можно столкнуться
Ожидание готовности
Когда контейнер с сервисом запущен, это еще не значит, что он готов принимать соединения. Надо дождаться соединения, чтобы продолжить.
Эту задачу иногда решают с помощью скрипта wait-for-it.sh, который дожидается возможности установить TCP-соединение. Однако LocalStack может выдать ошибку 502 Bad Gateway. Кроме того, он состоит из множества сервисов, и, если один из них готов, это ничего не говорит про остальные.
Решение: скрипты подготовки LocalStack, которые ждут ответа 200 и от SQS, и от SNS.
Конфликты параллельных задач
Несколько тестов могут работать одновременно в одном Docker-хосте, поэтому имена контейнеров и сетей должны быть уникальны. Более того, тесты с разных веток одного сервиса тоже могут работать одновременно, поэтому недостаточно прописать в каждом compose-файле свои имена.
Решение: скрипт устанавливает уникальное значение переменной COMPOSE_PROJECT_NAME.
Особенности Windows
При использовании Docker в Windows есть ряд вещей, на которые я хочу обратить ваше внимание, поскольку этот опыт важен для понимания причин ошибок.
- Шелл-скрипты в контейнере должны иметь линуксовые концы строк.
Символ CR для шелла – это синтаксическая ошибка. По сообщению об ошибке сложно понять, что дело в этом. При редактировании таких скриптов в Windows нужен правильный текстовый редактор. Кроме того, система контроля версий должна быть настроена должным образом.
Вот так настраивается git:
git config core.autocrlf input
- Git-bash эмулирует стандартные папки Linux и при вызове exe-файла (в том числе docker.exe) заменяет абсолютные Linux-пути на Windows-пути. Однако это не имеет смысла для путей не на локальной машине (или путей в контейнере). Такое поведение не отключается.
Решение: дописывать дополнительный слэш в начало пути: //bin вместо /bin. Linux понимает такие пути, для него несколько слэшей – то же, что один. Но git-bash такие пути не распознаёт и не пытается преобразовывать.
Вывод логов
При выполнении тестов хотелось бы видеть логи и от Newman, и от тестируемого сервиса. Так как события этих логов связаны между собой, совмещение их в одной консоли намного удобнее двух отдельных файлов. Newman запускается через docker-compose run, и поэтому его вывод попадает в консоль. Остается сделать так, чтобы туда попадал и вывод сервиса.
Первоначальное решение состояло в том, чтобы делать docker-compose up без флага -d, но, используя возможности шелла, отправлять этот процесс в фон:
docker-compose up <service> &
Это работало до тех пор, пока не потребовалось отправлять логи из докера в сторонний сервис. docker-compose up перестал выводить логи в консоль. Однако работала команда docker attach.
Решение:
docker attach --no-stdin ${COMPOSE_PROJECT_NAME}_<сервис>_1 &
Конфликт идентификаторов при итерациях теста
Тесты запускаются несколькими итерациями. База при этом не очищается. Записи в базе имеют уникальные ID. Если записать конкретные ID в запросах, на второй итерации получим конфликт.
Чтобы его не было, либо ID должны быть уникальными, либо надо удалять все объекты, созданные тестом. Удалять некоторые объекты нельзя, в соответствии с требованиями.
Решение: генерировать GUID-ы скриптами в Postman.
var uuid = require('uuid');
var myid = uuid.v4();
pm.environment.set('myUUID', myid);
Затем в запросе использовать символ {{myUUID}}, который будет заменен значением переменной.
Взаимодействие через LocalStack
Если тестируемый сервис читает SQS-очередь или пишет в нее, то для проверки этого сам тест также должен работать с этой очередью.
Решение: запросы из Postman к LocalStack.
API сервисов AWS документировано, что позволяет делать запросы без SDK.
Если сервис пишет в очередь, то мы ее читаем и проверяем содержимое сообщения.
Если сервис отправляет сообщения в SNS, на этапе подготовки LocalStack создается еще и очередь и подписывается на этот SNS-топик. Дальше все сводится к описанному выше.
Если сервис должен прочитать сообщение из очереди, то на предыдущем шаге теста мы пишем это сообщение в очередь.
Тестирование HTTP-запросов, исходящих от тестируемого микросервиса
Некоторые сервисы работают по HTTP с чем-то, кроме AWS, и некоторые функции AWS не реализованы в LocalStack.
Решение: в этих случаях может помочь MockServer, у которого есть готовый образ в Docker Hub. Ожидаемые запросы и ответы на них настраиваются HTTP-запросом. API документировано, поэтому делаем запросы из Postman.
Тестирование аутентификации и авторизации OAuth
Мы используем OAuth и JSON Web Tokens (JWT). Для теста нужен OAuth-провайдер, который мы сможем запустить локально.
Всё взаимодействие сервиса с OAuth-провайдером сводится к двум запросам: сначала запрашивается конфигурация /.well-known/openid-configuration, а потом запрашивается публичный ключ (JWKS) по адресу из конфигурации. Все это статический контент.
Решение: наш тестовый OAuth-провайдер – это сервер статического контента и два файла на нем. Токен сгенерирован один раз и закоммичен в Git.
Особенности тестирования SignalR
С веб-сокетами Postman не работает. Для тестирования SignalR был создан специальный инструмент.
Клиентом SignalR может быть не только браузер. Для него существует клиентская библиотека под .NET Core. Клиент, написанный на .NET Core, устанавливает соединение, проходит аутентификацию и ожидает определенной последовательности сообщений. Если получено неожиданное сообщение или соединение разрывается, клиент завершается с кодом 1. При получении последнего ожидаемого сообщения завершается с кодом 0.
Одновременно с клиентом работает Newman. Клиентов запускается несколько, чтобы проверить, что сообщения доставляются всем, кому надо.
Для запуска нескольких клиентов используется опция --scale в командной строке docker-compose.
Перед запуском Postman скрипт дожидается установления соединения всеми клиентами.
Проблема ожидания соединения нам уже встречалась. Но там были серверы, а здесь клиент. Нужен другой подход.
Решение: клиент в контейнере использует механизм HealthCheck, чтобы сообщить скрипту на хосте о своем статусе. Клиент создает файл по определенному пути, допустим, /healthcheck, как только соединение установлено. HealthCheck-скрипт в докер-файле выглядит так:
HEALTHCHECK --interval=3s CMD if [ ! -e /healthcheck ]; then false; fi
Команда docker inspect показывает для контейнера обычный статус, health-статус и код завершения.
После завершения Newman, скрипт проверяет, что все контейнеры с клиентом завершились, причем, с кодом 0.
Счастье есть
После того, как мы преодолели описанные выше сложности, у нас появился набор стабильно работающих тестов. В тестах каждый сервис работает как единое целое, взаимодействует с базой данных и с Amazon LocalStack.
Эти тесты защищают команду из 30+ разработчиков от ошибок в приложении со сложным взаимодействием 10+ микросервисов при частых деплоях.