E2E-тестирование в CI с помощью Testcontainers
Киреков Семен
Пару слов о себе
Telegram: @kirekov
2
План доклада
Telegram: @kirekov
3
Telegram: @kirekov
4
Часть 1. Unit/Integration/E2E-тесты
Архитектура системы
Telegram: @kirekov
5
Unit-тестирование
Telegram: @kirekov
6
Integration-тестирование
Telegram: @kirekov
7
Проверка обратной совместимости
Telegram: @kirekov
8
E2E-тестирование
Telegram: @kirekov
9
Плюсы E2E-тестирования
Telegram: @kirekov
10
Минусы E2E-тестирования
Telegram: @kirekov
11
Отличие Integration от E2E тесты
Telegram: @kirekov
12
Telegram: @kirekov
13
Часть 2. Варианты запусков E2E-тестов
Ночные сборки�на dev-ветке
14
Плюсы dev-branch подхода
Telegram: @kirekov
15
Минусы dev-branch подхода
Telegram: @kirekov
16
Сборка на CI
Telegram: @kirekov
17
Плюсы E2E-тестов на CI
Telegram: @kirekov
18
Минус E2E-тестов на CI
Telegram: @kirekov
19
Telegram: @kirekov
20
Часть 3. Реализация E2E-тестов
Технологический стек
Telegram: @kirekov
21
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;
Docker �Common� Network
23
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);� }
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")));�}
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")));�}
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);� }
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")));�}
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")));�}
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);� }
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()� )� )� );�}
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 "� )));� }�}
Очистка данных между E2E тестами
Telegram: @kirekov
33
@BeforeEach�void beforeEach() {� redis.cleanRedis();� testRabbitListener.resetMessages();�}
Результаты E2E теста
Telegram: @kirekov
34
Telegram: @kirekov
35
Часть 4. Запуск E2E-тестов на CI
Технологический стек
Telegram: @kirekov
36
Стадии Pipeline
Telegram: @kirekov
37
Dockerfile E2E тестов
Telegram: @kirekov
38
FROM openjdk:17-alpine��WORKDIR /app��COPY . /app��CMD ["/app/gradlew", ":e2e-tests:test"]
GitHub Actions: правила запуска
Telegram: @kirekov
39
name: Java CI��on:� push:� branches: [ "master" ]� pull_request:� branches: [ "master" ]
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
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
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
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
Job Build Prod Images
Telegram: @kirekov
44
build-prod-images:� needs:� - e2e-tests� runs-on: ubuntu-latest� if: github.ref == 'refs/heads/master'� steps: …
Результат запуска
Telegram: @kirekov
45
Потенциальные проблема
Telegram: @kirekov
46
Долгое выполнение тестов
Telegram: @kirekov
47
Как ускорить старт контейнеров?
Telegram: @kirekov
48
Как ускорить сами тесты?
Telegram: @kirekov
49
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 секунд
Как ускорить сами тесты?
Telegram: @kirekov
51
Способы параллельного запуска
Telegram: @kirekov
52
Параллельный запуск инфраструктуры
Telegram: @kirekov
53
tasks.named('test') {� useJUnitPlatform {� includeTags project.property('testTags')� }�}
Выводы
Telegram: @kirekov
54
Спасибо за внимание!
55