Введение
TestContainers довольно мощная штука, почитать о том, что это такое, зачем нужно можно на оф сайте, а так же есть интересная статья на Хабре.
Традиционная настройка через junit4 (посмотреть, как это делается можно в статьях в этой статье, этой статье), а более красивая через junit5 с использованием DynamicPropertySource (можно посмотреть в данной статье) довольно удобны в том случае, когда у нас простые контейнеры, никак не связанные между собой.
Трудности возникают в том случае, когда появляется потребность провести тестирование, например, базы + очереди, нескольких очередей и т.д., в общем, когда необходимо хотим провести сложное интеграционное тестирование.
Проблема состоит в том, чтобы определить последовательность старта контейнеров, динамически сформировать properties для контейнеров, которые зависят друг от друга.
И в этом случае можно воспользоваться имеющимся механизмом spring-beans – dependsOn.
Теория
Разбирать будем на простом примере тестовый контекст + тестконтейнер + определение параметров для подключения к БД в тестовом контексте.
Spring предоставляет разные возможности для того, чтобы настроить контекст. Этим мы и воспользуемся для того чтобы:
Определить Bean с контейнером
Определить момент запуска контейнера
Динамически заполнить «.properties» файл значениями для подключения к БД ДО того как приложение начнёт пытаться подключиться к этой самой БД
И сделаем так, чтобы это вся работа выполнялась только при наличии аннотации
Для достижения этих целей будет использоваться механизм BeanFactoryPostProcessor (как дань уважения Евгению Борисову ;) ), т.к. благодаря BFPP возможно взаимодействовать с BeanDefinition’ами до того, как по ним начнут строиться бины. Этот механизм как раз будет использоваться для того, чтобы задать порядок старта контейнеров.
Практика
1. Определяем бин с контейнером
Описываем класс, в котором указываем что за контейнер нам нужен: указываем image, логин, пароль, указываем порт (чтобы можно было локально приконнектиться при дебаге)
public class PostgresContainer {
private final PostgreSQLContainer<?> postgreSQLContainer;
public PostgresContainer() {
postgreSQLContainer = new PostgreSQLContainer<>(DockerImageName.parse("postgres:14.3"))3"))
.withDatabaseName("postgres")
.withUsername("postgres")
.withPassword("example")
.withExposedPorts(5432)
;
}
}
2. Определяем момент запуска контейнера
Запускать будем в PostConstruct.
Затем заполним конфиг файл для подключения к бд данными из нашего контейнера.
При разрушении контеста тоже пропишем логику отключения контейнера.
В итоге получится класс
public class PostgresContainer {
private final PostgreSQLContainer<?> postgreSQLContainer;
public PostgresContainer() {
postgreSQLContainer = new PostgreSQLContainer<>(DockerImageName.parse("postgres:14.3"))
.withDatabaseName("postgres")
.withUsername("postgres")
.withPassword("example")
.withExposedPorts(5432)
;
}
@PostConstruct
public void start() {
postgreSQLContainer.start();
System.setProperty("spring.datasource.url", postgreSQLContainer.getJdbcUrl());
System.setProperty("spring.datasource.username", postgreSQLContainer.getUsername());
System.setProperty("spring.datasource.password", postgreSQLContainer.getPassword());
}
@PreDestroy
public void stop() {
postgreSQLContainer.stop();
}
}
3. Теперь нам нужно сделать так, чтобы наш контейнер стартовал сам по себе во время старта контекста.
Для этого создадим аннотацию, в которой создадим поле с названием того бина, который должен зависеть от нашего контейнера, таким образом определяем порядок построения бинов.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface AutoConfigurePostgresContainer {
/**
* Название бина, в BeanDefinition которому пропишется зависимость на #{@link PostgresContainer}
*/
String beanNameThatNeedsToDependOnContainer() default "";
/**
* Класс бина, в BeanDefinition которому пропишется зависимость на #{@link PostgresContainer}
*/
Class<?> beanClassNameThatNeedsToDependOnContainer() default Void.class;
}
Тут конечно можно пойти дальше, создать такое поле, которое будет принимать название бина с самим контейнером (это нужно в случае, если у нас несколько таких контейнеров). В результате получится аннотация, содержащая название бина с контейнером и название бина, который должен зависеть от этого контейнера. Таким образом получится очень гибко настроить порядок старта бинов. Это нужно, например, в случае если один контейнер настраивается такими значениями, которые формирует другой контейнер и т.п.
4. Определим BFPP который будет работать с этой аннотацией
public class DependsOnContainerSetterBFPP implements BeanFactoryPostProcessor, Ordered {
private final AutoConfigurePostgresContainer annotation;
public DependsOnContainerSetterBFPP(AutoConfigurePostgresContainer annotation) {
this.annotation = annotation;
}
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
registerContainer(beanFactory);
setDependsOn(beanFactory, annotation);
}
/**
* Регистрируется класс #{@link PostgresContainer}
*/
private void registerContainer(ConfigurableListableBeanFactory beanFactory) {
BeanDefinitionRegistry registry = (BeanDefinitionRegistry) beanFactory;
registry.registerBeanDefinition(decapitalize(PostgresContainer.class.getSimpleName()),
BeanDefinitionBuilder.genericBeanDefinition(PostgresContainer.class).getBeanDefinition());
}
/**
* Заставляем класс {@code annotation.beanToSubscribeOnEnvironmentSetter}
* зависеть от {@link PostgresContainer}
*/
private void setDependsOn(ConfigurableListableBeanFactory beanFactory, AutoConfigurePostgresContainer annotation) {
beanFactory.getBeanDefinition(geBeanWhoNeedsToDepend(annotation))
.setDependsOn(decapitalize(PostgresContainer.class.getSimpleName()));
}
/**
* @return название бина, который надо подписать на {@link PostgresContainer}
* Название бина достаётся либо из {@param annotation.beanNameThatNeedsToDependOnContainer}
* либо из класса {@param annotation.beanClassNameThatNeedsToDependOnContainer}
* название которого переводится в стрингу и понижается первая буква
*/
private String geBeanWhoNeedsToDepend(AutoConfigurePostgresContainer annotation) {
String beanToSubscribe = annotation.beanNameThatNeedsToDependOnContainer();
if (!beanToSubscribe.isEmpty()) {
return beanToSubscribe;
}
Class<?> beanClassToSubscribe = annotation.beanClassNameThatNeedsToDependOnContainer();
if (beanClassToSubscribe != Void.class) {
return decapitalize(beanClassToSubscribe.getSimpleName());
}
throw new RuntimeException();
}
/**
* Порядок выставляем самый приоритетный, чтобы зависимость прописалась раньше всего
* это не обязательно, но для большего контроля можно добавить
*/
@Override
public int getOrder() {
return 0;
}
}
5. Далее, т.к. контекст у нас тестовый – надо сделать так, чтобы тестовый контекст распознавал нашу аннотацию и делал всю нужную магию. Делается это через кастомизатор контекста, который вызывается фабрикой кастомизаторов контекста (ContextCustomizerFactory -> ContextCustomizer).
Создаём ContextCustomizerFactory, в котором читаем аннотацию и передаём в кастомизатор контекста (который будет создан на след. Шаге)
public class ContainerContextCustomizerFactory implements ContextCustomizerFactory {
@Override
public ContextCustomizer createContextCustomizer(
Class<?> testClass,
List<ContextConfigurationAttributes> configAttributes
) {
AutoConfigurePostgresContainer annotation = testClass.getAnnotation(AutoConfigurePostgresContainer.class);
return new ContainerContextCustomizer(annotation);
}
}
Прописываем созданный класс в META-INF/spring.factories
org.springframework.test.context.ContextCustomizerFactory=\
путь.к.классу.ContainerContextCustomizerFactory
6. Создаём кастомизатор контекста. В котором регистрируем BFPP .
public class ContainerContextCustomizer implements ContextCustomizer {
private final AutoConfigurePostgresContainer annotation;
public ContainerContextCustomizer(AutoConfigurePostgresContainer annotation) {
this.annotation = annotation;
}
@Override
public void customizeContext(
ConfigurableApplicationContext context,
MergedContextConfiguration mergedConfig
) {
context.addBeanFactoryPostProcessor(new DependsOnContainerSetterBFPP(annotation));
}
}
7. Запускаем тест
@SpringBootTest
@AutoConfigurePostgresContainer(beanClassNameThatNeedsToDependOnContainer = DataSource.class)
class DatabaseTest {
@Autowired
private PostgresContainer postgresContainer;
@Autowired
private MyRepository repository;
@Test
void testConnect() {
assertTrue(postgresContainer.getPostgreSQLContainer().isRunning());
}
@Test
void testRepo() {
MyEntity testEntity = new MyEntity().setName("testName");
repository.save(testEntity);
List<MyEntity> all = repository.findAll();
assertThat(testEntity).isEqualTo(all.get(0));
}
}
Разумеется, всё можно кастомизировать и добавлять свой функционал.
Заключение
Теперь не имеет значения какой версии junit используется в тестах, настраиваются контейнеры одинаково.
Было рассмотрено:
Кастомизация тестового контекста через ContextCustomizer
Взаимодействие с BeanDefinition’ами на раннем этапе инициализации этого контекста
Реализация аннотации и взаимодействия тестового контекста с этой аннотацией
Представленный функционал позволяет покрыть тестами более широкую интеграционную часть приложения, что увеличивает качество кода и снижает количество ошибок, которые возникают при регресс тестировании и которые связанны с интеграционной частью.
Ссылки
Интеграционные тесты баз данных с помощью Spring Boot и Testcontainers
Как собрать образ Oracle DB для Testcontainers
Интеграционное тестирование в SpringBoot с TestContainers-стартером
Обзор модульного и интеграционного тестирования Spring Boot
Интеграционные тесты баз данных с помощью Spring Boot и Testcontainers
Комментарии (6)
Keres
08.08.2022 14:05+1А скормить скрипты ликвы не пробовали? В доке вроде бы сказано, что добавили поддержку, но нет никакой информации по алгоритму действий, с flyway всё проще.
Bonifach Автор
08.08.2022 14:11Если под ликвой имеется ввиду ликвибаза, то -
Пробовал (переезжал с h2 на тестконтейнеры, настройки не менял в части ликвибазы), скрипты накатываются, но только есть один нюанс - если использовать автоконфигурацию спринг бутовую, то при настройке тестового контекста вместо указанного (в классе с настройкой контейнера) пользователя начинает использоваться пользователь с логином system (а вот пароль остаётся тот, который указали). Тем не менее - скрипты накатываются, всё работает.
ily_a_inykh
08.08.2022 20:14+1У меня не было необходимости выражать отношение depends on для контейнеров, но мне кажется это можно довольно явно выразить через ´Nested´ (см. ссылку).
Внутренний тест будет запускать после внешних тестов и после их инициализации. Можно и без Spring обойтись, в теории.
Пробовали использовать этот механизм?
Bonifach Автор
09.08.2022 10:04+1Nested не пробовал. В целом стараюсь избегать любых вложенных классов, ибо большой класс, даже хорошо организованный - довольно сложно читать.
В теории да, конечно всегда можно без спринга обойтись (вопрос в том, насколько это сложно), но целью было интегрировать механизм именно в существующее спринговое приложение.
Например, в рамках следующего кейса - в спринговом приложении поднять базу, поднять систему аутентификации+подружить их между собой и начать после всего этого играться с токенами в рамках тестирования. Описанный подход как раз позволил этого добиться.
Ares_ekb
Я замаялся настраивать запуск TestContainers из Docker и GitLab CI/CD и заменил на Zonky.
Bonifach Автор
Вот да, единственное - трудно в Gitlab настроить, т.к. ryuk требует docker.sock чтобы управлять запуском / остановкой / чисткой контейнеров. https://www.testcontainers.org/supported_docker_environment/continuous_integration/gitlab_ci/
На своём раннере ещё можно настроить. На общих гитлабовских раннерах будет сложно что-то сделать.