1 of 55

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

Киреков Семен

2 of 55

Пару слов о себе

  • Киреков Семен
  • Java Dev/Team Lead в МТС Диджитал, Центре «Big Data»
  • Java-декан в MTS Teta

Telegram: @kirekov

2

3 of 55

План доклада

  • Unit/Integration/E2E-тесты: назначение и различия
  • Зачем нужны E2E-тесты?
  • Варианты запуска
  • Примеры кода: тесты + GitHub Actions Pipeline
  • SimonHarmonicMinor/e2e-tests-example

Telegram: @kirekov

3

4 of 55

Telegram: @kirekov

4

Часть 1. Unit/Integration/E2E-тесты

5 of 55

Архитектура системы

Telegram: @kirekov

5

6 of 55

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

Telegram: @kirekov

6

7 of 55

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

Telegram: @kirekov

7

8 of 55

Проверка обратной совместимости

  • Producer отправил сообщение в неверном формате. Consumer не смог его десериализовать
  • Client отправил REST запрос на неверный endpoint
  • Client ожидал сообщение не из того топика

Telegram: @kirekov

8

9 of 55

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

Telegram: @kirekov

9

10 of 55

Плюсы E2E-тестирования

  • Проверяем бизнес-сценарий целиком
  • Можно провалидировать самые важные кейсы перед production

Telegram: @kirekov

10

11 of 55

Минусы E2E-тестирования

  • Крайне сложная конфигурация
  • Очень высокий порог входа
  • Долгий bootstrap и время работы
  • Запускать параллельно невероятно трудно
  • Логов самих тестов не хватит. Нужны логи сервисов

Telegram: @kirekov

11

12 of 55

Отличие Integration от E2E тесты

  • Integration тест «поднимает» сервис и его прямые зависимости (часть окружения)
  • E2E тест «поднимает» все сервисы и зависимости (все окружение)

Telegram: @kirekov

12

13 of 55

Telegram: @kirekov

13

Часть 2. Варианты запусков E2E-тестов

14 of 55

Ночные сборки�на dev-ветке

14

15 of 55

Плюсы dev-branch подхода

  • Простая реализация
  • Знакома многим разработчикам

Telegram: @kirekov

15

16 of 55

Минусы dev-branch подхода

  • Долгий ответ между реализаций фичи и E2E-тестами
  • Исправление может усложниться из-за «наслоения» кода других фич
  • CI pipeline не предотвращает возможные ошибки
    • Feature/branch не из той ветки
    • Мерж dev в master до (или во время) запуска тестов
  • Дублирование в разных репозиториях

Telegram: @kirekov

16

17 of 55

Сборка на CI

Telegram: @kirekov

17

18 of 55

Плюсы E2E-тестов на CI

  • Быстрый ответ между реализаций фичи и E2E-тестами
  • Заведомо некорректный код не доходит до каких-либо стендов
  • CI pipeline предотвращает возможные ошибки

Telegram: @kirekov

18

19 of 55

Минус E2E-тестов на CI

  • Сложная реализация
    • Сейчас все расскажу

Telegram: @kirekov

19

20 of 55

Telegram: @kirekov

20

Часть 3. Реализация E2E-тестов

21 of 55

Технологический стек

  • Java 17
  • JUnit 5 + Spring Boot Test
  • Testcontainers

Telegram: @kirekov

21

22 of 55

E2ESuite

Telegram: @kirekov

22

@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;

23 of 55

Docker �Common� Network

23

24 of 55

Initializer

Telegram: @kirekov

24

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 = requireNonNull(� environment.getProperty("api.exposed-port", Integer.class),� "API Exposed Port is null"� );� API_SERVICE = createApiServiceContainer(environment, apiExposedPort);� GAIN_SERVICE = createGainServiceContainer(environment);�� Startables.deepStart(API_SERVICE, GAIN_SERVICE).join();� setPropertiesForConnections(environment);� }

25 of 55

Create Redis Container

Telegram: @kirekov

25

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")));�}

26 of 55

Create RabbitMQ Container

Telegram: @kirekov

26

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")));�}

27 of 55

Initializer

Telegram: @kirekov

27

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 = requireNonNull(� environment.getProperty("api.exposed-port", Integer.class),� "API Exposed Port is null"� );� API_SERVICE = createApiServiceContainer(environment, apiExposedPort);� GAIN_SERVICE = createGainServiceContainer(environment);�� Startables.deepStart(API_SERVICE, GAIN_SERVICE).join();� setPropertiesForConnections(environment);� }

28 of 55

Create �API-Service

Telegram: @kirekov

28

private GenericContainer<?> createApiServiceContainer(Environment environment, 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(alwaysPull())� .withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger("API-Service")));�}

29 of 55

Create Gain-Service

Telegram: @kirekov

29

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(alwaysPull())� .withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger("Gain-Service")));�}

30 of 55

Initializer

Telegram: @kirekov

30

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 = requireNonNull(� environment.getProperty("api.exposed-port", Integer.class),� "API Exposed Port is null"� );� API_SERVICE = createApiServiceContainer(environment, apiExposedPort);� GAIN_SERVICE = createGainServiceContainer(environment);�� Startables.deepStart(API_SERVICE, GAIN_SERVICE).join();� setPropertiesForConnections(environment);� }

31 of 55

SetPropertiesForConnection

Telegram: @kirekov

31

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()� )� )� );�}

32 of 55

E2E тест

Telegram: @kirekov

32

class GainTest extends E2ESuite {� @Test� void shouldGainMessage() {� rest.post(� "/api/message",� Map.of("some_key", "some_value", "cookie", "cookie-value", "msisdn", "88005553535"),� Void.class� );� await().atMost(FIVE_SECONDS)� .until(() -> getGainQueueMessages().contains(Map.of(� "some_key", "some_value",� "cookie", "cookie-value",� "msisdn", "88005553535 "� )));� 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", "88005553535 "� )));� }�}

33 of 55

Очистка данных между E2E тестами

Telegram: @kirekov

33

@BeforeEach�void beforeEach() {� redis.cleanRedis();� testRabbitListener.resetMessages();�}

34 of 55

Результаты E2E теста

Telegram: @kirekov

34

35 of 55

Telegram: @kirekov

35

Часть 4. Запуск E2E-тестов на CI

36 of 55

Технологический стек

  • GitHub Actions
  • Монорепозиторий

Telegram: @kirekov

36

37 of 55

Стадии Pipeline

  1. Build – unit и integration тестирование API-Service и Gain-Service + сборка артефактов
  2. Build-dev-images – сборка и публикации API-Service и Gain-Service в Docker images под тегом dev-$CI_BUILD_NUM
  3. E2E-tests – запуск E2E-тестов
  4. Build-prod-images – сборка и публикация API-Service и Gain-Service в Docker images по тегом latest. Выполняется только при сборке master

Telegram: @kirekov

37

38 of 55

Dockerfile E2E тестов

Telegram: @kirekov

38

FROM openjdk:17-alpine�WORKDIR /app��COPY . /app��CMD ["/app/gradlew", ":e2e-tests:test"]

39 of 55

GitHub Actions: правила запуска

Telegram: @kirekov

39

name: Java CI��on:� push:� branches: [ "master" ]� pull_request:� branches: [ "master" ]

40 of 55

Job Build

Telegram: @kirekov

40

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@...� with:� arguments: :gain-service:build :api-service:build

41 of 55

Job Build�dev�images (part 1)

Telegram: @kirekov

41

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"

Новый Docker Image пробрасывается в E2E-tests job

Пробрасываем в следующий step Dev-images job

Пробрасываем в E2E-tests job

42 of 55

Telegram: @kirekov

42

- 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 }}

Job Build�dev�images (part 2)

Обращаемся к проброшенному Docker Image

43 of 55

E2E tests Job

Telegram: @kirekov

43

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

Обращаемся к проброшенному Docker Image

44 of 55

Job Build Prod Images

Telegram: @kirekov

44

build-prod-images:� needs:� - e2e-tests� runs-on: ubuntu-latest� if: github.ref == 'refs/heads/master'� steps:

45 of 55

Результат запуска

Telegram: @kirekov

45

46 of 55

Потенциальные проблема

  • Долгое выполнение тестов

Telegram: @kirekov

46

47 of 55

Долгое выполнение тестов

  • Обычно контейнеры запускаются намного дольше, чем выполняются тесты
  • Но запускаются они единожды перед началом тестирования
  • Решение – ускорение старта контейнеров

Telegram: @kirekov

47

48 of 55

Как ускорить старт контейнеров?

  • Независимые контейнеры следуют стартовать параллельно
  • Обратите внимание на pull policy: не нужно вытягивать повторно те же самые docker images
  • Testcontainers поддерживает опцию withReuse

Telegram: @kirekov

48

49 of 55

Как ускорить сами тесты?

  • Правильно расставляйте timeout в тестах и используйте awaitility

Telegram: @kirekov

49

50 of 55

Awaitility vs Thread.sleep

Telegram: @kirekov

50

await().atMost(FIVE_SECONDS)� .until(() -> getGainQueueMessages().contains(Map.of(� "some_key", "some_value",� "cookie", "cookie-value",� "msisdn", "88005553535"� )));

Thread.sleep(5000);�assertTrue(getGainQueueMessages().contains(Map.of(� "some_key", "some_value",� "cookie", "cookie-value",� "msisdn", "88005553535"�)));

Будет ждать 5 секунд

Будет ждать от

0 до 5 секунд

51 of 55

Как ускорить сами тесты?

  • Правильно расставляйте timeout в тестах и используйте awaitility
  • Параллельный запуск

Telegram: @kirekov

51

52 of 55

Способы параллельного запуска

  • На каждое сообщение добавляется уникальный ID. Далее в ответе смотрим только сообщение с нужным ID
  • Параллельный запуск инфраструктуры

Telegram: @kirekov

52

53 of 55

Параллельный запуск инфраструктуры

  • Добавляем возможность запускать часть тестов. Например, с помощью JUnit тегов

  • Стартуем параллельно экземпляров тестов, равных количеству тегов

Telegram: @kirekov

53

tasks.named('test') {� useJUnitPlatform {� includeTags project.property('testTags')� }�}

54 of 55

Выводы

  • Настроили автоматический запуск E2E тестов на CI во время сборки PR
  • Запуск E2E тестов во время сборки PR позволяет исправлять ошибки до того, как они попадут на environment (dev/staging/prod) и так далее
  • Подход применим как монорепозиториям, так и repository per service
  • E2E-тесты не так сложны, как может показаться ☺

Telegram: @kirekov

54

55 of 55

Спасибо за внимание!

55