E2E-тестирование в среде CI с помощью Testcontainers

Я написал много постов в блоге о модульном и интеграционном тестировании. Но сегодня я хочу рассказать вам о том, что выходит за их рамки. И это E2E-тестирование. Хотя важно тестировать поведение каждого сервиса в отдельности. Но также очень важно проверять достоверность бизнес-сценария на всей работающей системе. В этой статье я расскажу вам, что такое E2E-тестирование, почему оно так важно и как его можно реализовать в вашем конвейере релизов. Вы узнаете, как запускать E2E-тесты на каждом новом запросе на поставку перед слиянием изменений в ветку master.

Примеры кода приведены на Java, но предложенное решение применимо к любому языку программирования. Вы можете найти исходный код всего проекта по этой ссылке.

Домен

Мы собираемся разработать систему получения предстоящих сообщений с дополнительными данными. Взгляните на схему ниже.

Алгоритм обработки сообщений прост:

  1. Пользователь отправляет сообщение через REST API.

Тестирование

Юнит-тестирование

Как мы можем проверить поведение системы? Есть несколько вариантов. Самый простой – модульное тестирование. Взгляните на диаграмму ниже. Я обозначил области тестирования бледно-зеленым и синим овалами.

У модульных тестов есть несколько преимуществ:

  1. Они быстро выполняются.
  2. Легко интегрируются в конвейер CI/CD.
  3. Могут выполняться параллельно (если правильно написаны).

Однако у них есть и проблема. Юнит-тесты не проверяют взаимодействие с реальными внешними сервисами (например, Redis, RabbitMQ). Речь идет о проверке бизнес-логики, но не о реальном сценарии производства.

Я написал длинную статью о паттернах модульного тестирования и лучших практиках. Ознакомьтесь с ним, он действительно потрясающий.

Интеграционное тестирование

Нам нужно расширить перспективы. Значит, интеграционные тесты могут пригодиться, верно? Взгляните на следующую диаграмму ниже.

В этом случае мы проверяем взаимодействие с внешними сервисами. Однако остается одна проблема. Бизнес-операция включает в себя несколько компонентов взаимодействия. Даже если каждый модуль протестирован должным образом, как мы можем проверить корректность мультисервисного запроса (т.е. бизнес-сценария)? Например, если API-сервис внесет разрушающее изменение в формат выходного сообщения, то gain-сервис не сможет успешно продолжить обогащение. Хотя интеграционные и модульные тесты API-сервиса пройдут.

Чтобы решить эту проблему, нам нужно что-то помимо интеграционных тестов.

Я написал статью, глубоко объясняющую интеграционные тесты. Вам стоит ознакомиться с ней.

E2E тестирование

Идея E2E-тестирования проста. Мы рассматриваем всю систему как черный ящик, который принимает некоторые данные и возвращает вычисленный результат (синхронно или асинхронно). Взгляните на схему ниже.

Что ж, звучит разумно и внушает доверие. Но как мы можем ее реализовать? С чего мы начнем? Давайте начнем разбирать эту проблему шаг за шагом.

Стратегии выпуска

Во-первых, давайте проясним конвейер выпуска отдельных сервисов. Это поможет нам понять весь подход к тестированию E2E. Взгляните на схему ниже.

Вот пошаговая схема:

  1. Разработчик вносит изменения в ветку feature/task.
  2. Затем делает pull request из ветки feature/task в ветку master.
  3. Во время конвейера CI происходит сборка запроса (т.е. выполнение юнит-тестов и интеграционных тестов).
  4. Если конвейер зеленый, изменения сливаются в ветку master.
  5. После слияния pull request результирующий артефакт публикуется в Docker Hub.
  6. Когда запускается релиз (например, по расписанию), на этапе deploy извлекается необходимый образ Docker (по умолчанию последний) и запускается в указанном окружении.

Итак, как мы можем поместить E2E-тесты в указанный процесс? На самом деле, есть несколько способов.

Стратегия синхронного выпуска

Это самый простой для понимания подход. Независимо от того, сколько у нас сервисов, конвейер выпуска развертывает каждый из них в рамках одного задания. В этом случае нам просто нужно запустить E2E-тесты непосредственно перед развертыванием артефактов в продакшн. Взгляните на схему ниже, описывающую этот процесс.

Алгоритм следующий:

  1. Триггер релиза
  2. Извлечь образы всех сервисов из Docker Hub (по умолчанию последние).
  3. Запустить E2E-тесты с извлеченными образами (я объясню вам подход позже в статье).
  4. Если тесты прошли успешно, разверните извлеченные образы.

Несмотря на свою простоту, этот подход имеет существенное препятствие. Вы не можете обновить один микросервис
изолированно. Это означает, что разные модули должны быть выпущены все сразу. Хотя в реальности некоторые микросервисы приходится обновлять чаще, чем другие. Но здесь необходимо выбрать такой триггер выпуска, который удовлетворял бы (хотя бы частично) требованиям каждого сервиса.

Стратегия асинхронного освобождения

Эта стратегия подразумевает обновление каждого сервиса как изолированной функциональности. Каждый модуль может быть развернут в соответствии со своими собственными правилами.

Вот пример стратегии асинхронного высвобождения. Взгляните на схему ниже.

Как видите, схема похожа на конвейер выпуска одного модуля, который мы видели раньше. Хотя есть и небольшие отличия. Теперь есть этап E2E-tests, который выполняется как во время сборки pull request, так и непосредственно перед развертыванием в продакшн.
Зачем нам снова запускать E2E-тесты, если они уже завершены на конвейере pull request? Посмотрите на рисунок ниже, чтобы понять суть проблемы.

Мы развернули API-Service сразу после слияния PR. Но мы задержали выпуск Gain-Service на один день. Поэтому, если E2E-тесты запускаются только во время сборки pull request, есть вероятность, что некоторые другие сервисы уже были обновлены. Но мы проверяли корректность только на предыдущих версиях, потому что во время сборки pull request новейшие релизы еще не были продвинуты.

Если вы придерживаетесь стратегии асинхронного выпуска релизов, вам необходимо запускать E2E-тесты непосредственно перед развертыванием в продакшн, а также во время сборки pull request.

В этой статье мы рассмотрим стратегию асинхронного релиза как предпочтительную для микросервисов.

Создание процесса

Что ж, все это звучит многообещающе. Но как нам наладить этот сценарий? Могу сказать, что это не так сложно, как кажется. Посмотрите на пример запуска E2E-тестов для API-Service ниже.

Здесь есть две части. Запуск E2E-тестов во время сборки pull request и непосредственно перед развертыванием артефакта на производстве. Давайте пройдемся по каждому сценарию шаг за шагом.

Сборка в рамках pull request

  1. Сначала запускаются модульные и интеграционные тесты. Эти два шага обычно совмещаются с самой сборкой артефакта.
  2. Затем собирается текущая версия API-Service и сохраняется локально в виде образа Docker. Мы не выкладываем ее в хаб, потому что предложенные изменения могут оказаться некорректными (мы еще не запускали E2E-тесты для проверки). Хотя некоторые CI-провайдеры не позволяют собирать образы Docker локально, чтобы использовать их позже. В этом случае вы можете указать тег, который не будет использоваться в продакшене. Например, dev-CI_BUILD_ID.
  3. Затем мы извлекаем образ Docker, содержащий сами E2E-тесты. Как мы увидим позже, это простое приложение. Поэтому его удобно хранить и в Docker Hub.
  4. И, наконец, пришло время запустить E2E-тесты. Приложение, содержащее тесты, должно быть настроено на запуск с различными Docker-образами сервисов (в данном случае API-Service и Gain-Service). Здесь мы помещаем API_SERVICE_IMAGE как тот, который мы создали локально в шаге 2.

Все остальные сервисы должны иметь в качестве последнего тега Docker-образ по умолчанию. Это даст нам возможность запускать тесты E2E в любом репозитории, переопределив текущую версию образа сервиса.

Если все проверки пройдены, PR принимается к слиянию. После слияния новая версия API-Service выкладывается в Docker Hub с тегом latest.

E2E-тесты, выполняемые перед стадией развертывания

  1. Юнит-тесты и интеграционные тесты выполняются одинаково.
  2. Последняя версия образов E2E-tests извлекается из Docker Hub.
  3. E2E-тесты запускаются с тегами latest для всех сервисов.

Сервис API-Service уже был помещен в Docker Hub с тегом latest при слиянии запросов pull request. Поэтому нет необходимости указывать конкретную версию образа при запуске E2E-тестов.

Реализация кода

Давайте приступим к реализации тестов E2E. Вы можете ознакомиться с исходным кодом по этой ссылке.
В качестве фреймворка для E2E-тестов я использую Spring Boot Test. Но вы можете применить любую технологию, которая вам нравится.

Для простоты я разместил все модули (включая e2e-tests) в одном моно-репозитории. В любом случае, подход, который я вам описываю, является всеобъемлющим. Поэтому вы можете применить его и к микросервисам с несколькими хранилищами.

Давайте начнем с E2ESuite. Он будет содержать все конфигурации и выступать в качестве суперкласса для всех тестовых случаев. Взгляните на пример кода ниже.

@ContextConfiguration(initializers = Initializer.class)
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Import({
    TestRedisFacade.class,
    TestRabbitListener.class,
    TestRestFacade.class
})
public class E2ESuite {
  private static final Network SHARED_NETWORK = Network.newNetwork();
  private static GenericContainer<?> REDIS;
  private static RabbitMQContainer RABBIT;
  private static GenericContainer<?> API_SERVICE;
  private static GenericContainer<?> GAIN_SERVICE
}
Вход в полноэкранный режим Выход из полноэкранного режима

Во-первых, мы должны объявить контейнеры Docker для запуска в среде Testcontainers. Здесь у нас есть Redis и RabbitMQ, которые являются частью инфраструктуры. В то время как API_SERVICE и GAIN_SERVICE являются пользовательскими сервисами, реализующими бизнес-логику.

Аннотация @Import используется для добавления пользовательских классов в Spring Context, которые используются в целях тестирования. Их реализация тривиальна. Поэтому вы можете найти его по ссылке на репозиторий выше. Хотя @ContextConfiguration является важным. Мы скоро перейдем к этому.

Кроме того, SHARED_NETWORK имеет решающее значение. Видите ли, контейнеры должны взаимодействовать друг с другом, потому что это цель сценария E2E. Но также мы должны иметь возможность отправлять HTTP-запросы к API-Service для вызова бизнес-логики. Для достижения обеих этих целей мы связали все контейнеры единой сетью и пробросили HTTP-порт API-Service, чтобы открыть доступ для клиента. Посмотрите на схему ниже, описывающую этот процесс.

Теперь нам нужно как-то инициализировать и запустить контейнеры. Кроме того, нам также нужно указать правильные свойства для подключения нашего приложения E2E-tests к недавно запущенным контейнерам Docker. В этом случае может пригодиться аннотация @ContextConfiguration. Она предоставляет параметр initializers, который представляет обратные вызовы, вызываемые на этапе инициализации Spring Context. Здесь мы поместили внутренний класс Initializer. Взгляните на пример кода ниже.

static class Initializer implements
      ApplicationContextInitializer<ConfigurableApplicationContext> {

    @Override
    public void initialize(ConfigurableApplicationContext context) {
      final var environment = context.getEnvironment();
      REDIS = createRedisContainer();
      RABBIT = createRabbitMQContainer(environment);

      Startables.deepStart(REDIS, RABBIT).join();
      final var apiExposedPort = environment.getProperty("api.exposed-port", Integer.class);
      API_SERVICE = createApiServiceContainer(environment, apiExposedPort);
      GAIN_SERVICE = createGainServiceContainer(environment);

      Startables.deepStart(API_SERVICE, GAIN_SERVICE).join();

      setPropertiesForConnections(environment);
    }
    ...
}
Вход в полноэкранный режим Выход из полноэкранного режима

Давайте разберем эту функциональность пошагово. Сначала создается контейнер Redis. Посмотрите на фрагмент кода ниже.

private GenericContainer<?> createRedisContainer() {
      return new GenericContainer<>("redis:5.0.14-alpine3.15")
          .withExposedPorts(6379)
          .withNetwork(SHARED_NETWORK)
          .withNetworkAliases("redis")
          .withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger("Redis")));
    }
Вход в полноэкранный режим Выход из полноэкранного режима

На момент написания статьи в библиотеке Testcontainers нет отдельного контейнера для Redis. Поэтому я использую общий. Наиболее важными атрибутами являются network и network aliases. Их наличие делает контейнер доступным для других контейнеров в той же сети. Мы также раскрываем порт 6379 (порт Redis по умолчанию), поскольку тестовый пример E2E будет подключаться к Redis во время выполнения.

Также я хотел бы, чтобы вы обратили внимание на log consumer. Видите ли, когда сценарий E2E терпит неудачу, не всегда очевидно, почему. Иногда, чтобы понять источник проблемы, приходится копаться в журналах контейнеров. К счастью, log consumer позволяет нам пересылать журналы контейнера любому экземпляру регистратора SLF4J. В данном проекте логи контейнеров пересылаются в обычные текстовые файлы (конфигурацию Logback можно найти в репозитории). Хотя гораздо лучше передавать логи во внешнее средство логирования (например, Kibana).

Далее идет RabbitMQ. Посмотрите на инициализацию контейнера ниже.

private RabbitMQContainer createRabbitMQContainer(Environment environment) {
      return new RabbitMQContainer("rabbitmq:3.7.25-management-alpine")
          .withNetwork(SHARED_NETWORK)
          .withNetworkAliases("rabbit")
          .withQueue(
              environment.getProperty("queue.api", String.class)
          )
          .withQueue(
              environment.getProperty("queue.gain", String.class)
          )
          .withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger("Rabbit")));
    }
Вход в полноэкранный режим Выход из полноэкранного режима

Идея похожа на инициализацию контейнера Redis. Но здесь мы также вызвали метод withQueue (который является частью класса RabbitMQContainer), чтобы указать темы по умолчанию при запуске RabbitMQ. API-Service посылает сообщения в тему queue.api, а Gain-Service посылает сообщения в тему queue.gain (эти свойства настраиваются). Таким образом, удобно создавать нужные темы при запуске приложения.

Далее следует интересная строка кода.

Startables.deepStart(REDIS, RABBIT).join();
Вход в полноэкранный режим Выход из полноэкранного режима

Метод deepStart принимает varargs контейнеров для запуска и возвращает CompletableFuture. Нам нужно, чтобы эти контейнеры запускались до API-Service и Gain-Service. Поэтому мы вызываем метод join, чтобы подождать, пока контейнеры будут готовы принимать запросы.

Вы также можете запустить все контейнеры одним вызовом метода deepStart и указать порядок, вызвав метод dependsOn на самом контейнере. Это более производительно, но сложнее для чтения. Поэтому я оставляю более простой пример.

А теперь мы можем запустить наши пользовательские контейнеры.

final var apiExposedPort = environment.getProperty("api.exposed-port", Integer.class);
API_SERVICE = createApiServiceContainer(environment, apiExposedPort);
Вход в полноэкранный режим Выход из полноэкранного режима

Прежде всего, давайте углубимся в метод createApiServiceContainer. Взгляните на приведенный ниже фрагмент кода.

private GenericContainer<?> createApiServiceContainer(
        Environment environment,
        int apiExposedPort
    ) {
      final var apiServiceImage = environment.getProperty(
          "image.api-service",
          String.class
      );
      final var queue = environment.getProperty(
          "queue.api",
          String.class
      );
      return new GenericContainer<>(apiServiceImage)
          .withEnv("SPRING_RABBITMQ_ADDRESSES", "amqp://rabbit:5672")
          .withEnv("QUEUE_NAME", queue)
          .withExposedPorts(8080)
          .withNetwork(SHARED_NETWORK)
          .withNetworkAliases("api-service")
          .withCreateContainerCmdModifier(
              cmd -> cmd.withHostConfig(
                  new HostConfig()
                      .withNetworkMode(SHARED_NETWORK.getId())
                      .withPortBindings(new PortBinding(
                          Ports.Binding.bindPort(apiExposedPort),
                          new ExposedPort(8080)
                      ))
              )
          )
          .waitingFor(
              Wait.forHttp("/actuator/health")
                  .forStatusCode(200)
          )
          .withImagePullPolicy(new AbstractImagePullPolicy() {
            @Override
            protected boolean shouldPullCached(DockerImageName imageName,
                ImageData localImageData) {
              return true;
            }
          })
          .withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger("API-Service")));
    }
Вход в полноэкранный режим Выход из полноэкранного режима

Есть несколько моментов, на которые я хотел бы обратить внимание.

Метод withEnv просто устанавливает обычную переменную окружения. Они используются для настройки API-Service. Вы, вероятно, заметили, что URL RabbitMQ – это amqp://rabbit:5672. Потому что rabbit – это имя соответствующего контейнера во внутренней сети (мы указали его в качестве сетевого псевдонима при инстанцировании контейнера). Именно это делает RabbitMQ доступным для API-Service.

Пункт waitingFor более интересен. Testcontainers должен каким-то образом узнать, что контейнер готов принимать соединения. API-Service раскрывает HTTP-путь /actuator/health, который возвращает код 200, если экземпляр готов.

Модификатор withCreateContainerCmdModifier в сочетании с методом withExposedPorts привязывает порт внутреннего контейнера 8080 к порту apiExposedPort (указанному переменной окружения перед началом E2E тестов).

withImagePullPolicy определяет правило для получения образов непосредственно из Docker Hub. По умолчанию Testcontainers проверяет наличие образа локально. Если он его находит, то ничего не извлекает с удаленного сервера. Такое поведение подходит для тестирования конкретных образов. Но если вы укажете изображение с тегом latest, есть вероятность, что библиотека вытащит не самую актуальную версию. В этом случае Testcontainers всегда берет образы из удаленного Docker Hub.

Посмотрите на объявление контейнера Gain-Service ниже.

private GenericContainer<?> createGainServiceContainer(Environment environment) {
      final var gainServiceImage = environment.getProperty(
          "image.gain-service",
          String.class
      );
      final var apiQueue = environment.getProperty(
          "queue.api",
          String.class
      );
      final var gainQueue = environment.getProperty(
          "queue.gain",
          String.class
      );
      return new GenericContainer<>(gainServiceImage)
          .withNetwork(SHARED_NETWORK)
          .withNetworkAliases("gain-service")
          .withEnv("SPRING_RABBITMQ_ADDRESSES", "amqp://rabbit:5672")
          .withEnv("SPRING_REDIS_URL", "redis://redis:6379")
          .withEnv("QUEUE_INPUT_NAME", apiQueue)
          .withEnv("QUEUE_OUTPUT_NAME", gainQueue)
          .waitingFor(
              Wait.forHttp("/actuator/health")
                  .forStatusCode(200)
          )
          .withImagePullPolicy(new AbstractImagePullPolicy() {
            @Override
            protected boolean shouldPullCached(DockerImageName imageName,
                ImageData localImageData) {
              return true;
            }
          })
          .withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger("Gain-Service")));
    }
Вход в полноэкранный режим Выход из полноэкранного режима

Как вы можете видеть, инициализация похожа на API-Service. Итак, давайте пойдем дальше.

Когда контейнеры API-Service и Gain-Service будут готовы, мы сможем их запустить. Взгляните на фрагмент кода ниже.

Startables.deepStart(API_SERVICE, GAIN_SERVICE).join();
setPropertiesForConnections(environment);
Вход в полноэкранный режим Выход из полноэкранного режима

Мы уже обсуждали идею Startables.deepStart. Однако setPropertiesForConnections требует некоторых пояснений. Этот метод устанавливает URL запущенного контейнера в качестве свойств для тестовых примеров E2E. Таким образом, тестовые наборы могут проверять результаты. Посмотрите на реализацию процедуры ниже.

private void setPropertiesForConnections(ConfigurableEnvironment environment) {
      environment.getPropertySources().addFirst(
          new MapPropertySource(
              "testcontainers",
              Map.of(
                  "spring.rabbitmq.addresses", RABBIT.getAmqpUrl(),
                  "spring.redis.url", format(
                      "redis://%s:%s",
                      REDIS.getHost(),
                      REDIS.getMappedPort(6379)
                  ),
                  "api.host", API_SERVICE.getHost()
              )
          )
      );
    }
Вход в полноэкранный режим Выход из полноэкранного режима

Здесь мы указали соединения для RabbitMQ и Redis. Также мы сохранили хост API-Service для отправки HTTP-запросов.

Хорошо, давайте сделаем тестовые примеры. Мы пишем один сценарий E2E. Посмотрите на список элементов ниже.

  1. Клиент отправляет сообщение, содержащее значения msisdn и cookie на API-Service.
  2. Сообщение без модификаций должно быть передано в RabbitMQ в конечном итоге.
  3. Клиент отправляет сообщение, содержащее только значение cookie в API-Service.
  4. Обогащенное сообщение с определенным значением msisdn должно быть передано в RabbitMQ.

Посмотрите на тестовый пакет ниже.

class GainTest extends E2ESuite {

  @Test
  void shouldGainMessage() {
    rest.post(
        "/api/message",
        Map.of(
            "some_key", "some_value",
            "cookie", "cookie-value",
            "msisdn", "msisdn-value"
        ),
        Void.class
    );
    await().atMost(FIVE_SECONDS)
        .until(() -> getGainQueueMessages().contains(Map.of(
            "some_key", "some_value",
            "cookie", "cookie-value",
            "msisdn", "msisdn-value"
        )));

    rest.post(
        "/api/message",
        Map.of(
            "another_key", "another_value",
            "cookie", "cookie-value"
        ),
        Void.class
    );
    await().atMost(FIVE_SECONDS)
        .until(() -> getGainQueueMessages().contains(Map.of(
            "another_key", "another_value",
            "cookie", "cookie-value",
            "msisdn", "msisdn-value"
        )));
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Сначала мы отправляем сообщение с cookie и msisdn. Затем мы проверяем, что сообщение передается дальше в неизменном виде. Следующий шаг – отправка другого сообщения с опущенным msisdn, но присутствующим значением cookie. Наконец, сообщение с обогащенным значением msisdn должно быть отправлено в RabbitMQ с помощью Gain-Service.

Если вы запускаете тест локально, это может занять некоторое время. В любом случае, требуется время для загрузки необходимых образов и запуска соответствующих контейнеров. Но тест должен пройти успешно.

Запуск в среде CI

Что ж, все это звучит замечательно. Но как нам запустить E2E-тесты во время CI-конвейера?

Во-первых, мы должны упаковать E2E-тесты как образ Docker. Посмотрите на Dockerfile ниже.

FROM openjdk:17-alpine

WORKDIR /app

COPY . /app

CMD ["/app/gradlew", ":e2e-tests:test"]
Вход в полноэкранный режим Выйти из полноэкранного режима

Таким образом, тесты компилируются и запускаются при старте контейнера.

Тесты не являются частью скомпилированного артефакта (в данном случае файла .jar). Поэтому мы копируем весь каталог с самим кодом.

Далее идет конфигурация YAML для конвейера GitHub Actions. Получившийся плейбук довольно длинный. Поэтому я показываю его небольшими частями.

Мы будем запускать тестовые примеры для каждого запроса на вытягивание и каждого слияния с веткой master.

name: Java CI

on:
  push:
    branches: [ "master" ]
  pull_request:
    branches: [ "master" ]
Вход в полноэкранный режим Выход из полноэкранного режима

Весь конвейер состоит из 3 заданий:

Давайте рассмотрим каждое задание в отдельности.

build

Это самое тривиальное задание. Более того, GitHub может сгенерировать его за вас.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Set up JDK 17
        uses: actions/setup-java@v3
        with:
          java-version: '17'
          distribution: 'temurin'
      - name: Build with Gradle
        uses: gradle/gradle-build-action@0d13054264b0bb894ded474f08ebb30921341cee
        with:
          arguments: :gain-service:build :api-service:build
Войти в полноэкранный режим Выйти из полноэкранного режима

build-dev-images

Этот вариант непростой. Во-первых, мы должны сохранить DOCKERHUB_USERNAME и DOCKERHUB_TOKEN в качестве секретов репозитория для проталкивания Docker-образов. Затем мы должны переслать артефакты. И, наконец, мы должны переслать вычисленный тег dev следующему заданию. Взгляните на реализацию ниже.

jobs:
  ...
  build-dev-images:
    needs:
      - build
    runs-on: ubuntu-latest
    outputs:
      image_tag: ${{ steps.env.outputs.image_tag }}
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v2
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2
      - name: Login to DockerHub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      - name: Define images tags
        id: env
        run: |
          export IMAGE_TAG_ENV=dev-${{ github.run_number }}
          echo "IMAGE_TAG=$IMAGE_TAG_ENV" >> "$GITHUB_ENV"
          echo "::set-output name=image_tag::$IMAGE_TAG_ENV"
      - name: Build and push E2E-tests
        uses: docker/build-push-action@v3
        with:
          file: "./Dockerfile_e2e_tests"
          push: true
          tags: kirekov/e2e-tests:${{ env.IMAGE_TAG }}
      - name: Build and push API-Service
        uses: docker/build-push-action@v3
        with:
          file: "./Dockerfile_api_service"
          push: true
          tags: kirekov/api-service:${{ env.IMAGE_TAG }}
      - name: Build and push Gain-Service
        uses: docker/build-push-action@v3
        with:
          file: "./Dockerfile_gain_service"
          push: true
          tags: kirekov/gain-service:${{ env.IMAGE_TAG }}
Вход в полноэкранный режим Выход из полноэкранного режима

Я хочу, чтобы вы обратили внимание на эти строки кода.

jobs:
  ...
  build-dev-images:
    ...
    outputs:
      image_tag: ${{ steps.env.outputs.image_tag }}
    steps:
      ...
      - name: Define images tags
        id: env
        run: |
          export IMAGE_TAG_ENV=dev-${{ github.run_number }}
          echo "IMAGE_TAG=$IMAGE_TAG_ENV" >> "$GITHUB_ENV"
          echo "::set-output name=image_tag::$IMAGE_TAG_ENV"
Вход в полноэкранный режим Выход из полноэкранного режима

Строка export IMAGE_TAG_ENV=dev-${{ github.run_number }} устанавливает тег dev со сгенерированным номером сборки в переменную окружения IMAGE_TAG_ENV.

Строка echo "IMAGE_TAG=$IMAGE_TAG_ENV" >> "$GITHUB_ENV" делает доступной переменную ${{ env.IMAGE_TAG }}. Она используется для указания тега Docker при публикации образа на следующих шагах.

Команда echo "::set-output name=image_tag::$IMAGE_TAG_ENV" сохраняет переменную image_tag в качестве вывода. Таким образом, следующее задание может ссылаться на нее для запуска указанной версии тестов E2E.

Само выталкивание в Docker Hub осуществляется с помощью docker/build-push-action. Посмотрите на фрагмент кода ниже.

- name: Build and push E2E-tests
  uses: docker/build-push-action@v3
    with:
    file: "./Dockerfile_e2e_tests"
    push: true
    tags: kirekov/e2e-tests:${{ env.IMAGE_TAG }}
Вход в полноэкранный режим Выход из полноэкранного режима

Сборка и проталкивание API-Service и Gain-Service аналогичны.

e2e-тесты

А теперь пришло время запустить E2E-тесты. Взгляните на конфигурацию ниже.

jobs:
  ...
  e2e-tests:
    needs:
      - build-dev-images
    runs-on: ubuntu-latest
    container:
      image: kirekov/e2e-tests:${{needs.build-dev-images.outputs.image_tag}}
      volumes:
        - /var/run/docker.sock:/var/run/docker.sock
    steps:
      - name: Run E2E-tests
        run: |
          cd /app
          ./gradlew :e2e-tests:test
Вход в полноэкранный режим Выход из полноэкранного режима

В container.image указывается версия тестов E2E для запуска. Переменная ${{needs.build-dev-images.outputs.image_tag}} ссылается на ту, которая была выставлена заданием build-dev-images на предыдущем шаге.

Переменная volumes: /var/run/docker.sock:/var/run/docker.sock имеет решающее значение. Потому что образы e2e-tests используют библиотеку Testcontainers для запуска других контейнеров Docker. Монтирование docker.sock в качестве тома реализует паттерн Docker Wormhole. Подробнее об этом можно прочитать по этой ссылке.

build-prod-images

Этот шаг почти такой же, как и build-dev-images. Вы можете найти его в репозитории.

Заключение

В результате мы настроили среду CI для запуска модульных, интеграционных и E2E-тестов для нескольких бизнес-компонентов (т.е. Gain-Service и API-Service) и внешних сервисов (т.е. RabbitMQ, Redis). Testcontainers позволяет нам строить комплексные и надежные конвейеры. Что еще более интересно, так это то, что вам не нужно иметь выделенные серверы для E2E-тестирования. Достаточно чистого CI-конвейера!

Надеюсь, вам понравился предложенный мной подход к E2E-тестированию. Если у вас есть вопросы или предложения, пожалуйста, оставляйте свои комментарии ниже. Кроме того, вы всегда можете написать мне напрямую. Я буду рад обсудить эту тему. Спасибо за чтение!

Ресурсы

  1. Репозиторий с исходным кодом
  2. Руководство по тестированию Apache Spark, Hive и Spring Boot
  3. Тестирование Spring Boot – тестовые контейнеры и Flyway
  4. Тестирование Spring Boot – данные и сервисы
  5. Spring Data JPA – чистые тесты
  6. Глубокое погружение в модульное тестирование
  7. Правильное интеграционное тестирование
  8. SLF4J
  9. Logback
  10. Kibana
  11. Паттерны для запуска тестов внутри контейнера Docker

Оцените статью
Procodings.ru
Добавить комментарий