Уже немало копий поломали в поиске грааля идеального способа интеграционного тестирования с использованием БД.
Вашему вниманию предлагается способ решения этой проблемы самым минималистичным способом без необходимости создавать угрозы безопасности вашего кластера или создавать разработчикам невыносимые условия труда. Впрочем что может быть хуже сломанной кофе-машины или отсутствия лавандового рафа?
Постановка задачи
Необходимо обеспечить возможность запуска интеграционных тестов БД с использованием r2dbc так, что бы удовлетворять следующим условиям:
Выбор образа БД на основе тэгов докер образов;
Запуск локально при помощи testcontainers (docker);
Запуск пайплайнов в gitlab-runner без использования docker:dind (полный запрет на привилигированный доступ из контейнера).
По мнению автора максимально близко приблизился к желаемому результату некий Овечкин: «Spring Test Containers как бины». Разумеется у него не обошлось без фатального недостатка, и проблема не только в другой вере использовании 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.
Ссылки на репозитории
https://github.com/lastrix/database-liquibase построение образа БД из liquibase схемы;
https://github.com/lastrix/note-app - библиотека и пример использования.
