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:
- Настройте компонент, такой как PostgreSQL, перед всеми тестами. Обычно эти компоненты прослушивают случайные порты.
- Запустите тесты.
- Разберите компонент.
Если наш контейнер 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 .