1. Вступ
Якщо ви звикли думати: «Ну, властивості — це ж просто рядки, що тут може бути складного», — ласкаво просимо у світ тестів, де рядок раптом перетворюється на пригоду. До цього моменту ми працювали з конфігурацією як із чимось статичним: YAML-файл, кілька ключів — і працюємо далі. Але іноді значення зʼявляється лише в момент запуску тесту — і це нормально, це не помилка дизайну.
Уявіть дві типові ситуації в ContentHub (і взагалі в будь-якому бекенд-проєкті). Перша: файловому сховищу для вкладень потрібен шлях contenthub.storage.root, і ви хочете, щоб він був унікальним для конкретного тестового прогону, щоб тести не заважали одне одному й не змагалися за ті самі файли. Друга: ви піднімаєте локальний стаб-сервер модерації, який слухає на випадковому вільному порту (бо фіксований порт завжди зайнятий… особливо коли ви поспішаєте). В обох випадках ви не можете заздалегідь вписати точне значення в YAML, тому що воно стане відомим лише «прямо зараз».
І ось тут виникає питання: як дати Springʼу властивість, яка обчислюється на льоту, але при цьому залишається звичайною властивістю за звичайним ключем, наприклад contenthub.moderation.base-url? Відповідь — @DynamicPropertySource.
2. Що таке @DynamicPropertySource
Важливо не сприймати @DynamicPropertySource як «ще один спосіб записати властивості, бо ми любимо анотації». Це конкретний механізм із Spring Test, який дає змогу програмно додати або перевизначити значення властивостей у Environment тестового ApplicationContext до того, як контекст повністю підніметься і почне створювати біни.
Якщо сказати простими словами, Spring у тестах працює так: «Спочатку зберу всі джерела конфігурації, складу підсумковий набір значень, і лише потім почну створювати біни». @DynamicPropertySource якраз вбудовується в цю фазу збирання підсумкового набору значень. Тому біни, які читають @Value("${contenthub.storage.root}") або @Value("${contenthub.moderation.base-url}"), побачать уже готове динамічне значення, ніби воно було в YAML.
Щоб закріпити момент у часі, зручно уявити це як маленький конвеєр:
flowchart TD
A[JUnit 6 запускає тест] --> B[Spring TestContext готує Environment]
B --> C[Завантажує application.yml + application-test.yml]
C --> D[Застосовує локальні перевизначення: властивості / @TestPropertySource]
D --> E["@DynamicPropertySource додає значення під час виконання"]
E --> F[Оновлює ApplicationContext і створює біни]
F --> G[Тест виконується]
Ключовий висновок: @DynamicPropertySource працює не «після того, як усе піднялося», а в момент, коли Spring ще збирає конфігурацію. Це робить його потужним інструментом, але водночас вимагає дисципліни: ви впливаєте на основу, на якій стоятиме весь тестовий контекст.
3. Синтаксис і вимога static
@DynamicPropertySource виглядає так, ніби ви «просто написали метод», але під капотом Spring викликає його досить рано. Через це метод має бути static: у момент, коли Spring збирає контекст, обʼєкта вашого тестового класу може ще не існувати (а іноді його створюють пізніше, коли контекст уже піднято і закешовано). Тому правило просте: побачили @DynamicPropertySource — прийняли static і рухаємося далі.
Усередині методу ви отримуєте DynamicPropertyRegistry. Це маленький реєстр, у який ви додаєте пари key -> supplier. Чому supplier, а не одразу значення? Бо значення можна обчислити пізніше, а воно має залишатися стабільним.
Мінімальний шаблон виглядає так:
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
class DemoTest {
@DynamicPropertySource
static void registerProps(DynamicPropertyRegistry registry) {
// Реєструємо властивість до підняття контексту:
// так біни побачать значення так, ніби воно лежить у application-test.yml.
registry.add("contenthub.storage.root", () -> "build/test-storage");
}
}
Цей приклад навмисно «не динамічний» — він просто показує форму. Насправді ви додаєте не константу, а значення, яке обчислюється. Але форма завжди одна: ключ властивості та Supplier, який повертає значення (зазвичай рядок). І так: використовуйте ті самі ключі, які застосунок використовує у звичайній конфігурації. @DynamicPropertySource — це не окрема конфігураційна реальність, а спосіб подати значення в ту саму систему.
4. Приклад: унікальний contenthub.storage.root
Коли ви пишете тести навколо логіки вкладень, поруч майже завжди зʼявляється файлова система. А файлова система — штука чесна: якщо два тести пишуть у той самий каталог, вони заважатимуть одне одному без жодної філософії. Іноді це закінчується «дивними» падіннями, які зникають під час повторного запуску. А інколи — тим, що ви випадково дивитеся в build/test-storage і знаходите там артефакти тестів тижневої давнини (це як археологія, тільки без грантів).
Один із простих прийомів — давати кожному тестовому класу свій унікальний каталог зберігання. Тоді тести з різних класів не перетинаються, а заодно простіше зрозуміти, хто що створив. І саме тут @DynamicPropertySource підходить ідеально: ми обчислимо шлях, зареєструємо його як значення contenthub.storage.root, а далі застосунок працюватиме з цим ключем як завжди.
Нижче — повний мінімальний приклад: в одному класі ми і реєструємо властивість, і піднімаємо контекст, і перевіряємо підсумкове значення.
import java.nio.file.Path;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@ActiveProfiles("test")
class DynamicStorageRootSpringBootTest {
// Значення обчислюємо один раз, щоб воно було стабільним для цього тестового класу.
static final Path STORAGE_ROOT =
Path.of("build", "tmp", "contenthub-" + UUID.randomUUID());
@DynamicPropertySource
static void register(DynamicPropertyRegistry registry) {
// Реєструємо значення під час виконання, до підняття контексту.
registry.add("contenthub.storage.root", () -> STORAGE_ROOT.toString());
}
@Value("${contenthub.storage.root}")
String root;
@Test
void readsDynamicStorageRoot() {
// Для Spring це вже звичайна властивість із Environment.
assertThat(root).isEqualTo(STORAGE_ROOT.toString());
}
}
Тут є кілька важливих «чому так». Ми тримаємо шлях у static final, щоб він був стабільним для цього тестового класу. Ми не обчислюємо UUID щоразу в supplier, інакше Spring може отримувати різні значення під час різних звернень. І ми використовуємо build/tmp/..., бо це «сміттєва» зона збирання, яку зазвичай не шкода очищати, і яка не виглядає як «ой, я випадково видалив домашню теку».
Зверніть увагу на форму перевірки: для @Value("${contenthub.storage.root}") це вже звичайна властивість із Environment. Біну байдуже, звідки вона прийшла — із YAML чи через @DynamicPropertySource. У цьому й сенс механізму: динамічне значення підставляється в ту саму конфігураційну систему, а не живе окремим прихованим життям.
І ще один практичний нюанс: якщо ваш застосунок у режимі, наближеному до продуктивного, очікує, що каталог уже існує, ви можете створити його один раз у статичному блоці або в окремому допоміжному класі. Але частіше сервіси зберігання самі вміють створити каталог під час першого використання, і це добре — менше ручної магії в тесті.
5. Приклад: динамічний base-url для стабу
З URL зовнішніх сервісів у тестах є неприємна традиція: фіксований порт майже завжди кимось зайнятий. Учора це був Docker, сьогодні — інший тест, завтра — ваш же браузер із якимось розширенням, яке вирішило стати сервером (так, так теж буває). Тому хороший підхід у тестах — піднімати стаб-сервер на порту 0, тобто «дай мені будь-який вільний», а потім дізнатися, який порт справді виділився.
Проблема в тому, що contenthub.moderation.base-url залежить від цього порту. Отже, значення неможливо заздалегідь записати в application-test.yml або в атрибут properties. Воно стає відомим лише після запуску стаб-сервера. І ось тут @DynamicPropertySource знову виявляється саме тим інструментом.
Покажемо це на максимально приземленому прикладі з JDK HttpServer. Нам зараз потрібен лише один факт: сервер запустився, і ми знаємо його реальний порт.
import java.net.InetSocketAddress;
import com.sun.net.httpserver.HttpServer;
class ModerationStub {
static HttpServer start() {
try {
// Порт 0 означає "дай будь-який вільний порт".
var server = HttpServer.create(new InetSocketAddress(0), 0);
// У реальному стаб-сервері тут зазвичай додають обробники (createContext),
// але для прикладу нам важливий лише факт запуску та доступний порт.
server.start();
return server;
} catch (Exception e) {
// У тестах часто простіше одразу впасти, ніж тягнути перевірювані винятки.
throw new RuntimeException(e);
}
}
}
Тут нам потрібна лише проста звʼязка: запускаємо сервер як static final, беремо порт і привʼязуємо його до contenthub.moderation.base-url. Сам тест навколо нього буде влаштований так само, як у прикладі зі storage.root.
import com.sun.net.httpserver.HttpServer;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
class DynamicModerationUrlTest {
// Сервер має запуститися до підняття Spring-контексту,
// інакше ми не дізнаємося порт вчасно.
static final HttpServer server = ModerationStub.start();
// Порт відомий лише після запуску.
static final int port = server.getAddress().getPort();
@DynamicPropertySource
static void register(DynamicPropertyRegistry registry) {
// Ключ має збігатися з тим, що реально читає застосунок.
registry.add("contenthub.moderation.base-url", () -> "http://localhost:" + port);
}
}
Тут важливий сенс, а не «ідеальна інженерія». Нам потрібно встигнути дізнатися порт до підняття контексту і зареєструвати його як звичайну властивість. Обробники createContext(...) і самі перевірки вже залежать від конкретного інтеграційного сценарію. У реальному тесті ви додасте ще й зупинку сервера, наприклад у @AfterAll, але налаштування навколо порту залишається саме таким.
Окремо проговорімо тонкість, яка часто ламає очікування: не намагайтеся запускати такий сервер у @BeforeAll, а потім окремо «реєструвати властивості». Контекст Spring найчастіше створюється раніше, ніж виконується ваш @BeforeAll. Тому правильніше запускати стаб так, щоб порт був відомий до створення контексту: через static-ініціалізацію або через допоміжний метод, який викликається під час завантаження класу.
6. Коли застосовувати @DynamicPropertySource
Дуже легко захопитися @DynamicPropertySource і почати динамічно конфігурувати все. Це трохи схоже на те, як відкрити для себе var у Java і почати ставити його всюди, навіть там, де читач уже губиться. Технічно можна, але читати такий код важче.
Добре правило просте: динамічні властивості потрібні лише тоді, коли значення справді визначається під час запуску і не може бути чесно записане заздалегідь. Якщо значення стабільне і однакове для більшості тестів, воно має жити в application-test.yml. Якщо значення потрібно перевизначити лише в одному-двох тестах, значно простіше й прозоріше написати @SpringBootTest(properties = "...") або @TestPropertySource(properties = { "..." }). Динаміка — це крайній випадок, а не основний стиль.
Щоб закріпити це як інженерне рішення, а не як «відчуття», ось таблиця, яка допомагає обрати інструмент:
| Ситуація в тесті | Що обрати | Чому це розумно |
|---|---|---|
| Значення однакове майже для всіх тестів | application-test.yml | Одна очевидна базова конфігурація, мінімум сюрпризів |
| Потрібно змінити 1–2 ключі лише в одному класі | @SpringBootTest(properties = ...) | Перевизначення видно поруч із тестом |
| Потрібен набір повʼязаних перевизначень для одного класу | @TestPropertySource(properties = {...}) | В одному місці, читабельно й без окремого файлу |
| Значення зʼявляється лише після обчислення/запуску ресурсу | @DynamicPropertySource | Інакше ви просто не знаєте значення заздалегідь |
Є ще один практичний аспект, який ми вже зачіпали, коли говорили про ціну Spring-тестів і кешування контексту. Динамічні властивості можуть множити контексти, якщо ви використовуєте їх бездумно. Наприклад, якщо ви генеруєте новий UUID для contenthub.storage.root у кожному тестовому класі, то кожен такий клас потенційно потребуватиме окремого ApplicationContext. Іноді це нормально (коли тестів мало і важлива ізоляція), але іноді це перетворюється на «чому тести стали виконуватися 4 хвилини замість 40 секунд».
Тому тримайте баланс. Якщо вам потрібна унікальність — нехай вона буде там, де справді захищає від ризику (файли, порти, тимчасові каталоги). Але не робіть динамічним те, що спокійно живе в YAML.
7. Типові помилки під час роботи з @DynamicPropertySource
Майже всі проблеми з @DynamicPropertySource зʼявляються не тому, що анотація «погана», а тому, що вона дає вам вплив на ранню фазу життя контексту, і цим впливом легко зловживати. Нижче — набір помилок, які найчастіше трапляються у новачків, і які варто навчитися уникати до того, як вони перетворяться на вашу особисту колекцію дивних падінь у CI.
Помилка №1: використовувати @DynamicPropertySource для константних значень.
Якщо значення відоме заздалегідь і стабільне, динамічний механізм робить тест менш читабельним. У результаті наступна людина (і ви через тиждень) шукатиме «а де ж це задано?», замість того щоб просто відкрити application-test.yml. Динаміка має бути виправдана тим, що значення справді зʼявляється під час запуску.
Помилка №2: метод не static (або неправильна сигнатура).
@DynamicPropertySource очікує static-метод, який приймає DynamicPropertyRegistry. Якщо зробити його нестатичним, Spring просто не зможе коректно викликати реєстрацію в потрібний момент. Помилка виглядатиме як «чому мої властивості не застосувалися?», і ви витратите час на читання логів замість того, щоб одразу побачити причину.
Помилка №3: «випадковість усередині supplier» — різні значення під час кожного читання.
Дуже спокусливо написати registry.add("key", () -> UUID.randomUUID()). І це майже гарантовано призведе до хаосу, бо одна й та сама властивість може читатися кілька разів на різних стадіях. Властивість має бути стабільною: обчисліть значення один раз у static final, а в supplier просто повертайте його.
Помилка №4: важка логіка та побічні ефекти прямо всередині методу реєстрації.
Реєстрація властивостей має бути максимально короткою: додали ключі, віддали supplier-и, пішли. Якщо ви почнете всередині методу піднімати сервер, писати файли, ходити в мережу або робити будь-яку іншу важку роботу, ви отримаєте тести, які складно дебажити і які ламаються на рівному місці. Якщо ресурс потрібно запустити заздалегідь, робіть це в окремому static-ініціалізаторі або в невеликому допоміжному класі, а в методі реєстрації лише привʼяжіть значення до ключів.
Помилка №5: зареєструвати не той ключ (який застосунок не читає).
Іноді автор тесту реєструє щось на кшталт contenthub.moderation.url, а в застосунку насправді використовується contenthub.moderation.base-url. У результаті тест «ніби налаштований», але застосунок продовжує жити із значенням за замовчуванням із YAML. Це особливо підступно, бо тест може навіть проходити — просто не тим шляхом, який ви думали. Найкращий засіб — тримати naming властивостей однаковим і не вигадувати ключі на ходу.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ