Я написал много постов в блоге о модульном и интеграционном тестировании. Но сегодня я хочу рассказать вам о том, что выходит за их рамки. И это E2E-тестирование. Хотя важно тестировать поведение каждого сервиса в отдельности. Но также очень важно проверять достоверность бизнес-сценария на всей работающей системе. В этой статье я расскажу вам, что такое E2E-тестирование, почему оно так важно и как его можно реализовать в вашем конвейере релизов. Вы узнаете, как запускать E2E-тесты на каждом новом запросе на поставку перед слиянием изменений в ветку master
.
Примеры кода приведены на Java, но предложенное решение применимо к любому языку программирования. Вы можете найти исходный код всего проекта по этой ссылке.
- Домен
- Тестирование
- Юнит-тестирование
- Интеграционное тестирование
- E2E тестирование
- Стратегии выпуска
- Стратегия синхронного выпуска
- Стратегия асинхронного освобождения
- Создание процесса
- Сборка в рамках pull request
- E2E-тесты, выполняемые перед стадией развертывания
- Реализация кода
- Запуск в среде CI
- build
- build-dev-images
- e2e-тесты
- build-prod-images
- Заключение
- Ресурсы
Домен
Мы собираемся разработать систему получения предстоящих сообщений с дополнительными данными. Взгляните на схему ниже.
Алгоритм обработки сообщений прост:
- Пользователь отправляет сообщение через REST API.
Тестирование
Юнит-тестирование
Как мы можем проверить поведение системы? Есть несколько вариантов. Самый простой — модульное тестирование. Взгляните на диаграмму ниже. Я обозначил области тестирования бледно-зеленым и синим овалами.
У модульных тестов есть несколько преимуществ:
- Они быстро выполняются.
- Легко интегрируются в конвейер CI/CD.
- Могут выполняться параллельно (если правильно написаны).
Однако у них есть и проблема. Юнит-тесты не проверяют взаимодействие с реальными внешними сервисами (например, Redis, RabbitMQ). Речь идет о проверке бизнес-логики, но не о реальном сценарии производства.
Я написал длинную статью о паттернах модульного тестирования и лучших практиках. Ознакомьтесь с ним, он действительно потрясающий.
Интеграционное тестирование
Нам нужно расширить перспективы. Значит, интеграционные тесты могут пригодиться, верно? Взгляните на следующую диаграмму ниже.
В этом случае мы проверяем взаимодействие с внешними сервисами. Однако остается одна проблема. Бизнес-операция включает в себя несколько компонентов взаимодействия. Даже если каждый модуль протестирован должным образом, как мы можем проверить корректность мультисервисного запроса (т.е. бизнес-сценария)? Например, если API-сервис
внесет разрушающее изменение в формат выходного сообщения, то gain-сервис
не сможет успешно продолжить обогащение. Хотя интеграционные и модульные тесты API-сервиса
пройдут.
Чтобы решить эту проблему, нам нужно что-то помимо интеграционных тестов.
Я написал статью, глубоко объясняющую интеграционные тесты. Вам стоит ознакомиться с ней.
E2E тестирование
Идея E2E-тестирования проста. Мы рассматриваем всю систему как черный ящик, который принимает некоторые данные и возвращает вычисленный результат (синхронно или асинхронно). Взгляните на схему ниже.
Что ж, звучит разумно и внушает доверие. Но как мы можем ее реализовать? С чего мы начнем? Давайте начнем разбирать эту проблему шаг за шагом.
Стратегии выпуска
Во-первых, давайте проясним конвейер выпуска отдельных сервисов. Это поможет нам понять весь подход к тестированию E2E. Взгляните на схему ниже.
Вот пошаговая схема:
- Разработчик вносит изменения в ветку
feature/task
. - Затем делает pull request из ветки
feature/task
в веткуmaster
. - Во время конвейера CI происходит сборка запроса (т.е. выполнение юнит-тестов и интеграционных тестов).
- Если конвейер зеленый, изменения сливаются в ветку
master
. - После слияния pull request результирующий артефакт публикуется в Docker Hub.
- Когда запускается релиз (например, по расписанию), на этапе
deploy
извлекается необходимый образ Docker (по умолчанию последний) и запускается в указанном окружении.
Итак, как мы можем поместить E2E-тесты в указанный процесс? На самом деле, есть несколько способов.
Стратегия синхронного выпуска
Это самый простой для понимания подход. Независимо от того, сколько у нас сервисов, конвейер выпуска развертывает каждый из них в рамках одного задания. В этом случае нам просто нужно запустить E2E-тесты непосредственно перед развертыванием артефактов в продакшн. Взгляните на схему ниже, описывающую этот процесс.
Алгоритм следующий:
- Триггер релиза
- Извлечь образы всех сервисов из Docker Hub (по умолчанию последние).
- Запустить E2E-тесты с извлеченными образами (я объясню вам подход позже в статье).
- Если тесты прошли успешно, разверните извлеченные образы.
Несмотря на свою простоту, этот подход имеет существенное препятствие. Вы не можете обновить один микросервис
изолированно. Это означает, что разные модули должны быть выпущены все сразу. Хотя в реальности некоторые микросервисы приходится обновлять чаще, чем другие. Но здесь необходимо выбрать такой триггер выпуска, который удовлетворял бы (хотя бы частично) требованиям каждого сервиса.
Стратегия асинхронного освобождения
Эта стратегия подразумевает обновление каждого сервиса как изолированной функциональности. Каждый модуль может быть развернут в соответствии со своими собственными правилами.
Вот пример стратегии асинхронного высвобождения. Взгляните на схему ниже.
Как видите, схема похожа на конвейер выпуска одного модуля, который мы видели раньше. Хотя есть и небольшие отличия. Теперь есть этап 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
- Сначала запускаются модульные и интеграционные тесты. Эти два шага обычно совмещаются с самой сборкой артефакта.
- Затем собирается текущая версия
API-Service
и сохраняется локально в виде образа Docker. Мы не выкладываем ее в хаб, потому что предложенные изменения могут оказаться некорректными (мы еще не запускали E2E-тесты для проверки). Хотя некоторые CI-провайдеры не позволяют собирать образы Docker локально, чтобы использовать их позже. В этом случае вы можете указать тег, который не будет использоваться в продакшене. Например,dev-CI_BUILD_ID
. - Затем мы извлекаем образ Docker, содержащий сами E2E-тесты. Как мы увидим позже, это простое приложение. Поэтому его удобно хранить и в Docker Hub.
- И, наконец, пришло время запустить E2E-тесты. Приложение, содержащее тесты, должно быть настроено на запуск с различными Docker-образами сервисов (в данном случае
API-Service
иGain-Service
). Здесь мы помещаемAPI_SERVICE_IMAGE
как тот, который мы создали локально в шаге 2.
Все остальные сервисы должны иметь в качестве последнего тега Docker-образ по умолчанию. Это даст нам возможность запускать тесты E2E в любом репозитории, переопределив текущую версию образа сервиса.
Если все проверки пройдены, PR принимается к слиянию. После слияния новая версия API-Service
выкладывается в Docker Hub с тегом latest
.
E2E-тесты, выполняемые перед стадией развертывания
- Юнит-тесты и интеграционные тесты выполняются одинаково.
- Последняя версия образов
E2E-tests
извлекается из Docker Hub. - 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. Посмотрите на список элементов ниже.
- Клиент отправляет сообщение, содержащее значения
msisdn
иcookie
наAPI-Service
. - Сообщение без модификаций должно быть передано в RabbitMQ в конечном итоге.
- Клиент отправляет сообщение, содержащее только значение
cookie
вAPI-Service
. - Обогащенное сообщение с определенным значением
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-тестированию. Если у вас есть вопросы или предложения, пожалуйста, оставляйте свои комментарии ниже. Кроме того, вы всегда можете написать мне напрямую. Я буду рад обсудить эту тему. Спасибо за чтение!
Ресурсы
- Репозиторий с исходным кодом
- Руководство по тестированию Apache Spark, Hive и Spring Boot
- Тестирование Spring Boot — тестовые контейнеры и Flyway
- Тестирование Spring Boot — данные и сервисы
- Spring Data JPA — чистые тесты
- Глубокое погружение в модульное тестирование
- Правильное интеграционное тестирование
- SLF4J
- Logback
- Kibana
- Паттерны для запуска тестов внутри контейнера Docker