1. Введение

  2. Теория

  3. Практика

  4. Заключение

  5. Ссылки

Введение

TestContainers довольно мощная штука, почитать о том, что это такое, зачем нужно можно на оф сайте, а так же есть интересная статья на Хабре.

Традиционная настройка через junit4 (посмотреть, как это делается можно в статьях в этой статье, этой статье), а более красивая через junit5 с использованием DynamicPropertySource (можно посмотреть в данной статье) довольно удобны в том случае, когда у нас простые контейнеры, никак не связанные между собой.

Трудности возникают в том случае, когда появляется потребность провести тестирование, например, базы + очереди, нескольких очередей и т.д., в общем, когда необходимо хотим провести сложное интеграционное тестирование.

Проблема состоит в том, чтобы определить последовательность старта контейнеров, динамически сформировать properties для контейнеров, которые зависят друг от друга.

И в этом случае можно воспользоваться имеющимся механизмом spring-beans – dependsOn.

Теория

Разбирать будем на простом примере тестовый контекст + тестконтейнер + определение параметров для подключения к БД в тестовом контексте.

Spring предоставляет разные возможности для того, чтобы настроить контекст. Этим мы и воспользуемся для того чтобы:

  1. Определить Bean с контейнером

  2. Определить момент запуска контейнера

  3. Динамически заполнить «.properties» файл значениями для подключения к БД ДО того как приложение начнёт пытаться подключиться к этой самой БД

  4. И сделаем так, чтобы это вся работа выполнялась только при наличии аннотации

Для достижения этих целей будет использоваться механизм 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 используется в тестах, настраиваются контейнеры одинаково.

Было рассмотрено:

  1. Кастомизация тестового контекста через ContextCustomizer

  2. Взаимодействие с BeanDefinition’ами на раннем этапе инициализации этого контекста

  3. Реализация аннотации и взаимодействия тестового контекста с этой аннотацией

Представленный функционал позволяет покрыть тестами более широкую интеграционную часть приложения, что увеличивает качество кода и снижает количество ошибок, которые возникают при регресс тестировании и которые связанны с интеграционной частью.

Ссылки

Что такое тестконтейнер

Интеграционные тесты баз данных с помощью Spring Boot и Testcontainers

Как собрать образ Oracle DB для Testcontainers

Интеграционное тестирование в SpringBoot с TestContainers-стартером

Обзор модульного и интеграционного тестирования Spring Boot

Интеграционные тесты баз данных с помощью Spring Boot и Testcontainers

Мой репозиторий с настроенным spring bean testcontainer'ом  

Комментарии (6)


  1. Ares_ekb
    08.08.2022 05:19

    Я замаялся настраивать запуск TestContainers из Docker и GitLab CI/CD и заменил на Zonky.


    1. Bonifach Автор
      08.08.2022 11:02
      +1

      Вот да, единственное - трудно в Gitlab настроить, т.к. ryuk требует docker.sock чтобы управлять запуском / остановкой / чисткой контейнеров. https://www.testcontainers.org/supported_docker_environment/continuous_integration/gitlab_ci/

      На своём раннере ещё можно настроить. На общих гитлабовских раннерах будет сложно что-то сделать.


  1. Keres
    08.08.2022 14:05
    +1

    А скормить скрипты ликвы не пробовали? В доке вроде бы сказано, что добавили поддержку, но нет никакой информации по алгоритму действий, с flyway всё проще.


    1. Bonifach Автор
      08.08.2022 14:11

      Если под ликвой имеется ввиду ликвибаза, то -

      Пробовал (переезжал с h2 на тестконтейнеры, настройки не менял в части ликвибазы), скрипты накатываются, но только есть один нюанс - если использовать автоконфигурацию спринг бутовую, то при настройке тестового контекста вместо указанного (в классе с настройкой контейнера) пользователя начинает использоваться пользователь с логином system (а вот пароль остаётся тот, который указали). Тем не менее - скрипты накатываются, всё работает.


  1. ily_a_inykh
    08.08.2022 20:14
    +1

    У меня не было необходимости выражать отношение depends on для контейнеров, но мне кажется это можно довольно явно выразить через ´Nested´ (см. ссылку).

    Внутренний тест будет запускать после внешних тестов и после их инициализации. Можно и без Spring обойтись, в теории.

    Пробовали использовать этот механизм?


    1. Bonifach Автор
      09.08.2022 10:04
      +1

      Nested не пробовал. В целом стараюсь избегать любых вложенных классов, ибо большой класс, даже хорошо организованный - довольно сложно читать.

      В теории да, конечно всегда можно без спринга обойтись (вопрос в том, насколько это сложно), но целью было интегрировать механизм именно в существующее спринговое приложение.

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