1. Роль @DynamicPropertySource і @ServiceConnection
Якщо ви щойно закохалися в @ServiceConnection усім серцем — я вас розумію: менше коду, менше помилок, менше шансів випадково забути пароль. Але в тестах є одна особливість: частина конфігурації стає відомою лише під час запуску. Контейнер підіймає PostgreSQL на випадковому порту, генерує параметри з’єднання, і ці значення треба «підставити» у Spring ще до того, як він створить datasource.
Тобто питання тепер не в тому, чи потрібен контейнер узагалі. Для звичайного PostgreSQL datasource @ServiceConnection залишається першим вибором. @DynamicPropertySource потрібен тоді, коли автоматичного зв’язування вже недостатньо і значення під час виконання доводиться вручну покласти в Environment.
В ідеальному світі Spring Boot сам усе підхоплює. І в типовому PostgreSQL‑кейсі він справді робить це через service connection. Але бувають ситуації, коли автоматичне зв’язування не спрацьовує або не підходить: вам потрібні нестандартні ключі властивостей, ви хочете пов’язати контейнер не з datasource, а з іншим набором налаштувань, або вам треба вручну покласти кілька додаткових динамічних значень у Environment. Тоді доводиться повертатися до універсальнішого механізму Spring Test — до @DynamicPropertySource.
Корисно думати про це так: @ServiceConnection — це «коротка дорога по трасі», а @DynamicPropertySource — «ґрунтовка», яка веде туди ж, але зате дозволяє об’їхати майже будь-який завал. Ґрунтовка довша й брудніша (код об’ємніший), зате ви керуєте всім самі.
2. Як працює @DynamicPropertySource
Коли чуєш «dynamic property source», може здатися, що зараз буде магія, ритуали й виклик духів Spring. Насправді ідея дуже приземлена: Spring Test дає вам точку, де ви можете програмно додати властивості в Spring Environment безпосередньо перед побудовою ApplicationContext. Тобто не через application-test.yml, не через properties = ... в анотації, а через код.
Технічно це виглядає так. У тестовому класі ви оголошуєте static метод і позначаєте його @DynamicPropertySource. Spring знайде цей метод під час підготовки тестового контексту й викличе його, передавши всередину об’єкт DynamicPropertyRegistry. А ви в цьому registry додасте пари «ключ → постачальник значення». І ось тут важливий нюанс: вам не обов’язково віддавати значення одразу. Нам значно зручніше передавати постачальник (Supplier), тому що контейнер може стартувати трохи пізніше, і тоді postgres::getJdbcUrl буде обчислено в потрібний момент.
Нижче — схема (трохи спрощена, зате чесна за змістом), як це відбувається, коли ми підключаємо Testcontainers через @DynamicPropertySource:
flowchart TD
A["JUnit 6 починає виконувати тестовий клас"] --> B["Testcontainers готує контейнер (PostgreSQLContainer)"]
B --> C["Spring Test готує ApplicationContext"]
C --> D["@DynamicPropertySource реєструє динамічні властивості у DynamicPropertyRegistry"]
D --> E["Spring зв’язує властивості (Environment)"]
E --> F["Створюється інфраструктура DataSource / Flyway / JPA"]
F --> G["Тести запускаються й працюють із реальним PostgreSQL"]
Тепер про вимоги, які найчастіше «кусають» початківців:
Метод із @DynamicPropertySource має бути static. Це не примха, а прямий наслідок того, що властивості потрібно зареєструвати до створення екземпляра тестового класу. Якщо метод буде не static, Spring просто не зможе безпечно викликати його на етапі bootstrap, і ви отримаєте помилку на кшталт «method must be static».
DynamicPropertyRegistry — це не «карта налаштувань застосунку», а саме реєстр динамічних значень. Найкраще додавати туди лише те, що справді залежить від часу виконання: URL, порт, логін/пароль, якісь тимчасові адреси. Якщо ви починаєте складати туди все підряд, включно з константами, ви дуже швидко перетворюєте @DynamicPropertySource на смітник (і потім дивуєтеся, чому ніхто не хоче чіпати ваш тестовий код без каски).
3. Приклад @DataJpaTest з Postgres
Зараз ми зберемо мінімальний, зручний для читання приклад для ContentHub. Нам потрібен @DataJpaTest, який працює з PostgreSQL у контейнері. Головне тут — не сам тест, а зв’язування: контейнер має піднятися, а Spring має отримати коректні spring.datasource.* властивості.
Щоб не будувати окремий паралельний світ, візьмімо той самий ArticleRepositoryPostgresContainerTest, що й у варіанті пріоритетного шляху. Змінюється лише те, як у контекст потрапляють spring.datasource.* властивості.
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers // Увімкнення інтеграції Testcontainers із тестовим раннером
@DataJpaTest // Data-slice тест: підіймаємо лише JPA-частину контексту
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // Не підміняємо DataSource на вбудовану БД
class ArticleRepositoryPostgresContainerTest {
@Container // Контейнер керується життєвим циклом Testcontainers
static PostgreSQLContainer
postgres =
new PostgreSQLContainer<>("postgres:16-alpine"); // Образ PostgreSQL для тестів
@DynamicPropertySource
static void postgresProperties(DynamicPropertyRegistry registry) {
// Важливо: передаємо Supplier, щоб значення обчислювалося тоді, коли контейнер уже готовий
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private DataSource dataSource;
@Test // JUnit 6
void connectsToPostgres() throws Exception {
// Перевіряємо саме реальне підключення: якщо тут падає, отже зв’язування не відбулося
try (var c = dataSource.getConnection()) {
assertThat(c.isValid(1)).isTrue(); // 1 секунда тайм-ауту на перевірку
}
}
}
Зверніть увагу на дві речі. По-перше, replace = NONE залишився на місці: @DynamicPropertySource не скасовує правила data-slice, він просто замінює автоматичний місток на ручну реєстрацію властивостей. По-друге, це той самий формат тесту, що й із @ServiceConnection: контейнер, як і раніше, static, сам клас, як і раніше, @DataJpaTest, змінюється тільки зв’язування datasource.
Якщо хочете трохи більше спокою щодо життєвого циклу, тримайте просте правило: не обчислюйте значення зарано. postgres::getJdbcUrl безпечніше, ніж спроба заздалегідь дістати рядок і зберегти його «десь поруч».
Додаткові динамічні властивості
На перших кроках здається, що datasource — це все. Але реальний проєкт любить додавати «другі осі». Наприклад, у вас увімкнено Flyway, і він може використовувати окремі налаштування, відмінні від datasource. Або ви хочете, щоб логування SQL вмикалося лише у контейнерному піднаборі. Або вам треба підкласти динамічний порт у клієнт інтеграції — і все це зручно робити тим самим механізмом.
Нижче наведено не новий клас, а варіант заміни для методу postgresProperties всередині того самого ArticleRepositoryPostgresContainerTest. У більшості проєктів Flyway і так візьме налаштування з datasource, тож це не шаблон, який працює завжди. Але він добре показує ідею: DynamicPropertyRegistry може наповнювати будь-які ключі, не тільки spring.datasource.*.
// Варіант заміни для методу postgresProperties всередині того самого ArticleRepositoryPostgresContainerTest
@DynamicPropertySource
static void postgresAndFlyway(DynamicPropertyRegistry registry) {
// Базові налаштування DataSource для Spring Boot
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
// Явно прокидаємо ті самі параметри у Flyway (іноді це зручно для нестандартних сценаріїв)
registry.add("spring.flyway.url", postgres::getJdbcUrl);
registry.add("spring.flyway.user", postgres::getUsername);
registry.add("spring.flyway.password", postgres::getPassword);
}
Тут важливо не піти в крайність: «раз уже є DynamicPropertyRegistry, покладу туди половину application.yml». Краще тримати просте правило: dynamic означає те, що залежить від часу виконання. Якщо значення статичне, його простіше й чесніше задати через application-test.yml або через properties = ... в анотації тесту. Так ви збережете читабельність і не зробите з dynamic properties «таємне місце, де живуть усі налаштування».
4. @ServiceConnection vs @DynamicPropertySource
Хочеться, звісно, оголосити @DynamicPropertySource «застарілим», тому що є @ServiceConnection. Але це було б неправдою: у них різні цілі. @ServiceConnection дає коротший шлях, але найкраще працює у стандартних сценаріях і для підтримуваних контейнерів чи типів зв’язування. @DynamicPropertySource — універсальніша викрутка: може бути не така гарна, зате підходить майже завжди.
@ServiceConnection залишається першим вибором для звичайного PostgreSQL datasource. @DynamicPropertySource потрібен саме тоді, коли стандартного містка вже бракує.
Порівняймо їх у вигляді таблиці — так простіше запам’ятати, ніж після десяти абзаців «загалом воно ось таке»:
| Критерій | @ServiceConnection | @DynamicPropertySource |
|---|---|---|
| Розмір коду | Зазвичай мінімальний | Зазвичай більше boilerplate |
| Читабельність | Висока: «контейнер → service connection» | Середня: потрібно розуміти property keys |
| Гнучкість | Добра, поки ви в «стандарті» | Дуже висока: можна реєструвати будь-які властивості |
| Нестандартні keys | Незручно/неможливо без додаткових містків | Саме для цього й зроблено |
| Ризик копіпасти | Низький | Вищий (якщо повторювати registry.add у кожному тесті) |
| Основний статус у курсі | Пріоритетний шлях | Резервний шлях |
Якщо ви працюєте з ContentHub і вам потрібен звичайний PostgreSQL datasource у тесті, ваш перший вибір — @ServiceConnection. Але якщо ви раптом з’ясували, що частина конфігурації застосунку «підчіпляється» за власними custom keys (або Boot не може сам пов’язати ваш контейнер із потрібною підсистемою), @DynamicPropertySource стає чесним і зрозумілим рішенням. Важливо лише пам’ятати: резервний інструмент не означає «поганий». Він означає «більш ручний», а отже вимагає більшої дисципліни.
5. Як уникнути копіпасти
У @DynamicPropertySource є один побічний ефект: він провокує копіпасту. Перший тестовий клас — окей. Другий — уже хочеться «швидко скопіювати». Третій — і ви раптом розумієте, що у вашому проєкті з’явився новий вид технічного боргу: «динамічні властивості розмазано по тестах».
Найпростіші ліки — винести мапінг властивостей у маленький допоміжний клас. Важливо, щоб цей допоміжний клас не перетворився на величезний «тестовий фреймворк імені вас», який уміє все, крім пояснення, що відбувається.
Приклад мінімалістичного допоміжного класу: він не ховає контейнер і не керує його життєвим циклом, а лише допомагає однаково реєструвати ключі.
import org.springframework.test.context.DynamicPropertyRegistry;
import org.testcontainers.containers.PostgreSQLContainer;
final class PostgresDynamicProperties {
static void register(DynamicPropertyRegistry registry, PostgreSQLContainer
postgres) {
// Реєструємо лише те, що реально змінюється від запуску до запуску (значення під час виконання)
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
private PostgresDynamicProperties() {
// Утилітний клас: екземпляри не потрібні
}
}
А в тестовому класі залишається чесна «точка входу», де видно, що відбувається:
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
@DynamicPropertySource
static void postgresProps(DynamicPropertyRegistry registry) {
// У тесті видно: ми просто прокидаємо значення контейнера у стандартні ключі Spring Boot
PostgresDynamicProperties.register(registry, postgres);
}
Чому це хороший компроміс саме для нашого курсу? Тому що наша мета — не побудувати ультрауніверсальну інфраструктуру, а тримати контейнерний піднабір маленьким і зрозумілим. Ми хочемо прибрати повторювані рядки, але не хочемо, щоб студент читав тест і думав: «постривайте, а хто взагалі встановлює datasource? де? чому воно працює?». Допоміжний клас економить рядки й залишає зміст поруч із тестом.
6. Типові помилки з @DynamicPropertySource
Помилка № 1: метод із @DynamicPropertySource не static.
Це класика: студент пише гарний метод void postgresProperties(...), а Spring відповідає досить жорстко: «ні». Динамічні властивості потрібні до створення екземпляра тесту, тому метод зобов’язаний бути static. Це не «нюанс версії», а концептуальна вимога.
Помилка № 2: реєструють значення, а не Supplier, і випадково обчислюють їх занадто рано.
Іноді хочеться зробити «простiше»: викликати postgres.getJdbcUrl() і передати рядок. По-перше, API registry побудовано навколо Supplier. По-друге, навіть якщо ви обійдете це через лямбду, раннє обчислення значення може статися до старту контейнера й призвести до дуже дивних падінь. Найбезпечніший стиль — method references: postgres::getJdbcUrl, postgres::getUsername, postgres::getPassword.
Помилка № 3: одночасно використовують @ServiceConnection і @DynamicPropertySource для одного й того самого datasource.
Це той випадок, коли ви намагаєтеся вдягнути ремінь безпеки поверх уже вдягнутого ременя безпеки й випадково пристібаєте себе до дверей. Якщо @ServiceConnection уже на контейнері, не треба вручну реєструвати ті самі spring.datasource.* ключі — ви підвищуєте ризик конфлікту і робите тест менш передбачуваним. Обирайте один шлях для одного тестового класу.
Помилка № 4: складають у DynamicPropertyRegistry усе підряд, включно зі статичними налаштуваннями.
Коли registry перетворюється на «альтернативний application-test.yml», тест стає нечитабельним: важливі значення, що залежать від часу виконання, тонуть серед констант. Статичні речі (наприклад, logging.level.*) краще тримати в YAML або через локальні override-и в анотаціях. Dynamic registry — для справді динамічного.
Помилка № 5: забувають про @AutoConfigureTestDatabase(replace = NONE) у @DataJpaTest і потім дивуються.
Симптом зазвичай такий: контейнер жваво стартує, ви бачите логи PostgreSQL, а тести поводяться так, ніби бази там немає. Причина — Spring Boot підмінив datasource на embedded DB, бо ви не сказали йому «не треба допомагати». У контейнерному @DataJpaTest це майже обов’язковий рядок.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ