Сейчас мы расскажем вам историю. Историю о том, как мы разработали 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 файл, из которого мы можем запустить только нужные сервисы одной командой и всего лишь с одним аргументом профиля. Также это решение нормально расширяется при добавлении новых режимов запуска.

Если вы сталкивались с подобными проблемами, расскажите нам, как вы их решили?

Авторы: Колесникова Анна, Шинкарев Александр
Вычитка и фидбек: Ядрышникова Мария, Черных Виктор, Сипатов Максим, Магденко Юлия
Оформление: Маргарита Шур

Комментарии (9)


  1. eyeDM
    25.07.2024 04:45
    +3

    С недавних пор в docker-compose.yml не нужно указывать version


    1. deepblack
      25.07.2024 04:45
      +1

      Дополню ваш комментарий ссылкой на спеку:

      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).


      1. baldr
        25.07.2024 04:45

        Дополню ваше дополнение замечанием что версия "3.x", хоть и не используется, но неявно сигнализирует (другим разработчикам) что это compose для Docker Swarm. Для обычных compose обычно это версии 2.x.


    1. TourmalineCore Автор
      25.07.2024 04:45

      Спасибо, узнали что-то новое, исправим!


  1. 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".


    1. TourmalineCore Автор
      25.07.2024 04:45

      Да, вы правы, можно использовать внешнюю переменную окружения. Но мы выбрали этот кейс как простой пример использования шаблонов сервисов. Нам, например, может понадобиться изменить скрипт в одном из копий сервиса, и для того, чтобы не копировать весь сервис, используем шаблон
      Спасибо за совет о docker compose, учтем!


  1. paxlo
    25.07.2024 04:45

    yaml можно называть просто compose.yml


  1. andrejs82
    25.07.2024 04:45

    Интересно, спасибо!


  1. baldr
    25.07.2024 04:45

    Ещё есть советы по поводу безопасности.. я понимаю что у вас тут, скорее всего, БД для тестов и данные в ней не очень секретные, но вашим примером может воспользоваться кто-то для другого случая и тогда безопасность пострадает.

    Плохая привычка хранить пароли в compose файлах. Потому что они попадают в системы контроля версий (git, etc) и доступны всем кто имеет доступ к коду. Например, случайная компрометация репозитория может раскрыть доступ ещё и к данным, а часто на продакшене часть секретов совпадает (ключи, сертификаты, пользователи).

    Публикация портов наружу. В вашем примере порт ( 5432:5432 ) будет опубликован для всех интерфейсов хоста. Что, вместе с предыдущим пунктом, приведёт к тому что кто угодно может подключиться к вашей базе. Вариант - использовать docker networks. У вас же всё на одном хосте? Включите все сервисы в одну network - и публикация портов не нужна совсем. Сервисы будут видеть друг друга внутри сети по именам контейнеров, а снаружи никто не сможет подключиться. Поскольку у вас независимые тесты - это идеальный вариант. В этом случае пароли можно использовать совсем простые и можно хранить внутри compose файла.