Сейчас мы расскажем вам историю. Историю о том, как мы разработали API и решили написать на него E2E-тесты. Тесты были простыми, описывали и проверяли функциональность API, но оказались мудрёные в плане запуска. Но обо всём по порядку.
В этой статье рассмотрим решение к которому пришли на примере простой Docker Compose конфигурации.
Ручной запуск тестов
Мы искали удобный инструмент для написания E2E-тестов для API. Практически сразу нам встретился инструмент Karate. Полистали документацию и сначала решили запустить тесты вручную такой командой:
java -jar karate.jar .
Но выяснилось, что для этого надо установить Karate и Java-рантайм для него, а потом написать об этом инструкцию для трех операционок: Windows, Linux и macOS.
Используем Docker Compose
Чтобы избежать установки множества инструментов (а еще и определенных версий!), решили запускать тесты в Docker, где все зависимости описаны в Dockerfile, и при сборке контейнера они устанавливаются в сам контейнер автоматически. Ниже приводим пример нашего Dockerfile для запуска Karate-тестов. Вдохновлялись официальной документацией. Для запуска тестов в Docker мы решили использовать Docker Compose, так как он позволяет нам поднять сразу несколько сервисов одной командой. В нашем случае это API, база данных и контейнер с тестами:
Для запуска всех сервисов написали docker-compose.yml
файл.
version: '3.8'
services:
db:
image: postgres:13
container_name: 'db'
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
api:
container_name: 'api'
build:
context: .
dockerfile: Api/Dockerfile # Путь до докерфайла, из которого собирается
# образ API
ports:
- 5000:80
depends_on: # При запуске API миграции применяются к базе данных автоматически,
# поэтому API запускается, только когда база данных уже запущена
- db
karate_tests:
container_name: 'karate_tests'
build:
dockerfile: KarateDockerfile # Путь до докерфайла, из которого собирается
# образ Karate (скачивается karate.jar файл,
# а Java уже существует внутри контейнера)
context: .
depends_on:
- api # Если API не будет запущен, тесты упадут, поэтому ждём запуска API
command: ["/karate"] # Запускаем тесты из папки /karate
volumes:
- .:/karate # Монтируем папку с тестами в папку /karate
environment:
API_URL: 'http://api'
KarateDockerfile
FROM openjdk:11-jre-slim
RUN apt-get update && apt-get install -y curl
RUN apt-get install -y unzip
RUN curl -o /karate.jar -L 'https://github.com/intuit/karate/releases/download/v1.3.0/karate-1.3.0.jar'
ENTRYPOINT ["java", "-jar", "/karate.jar"]
Два файла Docker Compose
Выглядит неплохо и удобно, но не всей команде нужны все контейнеры. Разработчику, например, нужна только база данных, так как API он запускает у себя в IDE.
Решили разделить Docker Compose файл на два: docker-compose.yml
— остался без изменений и docker-compose-db.yml
— содержащий только контейнер с базой данных:
docker-compose-db.yml
version: '3.8'
services:
db:
image: postgres:13
restart: always
container_name: 'db'
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 11237:5432
Запуск тестов в пайплайне
Удобная конфигурация готова, а значит можно запустить тесты в пайплайне на создание мерж-реквеста в основную ветку. Для этих целей мы использовали файл docker-compose.yml
, так как в нём есть всё необходимое для запуска тестов.
Написали пайплайн для GitHub, в котором запустили все контейнеры, в том числе и с самими тестами.
run: |
LOGS=$(docker-compose --file docker-compose.yml up --abort-on-container-exit) # запишем все логи в переменную,
# чтобы в дальнейшем их разобрать и проверить,
# прошли тесты или нет
# проверим, что нет упавших тестов
if [ "$FAILED" -gt 0 ]; then
echo "Failed tests found! Failing the pipeline..."
exit 1
fi
# проверим, что тесты в целом прошли, чтобы избежать ложного успешного завершения пайплайна
if [ "$PASSED" -eq 0 ]; then
echo "No tests passed! Failing the pipeline..."
exit 1
fi
Получилось немного костыльно, но пока не нашли лучшего способа обрушить пайплайн при упавших Karate-тестах.
Фактически, мы можем использовать два файла при сборке контейнеров, указывая их через флаг --file:
docker-compose --file docker-compose.yml --file docker-compose-db.yml up
Но это решение не самая лучшая идея. Между файлами можно запутаться, и придется каждый раз проверять, что один и тот же сервис не присутствует в нескольких файлах сразу. А ещё файлов будет много — столько же, сколько конфигураций запуска. Мы определили как минимум:
local-environment — запускается база данных и API;
db-only — запускается только база данных для взаимодействия с ней сервиса, запущенного в IDE;
e2e-local-environment — запускаются API, база данных и контейнер с Karate-тестами, которые тестируют API, запущенный в Docker;
e2e-production-environment — мы сторонники TDD(Test-Driven Development) и тестирования в проде. А потому хотим запускать тесты не только в фича-ветке до мержа в основную ветку, но и после деплоя в прод. Эта конфигурация запускает только karate_tests контейнер, который нацелен на прод.
Профили
Порылись в документации Docker Compose и обнаружили удобный инструмент — профили. Эта фича поможет разделить сервисы так, как это нужно, и при этом все они будут в одном файле. Тогда для запуска нужных сервисов понадобится указать в команде docker-compose up аргумент --profile с нужным именем профиля.
docker-compose --profile db-only up
Мы вновь объединили все контейнеры в один файл docker-compose.yml
и распределили между ними профили, чтобы выбирать для запуска только те контейнеры, которые нужны сейчас.
version: '3.8'
services:
db:
image: postgres:13
container_name: 'db'
profiles: ['db-only', 'e2e-local-environment', 'local-environment'] # только при запуске с этими профилями сервис будет запущен
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
api:
container_name: 'api'
profiles: ['e2e-local-environment', 'local-environment']
build:
context: .
dockerfile: Api/Dockerfile
ports:
- 5000:5000
depends_on:
- db
karate_tests:
container_name: 'karate_tests'
profiles: ['e2e-local-environment']
build:
dockerfile: KarateDockerfile
context: .
depends_on:
- api
command: ['/karate']
volumes:
- .:/karate
environment:
API_URL: 'http://api'
Шаблоны
Теперь нам нужно запускать те же самые E2E-тесты, но уже по задеплоенному сервису. Для этого нужно заменить адрес в API_URL на адрес API в проде.
Мы создали второй karate_tests сервис, где переменная окружения API_URL имеет уже другое значение.
version: '3.8'
services:
# остальные сервисы (API, database)
local_karate_tests:
container_name: 'local_karate_tests'
profiles: ['e2e-local-environment']
build:
dockerfile: KarateDockerfile
context: .
depends_on:
- api
command: ['/karate']
volumes:
- .:/karate
environment:
API_URL: 'http://api'
production_karate_tests:
container_name: 'production_karate_tests'
profiles: ['e2e-production-environment']
build:
dockerfile: KarateDockerfile
context: .
depends_on:
- api
command: ['/karate']
volumes:
- .:/karate
environment:
API_URL: 'https://my-deployed-service.com'
Но получается так, что эти два сервиса, запускающие Karate-тесты, практически полностью дублируют друг друга. У них меняется только API_URL.
Эту проблему мы решили используя фичу Docker Compose — extends блок, который позволяет сервису наследоваться от какого-то другого сервиса и переопределить лишь часть конфигурации, которая отличается между наследуемым сервисом и наследником.
Мы создали шаблон base_karate_tests в файле docker-compose.yml
. Он содержит в себе те данные, которые у контейнеров не меняются: KarateDockerfile, из которого собирается образ, команда для запуска и volume.
Теперь применим этот шаблон к сервисам с помощью блока extends таким образом:
version: '3.8'
services:
# остальные сервисы (API, database)
base_karate_tests:
build:
dockerfile: KarateDockerfile
context: .
command: ['/karate']
volumes:
- .:/karate
local_karate_tests:
container_name: 'local_karate_tests'
profiles: ['e2e-local-environment']
extends:
service: base_karate_tests
environment:
API_URL: 'http://api'
production_karate_tests:
container_name: 'production_karate_tests'
profiles: ['e2e-local-environment']
extends:
service: base_karate_tests
environment:
API_URL: 'https://my-deployed-service.com'
Один шаблон не занимает много места. Однако, если у нас появится система, в которой шаблонов и наследуемых сервисов будет больше, сам шаблон можно вынести в отдельный файл и ссылаться на него. Для этого создадим файл templates.yml
.
version: '3.8'
services:
base_karate_tests:
build:
dockerfile: KarateDockerfile
context: .
command: ['karate', '/karate']
volumes:
- .:/karate
Опишем здесь все нужные шаблоны, а в docker-compose.yml
будем использовать параметр file, помимо service, чтобы применить шаблон, но уже из другого файла.
extends:
file: templates.yml
service: base_karate_tests
Для маленькой системы в этом нет необходимости, но для более сложной автоматизации может быть самое то.
Итоги
Используя Docker Compose мы смогли успешно и с удовольствием запустить E2E-тесты API и в пайплайне мерж-реквеста перед закрытием фичи и после деплоя этой фичи в прод.
При запуске в Docker Compose мы прошли следующий путь эволюции решения:
Отдельные файлы для запуска конкретных сервисов, где манифест дублируется;
Привязка сервисов к профилям, в которых они должны запускаться;
Вынесение дублирующегося кода сервисов в шаблоны.
Итого имеем компактный Docker Compose файл, из которого мы можем запустить только нужные сервисы одной командой и всего лишь с одним аргументом профиля. Также это решение нормально расширяется при добавлении новых режимов запуска.
Если вы сталкивались с подобными проблемами, расскажите нам, как вы их решили?
Авторы: Колесникова Анна, Шинкарев Александр
Вычитка и фидбек: Ядрышникова Мария, Черных Виктор, Сипатов Максим, Магденко Юлия
Оформление: Маргарита Шур
Комментарии (10)
baldr
25.07.2024 04:45Но получается так, что эти два сервиса, запускающие Karate-тесты, практически полностью дублируют друг друга. У них меняется только API_URL.
В таком случае можно было этот API_URL использовать из внешней переменной окружения.
environment:
API_URL: ${API_URL:-http://url}Запускать как обычно.
Второй вариант - указать API_URL в командной строке самого compose:
docker compose -e API_URL="http://api" --file mystack.yaml up
Кстати, compose уже давно выделился в отдельный плагин, поэтому запуск через одну команду "docker-compose" - это легаси, в будущем может перестать работать. Нужно запускать как "docker compose".
TourmalineCore Автор
25.07.2024 04:45Да, вы правы, можно использовать внешнюю переменную окружения. Но мы выбрали этот кейс как простой пример использования шаблонов сервисов. Нам, например, может понадобиться изменить скрипт в одном из копий сервиса, и для того, чтобы не копировать весь сервис, используем шаблон
Спасибо за совет о docker compose, учтем!
baldr
25.07.2024 04:45+5Ещё есть советы по поводу безопасности.. я понимаю что у вас тут, скорее всего, БД для тестов и данные в ней не очень секретные, но вашим примером может воспользоваться кто-то для другого случая и тогда безопасность пострадает.
Плохая привычка хранить пароли в compose файлах. Потому что они попадают в системы контроля версий (git, etc) и доступны всем кто имеет доступ к коду. Например, случайная компрометация репозитория может раскрыть доступ ещё и к данным, а часто на продакшене часть секретов совпадает (ключи, сертификаты, пользователи).
Публикация портов наружу. В вашем примере порт (
5432:5432
) будет опубликован для всех интерфейсов хоста. Что, вместе с предыдущим пунктом, приведёт к тому что кто угодно может подключиться к вашей базе. Вариант - использовать docker networks. У вас же всё на одном хосте? Включите все сервисы в одну network - и публикация портов не нужна совсем. Сервисы будут видеть друг друга внутри сети по именам контейнеров, а снаружи никто не сможет подключиться. Поскольку у вас независимые тесты - это идеальный вариант. В этом случае пароли можно использовать совсем простые и можно хранить внутри compose файла.
pesh1983
25.07.2024 04:45+3Я бы ещё добавил health check на базу и для веб сервиса перед запуском тестов. У вас скорее все все стартует быстро, но при определенных условиях создание базы и старт веб сервиса соответственно могут знать какое-то время. То есть сервисы запустятся, но база не готова будет принимать запросы, и веб сервис тоже. Поэтому в docker compose добавили health check функционал https://docs.docker.com/compose/startup-order/. Вот тут есть примеры https://www.warp.dev/terminus/docker-compose-health-check. Для постгреса посмотрите в сторону pg_ ready.
eyeDM
С недавних пор в docker-compose.yml не нужно указывать version
deepblack
Дополню ваш комментарий ссылкой на спеку:
Compose spec: Version and name top-level elements
Version top-level element (obsolete)
The top-level
version
property is defined by the Compose Specification for backward compatibility. It is only informative you'll receive a warning message that it is obsolete if used.Compose doesn't use
version
to select an exact schema to validate the Compose file, but prefers the most recent schema when it's implemented.Compose validates whether it can fully parse the Compose file. If some fields are unknown, typically because the Compose file was written with fields defined by a newer version of the Specification, you'll receive a warning message. Compose offers options to ignore unknown fields (as defined by "loose" mode).
baldr
Дополню ваше дополнение замечанием что версия "3.x", хоть и не используется, но неявно сигнализирует (другим разработчикам) что это compose для Docker Swarm. Для обычных compose обычно это версии 2.x.
TourmalineCore Автор
Спасибо, узнали что-то новое, исправим!