Интеграционное тестирование, если у вас R2DBC и liquibase

71783cf875850bf994af87c9a9ec8402

Уже немало копий поломали в поиске грааля идеального способа интеграционного тестирования с использованием БД.

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

Постановка задачи

Необходимо обеспечить возможность запуска интеграционных тестов БД с использованием r2dbc так, что бы удовлетворять следующим условиям:

  1. Выбор образа БД на основе тэгов докер образов;

  2. Запуск локально при помощи testcontainers (docker);

  3. Запуск пайплайнов в gitlab-runner без использования docker: dind (полный запрет на привилигированный доступ из контейнера).

По мнению автора максимально близко приблизился к желаемому результату некий Овечкин: https://habr.com/ru/articles/681232/ . Разумеется у него не обошлось без фатального недостатка, и проблема не только в другой вере использовании datasource, но так же нагромождении ненужного кода и усложнения. Автор не разделяет мнение, что раз сеньор, то сиди и страдай на костыле, а джуну только аннотацию поставь и ни о чем не думай.

Предлагается доработать его решение до библиотеки, которую может и нельзя выставлить в виде некоего spring-boot-testcontainers-starter-gitlab-edition, но общей библиотеки ваших микросервисов, которую будет достаточно «просто подключить в testImplementation» там, где необходимо.

Решение

В Spring boot присутствуют авто-конфигурации, предлагается использовать их для получения нужного результата, пример такой конфигурации:

@Slf4j
@ConditionalOnProperty(name = "testcontainers.db.enabled", havingValue = "true", matchIfMissing = true)
@AutoConfiguration
@EnableConfigurationProperties({R2dbcProperties.class, DbTestContainerConfig.class})
public class DbTestContainerAutoConfiguration implements BeanFactoryPostProcessor {

    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
        beanFactory.getBeanDefinition(decapitalize(ConnectionFactory.class.getSimpleName()))
                .setDependsOn(decapitalize(PostgresContainer.class.getSimpleName()));
    }

    @Bean
    public PostgresContainer postgresContainer(
            DbTestContainerConfig dbTestContainerConfig,
            R2dbcProperties r2dbcProperties
    ) {
        return new PostgresContainer(dbTestContainerConfig, r2dbcProperties);
    }
}

Данная конфигурация отключаема через настройку testcontainers.db.enabled, что позволит нам в будущем не запускать ее при работе gitlab-runner. Разумеется, если вам удобно, то можете сидеть на dind, тогда возможность отключать конфигурацию вам не нужна.

Прямая зависимость ConnectionFactory от нашего тест-контейнера позволит нам предоставить для фабрики наш собственный набор настроек подключения, для этого методом научного ctrl+c/ctrl+v получим в бине, реализующем интерфейс R2dbcConnectionDetails следующий метод:

    @Override
    public ConnectionFactoryOptions getConnectionFactoryOptions() {
        String connectionUrl = getConnectionUrl();
        ConnectionFactoryOptions urlOptions = ConnectionFactoryOptions.parse(connectionUrl);
        ConnectionFactoryOptions.Builder optionsBuilder = urlOptions.mutate();
        configureIf(optionsBuilder, urlOptions, ConnectionFactoryOptions.USER, postgreSQLContainer::getUsername, StringUtils::hasText);
        configureIf(optionsBuilder, urlOptions, ConnectionFactoryOptions.PASSWORD, postgreSQLContainer::getPassword, StringUtils::hasText);
        configureIf(optionsBuilder, urlOptions, ConnectionFactoryOptions.DATABASE, postgreSQLContainer::getDatabaseName, StringUtils::hasText);
        if (this.properties.getProperties() != null) {
            this.properties.getProperties()
                    .forEach((key, value) -> optionsBuilder.option(Option.valueOf(key), value));
        }
        log.debug("Configured r2dbc [url: {}]", connectionUrl);
        return optionsBuilder.build();
    }

Это позволит нам предоставить для ConnectionFactory наш собственный набор параметров подключения, переопределяя имеющиеся.

Теперь, используя эту библиотеку, мы можем подключить ее к нашему проекту, задать желаемый образ в application.yml:

testcontainers:
  db:
    image: "registry.nexus.dev.lo/testcontainers/database-liquibase:dev"

И писать тесты с полностью рабочей БД в нашем тестовом окружении без необходимости задавать какие-то лишние аннотации:

@SpringBootTest
class PostgreNoteDaoTest {
    @Autowired
    PostgreNoteDao dao;

    @Test
    void test_save_read() {
        StepVerifier.create(dao.save(Note.builder()
                        .title("Example")
                        .content("Hello")
                        .build()))
                .expectSubscription()
                .assertNext(note -> {
                    assertEquals(1L, note.getId());
                    assertNotNull(note.getCreatedAt());
                    assertNotNull(note.getUpdatedAt());
                    assertEquals(note.getCreatedAt(), note.getUpdatedAt());
                })
                .verifyComplete();

        StepVerifier.create(dao.find(1L))
                .expectSubscription()
                .assertNext(note -> {
                    assertEquals(1L, note.getId());
                    assertEquals("Example", note.getTitle());
                    assertEquals("Hello", note.getContent());
                    assertNotNull(note.getCreatedAt());
                    assertNotNull(note.getUpdatedAt());
                    assertEquals(note.getCreatedAt(), note.getUpdatedAt());
                })
                .verifyComplete();
    }
}

Если запуск SpringBootTest вам не подходит, то можно всегда импортировать авто-конфигурацию, она подключит для вас БД, однако в этом случае каждый импорт будет запускать свой инстанс БД.

Возможность локального запуска интеграционных тестов есть. Приступим к gitlab-runner.

Настраиваем Gitlab CI

Как описано выше по религиозными соображениям в связи с проблемами в безопасности, использование docker: dind — невозможно. Для того, что бы запускать интеграционные тесты в пайплайне будет достаточно, если мы предоставим для тестов параметры подключения. Например так:

build:
  variables:
    GIT_DEPTH: 1
    GRADLE_OPTS: >
      -Dorg.gradle.daemon=false
      -Dorg.gradle.caching=true
      -Dorg.gradle.internal.launcher.welcomeMessageEnabled=false
    DB_IMAGE: "registry.nexus.dev.lo/testcontainers/database-liquibase"
    DB_TAG: "dev"
    POSTGRES_DB: postgres
    POSTGRES_USER: user
    POSTGRES_PASSWORD: password
    PGPASSWORD: password
    POSTGRES_SCHEMA: public
    POSTGRES_PORT: 5432
    TESTCONTAINERS_DB_ENABLED: "false"
    SPRING_R2DBC_URL: "r2dbc:postgresql://test-database:${POSTGRES_PORT}/${POSTGRES_DB}?currentSchema=${POSTGRES_SCHEMA}&sslmode=disable&binary_parameters=yes"
    SPRING_R2DBC_USERNAME: "${POSTGRES_USER}"
    SPRING_R2DBC_PASSWORD: "${POSTGRES_PASSWORD}"
  services:
    - name: "${DB_IMAGE}:${DB_TAG}"
      alias: test-database
  stage: build
  rules:
    - if: $CI_COMMIT_REF_NAME =~ /^(bugfix|hotfix|feature|dev).*$/
  script:
    - gradle clean assemble check

Обратите внимание, что переменной TESTCONTAINERS_DB_ENABLED: «false» мы запрещаем запускаться тестконтейнерам.

Вместо этого мы настраиваем сервис согласно тому образу, который разработчик указывает в переменных DB_IMAGE: DB_TAG. Очевидно, что их можно некоторым образом брать из ветки/файла или названия MR, но лучше остановиться на этом. Образы БД редко меняются и обычно достаточно текущей dev версии. Если разработка требует времени и частого запуска — всегда можно отредактировать переменную, а сами изменения внести в отдельный ченжсет в Intellij Idea. Так вы случайно не закоммитите нежелательные результаты. Да и ревью никто не отменял. Что еще делает не dev тэг в вашем МР, сударь?

Примечание: если у вас мультимодульный проект, то запуск тестов будет отличаться. Для тест-контейнеров БД будет создаваться на каждый модуль, в то время как в пайплайне на всю сессию тестирования. Нужно учитывать этот факт при написании тестов.

Бонус: Как нам свой образ БД построить

Очевидно, что образ БД можно всегда делать руками, достаточно простого Dockerfile:

FROM registry.nexus.dev.lo/lib/postgres:15-alpine
COPY database.sql /docker-entrypoint-initdb.d/

Всего-то нужно сделать дамп желаемой БД в файл database.sql и образ у вас. Но ведь нам требуется автоматизированное решение, которое позволит создавать образы без лишних усилий.

Для начала нам потребуется агент:

FROM registry.nexus.dev.lo/lib/postgres:15-alpine AS postgres-image
FROM gradle:8.5-jdk17-alpine AS gradle
COPY --from=postgres-image /usr/local /usr/local/
COPY --from=postgres-image /usr/lib /usr/lib/
COPY --from=postgres-image /lib /lib/

Если у кого есть более хороший вариант как получить комбо-образ для gradle + pg_dump — напишите в комментариях, а то образ толстоват — 900 мб.

Теперь нам потребуется сделать две задачи в пайплайне, первая провалидирует корректность нашего liquibase ченжлога, а вторая создаст нам образ из дампа, полученного на первом шаге.

Валидация liquibase проста:

validate:
  stage: validate
  variables:
    POSTGRES_DB: postgres
    POSTGRES_USER: user
    POSTGRES_PASSWORD: password
    PGPASSWORD: password
    POSTGRES_SCHEMA: public
    POSTGRES_PORT: 5432
  services:
    - name: registry.nexus.dev.lo/lib/postgres:15-alpine
      alias: liquibase-postgres
  artifacts:
    paths:
      - database.sql
    expire_in: 1 day
  script:
    - >
      gradle update validate
      -Ddb.context=database,schema,test-data 
      -Ddb.server=liquibase-postgres 
      -Ddb.name=${POSTGRES_DB} 
      -Ddb.login=${POSTGRES_USER} 
      -Ddb.password=${POSTGRES_PASSWORD} 
      -Ddb.port=${POSTGRES_PORT} 
      -Ddb.schema=${POSTGRES_SCHEMA}
    - pg_dump -p 5432 -h localhost --username=${POSTGRES_USER} -d "${POSTGRES_DB}" > database.sql
  rules:
    - if: $CI_COMMIT_REF_NAME =~ /^(dev).*$/
    - if: $CI_MERGE_REQUEST_ID

В артефакты добавляем наш database.sql, что бы он был доступен задачи построеня образа.

Как известно, использовать docker: dind — нельзя, но собрать образ нужно, для этого существует аналог — buildah, он не требует никаких привилегий в системе и может работать в gitlab-runner как есть.

Пример базовой задачи для построения образа:

.build_image_base:
  image: registry.nexus.dev.lo/lib/buildah:v1.34.0
  stage: validate
  needs:
    - job: validate
  variables:
    NEXUS_REGISTRY: registry.nexus.dev.lo
    BUILDAH_FORMAT: docker
    STORAGE_DRIVER: vfs
  before_script:
    - echo "${CI_BOT_NEXUS_PASSWORD}" | buildah login --tls-verify=false -u "${CI_BOT_NEXUS_USERNAME}" --password-stdin ${NEXUS_REGISTRY}
  script:
    - buildah build --tls-verify=false -t "${IMAGE_NAME}:${IMAGE_VERSION}"
    - buildah images
    - buildah push --tls-verify=false "${IMAGE_NAME}:${IMAGE_VERSION}"

Не забудьте определить переменные CI_BOT_NEXUS_PASSWORD и CI_BOT_NEXUS_USERNAME в переменных проекта. Либо используйте регистр образов самого гитлаба.

Теперь имея эту базу, мы можем сделать два варианта для получения образов: для dev и для мерж реквестов:

build_image_dev:
  extends: .build_image_base
  variables:
    IMAGE_VERSION: "dev"
  rules:
    - if: $CI_COMMIT_REF_NAME =~ /^(dev).*$/

build_image_feature:
  extends: .build_image_base
  variables:
    IMAGE_VERSION: "MR-$CI_MERGE_REQUEST_ID"
  rules:
    - if: $CI_MERGE_REQUEST_ID
      when: manual

Сделаем сборку из мерж реквестов запускаемой руками, так как эти образы не всегда могут быть нужны, а забивать регистр не стоит.

Теперь автоматизация образов БД так же в наличии.

Заключение

В статье рассмотрен способ унификации процесса интеграционного тестирования БД для R2DBC, предложен вариант автоматизации создания образов БД из ченжлога liquibase.

Ссылки на репозитории

  1. https://github.com/lastrix/database-liquibase построение образа БД из liquibase схемы;

  2. https://github.com/lastrix/note-app — библиотека и пример использования.

© Habrahabr.ru