Перейти к основному содержимому

Руководство по @DynamicPropertySource весной

· 6 мин. чтения

1. Обзор

Современные приложения не живут изолированно: обычно нам нужно подключаться к различным внешним компонентам, таким как PostgreSQL, Apache Kafka, Cassandra, Redis и другим внешним API.

В этом руководстве мы увидим, как Spring Framework 5.2.5 облегчает тестирование таких приложений с введением динамических свойств .

Во-первых, мы начнем с определения проблемы и посмотрим, как мы раньше решали проблему далеко не идеальным способом. Затем мы представим аннотацию @DynamicPropertySource и посмотрим, как она предлагает лучшее решение той же проблемы. В конце мы также рассмотрим еще одно решение из тестовых фреймворков, которое может быть лучше по сравнению с чистыми решениями Spring.

2. Проблема: динамические свойства

Предположим, мы разрабатываем типичное приложение, использующее PostgreSQL в качестве базы данных. Мы начнем с простого объекта JPA :

@Entity
@Table(name = "articles")
public class Article {

@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;

private String title;

private String content;

// getters and setters
}

Чтобы убедиться, что этот объект работает должным образом, мы должны написать для него тест, чтобы проверить его взаимодействие с базой данных. Поскольку этот тест должен работать с реальной базой данных, мы должны заранее настроить экземпляр PostgreSQL.

Существуют разные подходы к настройке таких инфраструктурных инструментов во время выполнения тестов . Собственно говоря, существует три основных категории таких решений:

  • Настройте отдельный сервер базы данных где-нибудь только для тестов
  • Используйте некоторые легкие альтернативы или подделки для конкретных тестов, такие как H2.
  • Пусть тест сам управляет жизненным циклом базы данных

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

Вот как будет выглядеть наш тестовый рабочий процесс, если мы будем использовать такие технологии, как Testcontainers:

  1. Настройте компонент, такой как PostgreSQL, перед всеми тестами. Обычно эти компоненты прослушивают случайные порты.
  2. Запустите тесты.
  3. Разберите компонент.

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

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

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

3. Традиционное решение

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

@SpringBootTest
@Testcontainers
@ContextConfiguration(initializers = ArticleTraditionalLiveTest.EnvInitializer.class)
class ArticleTraditionalLiveTest {

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:11")
.withDatabaseName("prop")
.withUsername("postgres")
.withPassword("pass")
.withExposedPorts(5432);

static class EnvInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

@Override
public void initialize(ConfigurableApplicationContext applicationContext) {
TestPropertyValues.of(
String.format("spring.datasource.url=jdbc:postgresql://localhost:%d/prop", postgres.getFirstMappedPort()),
"spring.datasource.username=postgres",
"spring.datasource.password=pass"
).applyTo(applicationContext);
}
}

// omitted
}

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

Только после этих шагов мы можем написать наш тест:

@Autowired
private ArticleRepository articleRepository;

@Test
void givenAnArticle_whenPersisted_thenShouldBeAbleToReadIt() {
Article article = new Article();
article.setTitle("A Guide to @DynamicPropertySource in Spring");
article.setContent("Today's applications...");

articleRepository.save(article);

Article persisted = articleRepository.findAll().get(0);
assertThat(persisted.getId()).isNotNull();
assertThat(persisted.getTitle()).isEqualTo("A Guide to @DynamicPropertySource in Spring");
assertThat(persisted.getContent()).isEqualTo("Today's applications...");
}

4. @DynamicPropertySource

В Spring Framework 5.2.5 появилась аннотация @DynamicPropertySource для облегчения добавления свойств с динамическими значениями . Все, что нам нужно сделать, это создать статический метод, аннотированный @DynamicPropertySource и имеющий только один экземпляр DynamicPropertyRegistry в качестве входных данных:

@SpringBootTest
@Testcontainers
public class ArticleLiveTest {

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:11")
.withDatabaseName("prop")
.withUsername("postgres")
.withPassword("pass")
.withExposedPorts(5432);

@DynamicPropertySource
static void registerPgProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url",
() -> String.format("jdbc:postgresql://localhost:%d/prop", postgres.getFirstMappedPort()));
registry.add("spring.datasource.username", () -> "postgres");
registry.add("spring.datasource.password", () -> "pass");
}

// tests are same as before
}

Как показано выше, мы используем метод add(String, Supplier<Object>) для данного DynamicPropertyRegistry , чтобы добавить некоторые свойства в Spring Environment . Этот подход намного чище по сравнению с инициализатором, который мы видели ранее. Обратите внимание, что методы, аннотированные с помощью @DynamicPropertySource , должны быть объявлены как статические и должны принимать только один аргумент типа DynamicPropertyRegistry .

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

5. Альтернатива: тестовые приборы

Пока что в обоих подходах настройка фикстуры и тестовый код тесно переплетены . Иногда эта тесная связь двух проблем усложняет тестовый код, особенно когда нам нужно настроить несколько вещей. Представьте, как выглядела бы настройка инфраструктуры, если бы мы использовали PostgreSQL и Apache Kafka в одном тесте.

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

Чтобы избежать этих недостатков, мы можем использовать средства тестовых приспособлений, которые предоставляет большинство сред тестирования . Например, в JUnit 5 мы можем определить расширение , которое запускает экземпляр PostgreSQL перед всеми тестами в нашем тестовом классе, настраивает Spring Boot и останавливает экземпляр PostgreSQL после выполнения тестов:

public class PostgreSQLExtension implements BeforeAllCallback, AfterAllCallback {

private PostgreSQLContainer<?> postgres;

@Override
public void beforeAll(ExtensionContext context) {
postgres = new PostgreSQLContainer<>("postgres:11")
.withDatabaseName("prop")
.withUsername("postgres")
.withPassword("pass")
.withExposedPorts(5432);

postgres.start();
String jdbcUrl = String.format("jdbc:postgresql://localhost:%d/prop", postgres.getFirstMappedPort());
System.setProperty("spring.datasource.url", jdbcUrl);
System.setProperty("spring.datasource.username", "postgres");
System.setProperty("spring.datasource.password", "pass");
}

@Override
public void afterAll(ExtensionContext context) {
// do nothing, Testcontainers handles container shutdown
}
}

Здесь мы реализуем AfterAllCallback и BeforeAllCallback для создания расширения JUnit 5. Таким образом, JUnit 5 выполнит логику beforeAll() перед выполнением всех тестов и логику метода afterAll() после выполнения тестов. При таком подходе наш тестовый код будет таким же чистым, как:

@SpringBootTest
@ExtendWith(PostgreSQLExtension.class)
@DirtiesContext
public class ArticleTestFixtureLiveTest {
// just the test code
}

Здесь мы также добавили аннотацию @DirtiesContext к тестовому классу. Важно отметить, что это воссоздает контекст приложения и позволяет нашим тестовым классам взаимодействовать с отдельным экземпляром PostgreSQL, работающим на произвольном порту . В результате наши тесты выполняются полностью изолированно друг от друга, в отношении отдельного экземпляра базы данных.

Помимо большей читаемости, мы можем легко повторно использовать ту же функциональность, просто добавив аннотацию @ExtendWith(PostgreSQLExtension.class) . Нет необходимости копировать и вставлять всю настройку PostgreSQL везде, где она нам нужна, как мы делали в двух других подходах.

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

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

Как обычно, все примеры доступны на GitHub .