JavaRush /Курси /Spring Test /@DynamicPropertySource

@DynamicPropertySource у тестах

Spring Test
Рівень 7, Лекція 2
Відкрита

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 властивостей однаковим і не вигадувати ключі на ходу.

1
Задача
Spring Test, 7 рівень, 2 лекція
Недоступна
Динамічний `storage.root` через `@DynamicPropertySource`
Динамічний `storage.root` через `@DynamicPropertySource`
1
Задача
Spring Test, 7 рівень, 2 лекція
Недоступна
Динамічний URL модерації через `@DynamicPropertySource`
Динамічний URL модерації через `@DynamicPropertySource`
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ