Как стать автором
Обновить

Spring Test Containers как бины

Время на прочтение9 мин
Количество просмотров15K
  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'ом  

Теги:
Хабы:
Всего голосов 11: ↑11 и ↓0+11
Комментарии11

Публикации

Истории

Работа

Java разработчик
309 вакансий

Ближайшие события

27 августа – 7 октября
Премия digital-кейсов «Проксима»
МоскваОнлайн
19 сентября
CDI Conf 2024
Москва
20 – 22 сентября
BCI Hack Moscow
Москва
24 сентября
Конференция Fin.Bot 2024
МоскваОнлайн
25 сентября
Конференция Yandex Scale 2024
МоскваОнлайн
28 – 29 сентября
Конференция E-CODE
МоскваОнлайн
28 сентября – 5 октября
О! Хакатон
Онлайн
30 сентября – 1 октября
Конференция фронтенд-разработчиков FrontendConf 2024
МоскваОнлайн
3 – 18 октября
Kokoc Hackathon 2024
Онлайн