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

@ConfigurationProperties в тестах

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

1. Конфігурація — теж код

До цього моменту ми обговорювали, звідки взагалі береться властивість: з application-test.yml, локального перевизначення або реєстрації під час виконання. Але щойно ключів стає більше за кілька, питання змінюється. Важливо вже не лише «яке там значення», а й «у що воно звʼязується», «які в нього значення за замовчуванням» і «коли конфігурація має впасти відразу, а не давати тихий збій пізніше».

Коли ми говоримо «конфігурація застосунку», дуже легко уявити собі нудний YAML-файл, який живе десь поруч і «якось» впливає на поведінку. Але насправді конфігурація — це код, просто написаний не на Java. І в цього «коду» є неприємна особливість: компілятор його не перевіряє, IDE не підказує, а помилки в ключах поводяться як ніндзя — тихо, раптово й у найнеприємніший момент.

Класичний симптом: у вас є кілька налаштувань однієї підсистеми, наприклад moderation-клієнта. Ви починаєте з пари @Value, потім додаєте третю, потім — четверту. І ось уже в коді гуляють рядки "contenthub.moderation.base-url", "contenthub.moderation.timeout", "contenthub.moderation.retries", а поруч ще хтось випадково написав "contenthub.moderation.baseUrl" (без дефіса) і дивується, чому значення не підхоплюється. І найнеприємніше: тести теж починають «підкручувати» ці рядки, тож через кілька тижнів ви вже не впевнені, який ключ де задано і чому конкретний тест узагалі проходить.

Щоб повернути собі контроль, ми хочемо досягти трьох речей. По-перше, щоб налаштування однієї підсистеми були зібрані разом, а не розкидані по різних класах. По-друге, щоб значення були типізовані, бо таймаут — це не рядок, а Duration, а ліміт розміру файлу — це не «5MB», а DataSize. По-третє, щоб ми могли протестувати не «рядок за ключем», а цілий об’єкт конфігурації — із дефолтами й валідацією.

2. @ConfigurationProperties як контракт

@ConfigurationProperties — це механізм Spring Boot, який бере набір конфігураційних ключів зі спільним префіксом і звʼязує їх із Java-класом. Тобто замість того, щоб читати кожну властивість через @Value, ми описуємо структуру конфігурації один раз і далі використовуємо її як звичайний об’єкт. Виходить майже як DTO, тільки не для HTTP-запиту, а для налаштувань застосунку.

У голові це зручно тримати так: YAML — це зовнішній контракт, як API, тільки для конфігурації. @ConfigurationProperties — це Java-модель цього контракту. А біндинг (binding) — це «парсер», який перетворює ключі й рядки з Environment у поля, дати, числа, Duration, DataSize тощо. Сенс у тому, що ваш бізнес-код та інфраструктурний код більше не мають займатися «розбором» конфігурації — вони отримують уже готовий типізований об’єкт.

Дуже корисно уявляти цей шлях так:

flowchart TD
    A["application.yml / application-test.yml"] --> B["Джерела властивостей середовища Environment"]
    B --> C["Біндер (Spring Boot)"]
    C --> D["@ConfigurationProperties bean AttachmentProperties"]
    D --> E["Сервіс / адаптер AttachmentValidationService"]

Ключова перевага для тестування в тому, що ви можете перевіряти шар D окремо: «чи правильно біндиться», «які значення за замовчуванням застосовуються», «чи валідується конфігурація» — не змішуючи це з HTTP, БД та рештою застосунку. У цьому місці конфігурація перестає бути «магією з YAML» і стає об’єктом, який можна потримати в руках.

3. Naming і relaxed binding

Після першої зустрічі з @ConfigurationProperties майже кожен робить одне й те саме: дивиться на YAML-ключ max-count, потім на поле maxCount — і думає: «А це точно те саме, чи я зараз випадково займаюся шаманством?». Добра новина: це не шаманство, це так і задумано.

Spring Boot підтримує relaxed binding, тобто вміє зіставляти різні варіанти написання імен. У конфігурації частіше використовують kebab-case, бо він читабельніший у YAML: max-size, base-url, enabled. У Java, природно, — camelCase: maxSize, baseUrl, enabled. Boot уміє звʼязувати ці варіанти між собою.

Ось мінітаблиця, щоб мозок перестав нервувати:

YAML ключ Поле в Java Чому так
contenthub.attachments.max-count maxCount дефіс у YAML ↔ camelCase у Java
contenthub.moderation.base-url baseUrl «url» залишається частиною імені
contenthub.notifications.enabled enabled збігається за змістом і формою

При цьому важливо не зловживати «свободою» relaxed binding. Можна написати і MAX_COUNT, і max_count, і max-count, і воно може спрацювати — але читачеві (і вашому майбутньому «я» через місяць) буде боляче. Тому в межах курсу тримаємо дисципліну: у YAML — kebab-case, у Java — camelCase.

4. Приклад ContentHub: групи налаштувань

Коли ви проєктуєте конфігурацію, думайте не «які властивості мені потрібні», а «які підсистеми в мене є». У ContentHub це особливо видно, бо проєкт спеціально зроблено близьким до production-середовища: є файлове сховище, ліміти вкладень, moderation-клієнт, сповіщення про публікацію. У кожної підсистеми є свої налаштування, і логічно тримати їх у своїх префіксах.

Нехай у конфігурації будуть такі групи:

- contenthub.storage.* — усе, що пов’язано з кореневим каталогом зберігання файлів.

- contenthub.attachments.* — ліміти розміру, кількості й типів вкладень.

- contenthub.moderation.* — адреса moderation service і таймаути.

- contenthub.notifications.* — чи ввімкнені сповіщення і як вони працюють.

Для attachments тут якраз корисно зробити паузу. Базові ліміти розміру й кількості можна тримати прямо в AttachmentProperties, а YAML використовувати лише тоді, коли їх справді потрібно перевизначити під конкретне середовище. Тому такий спільний тестовий знімок конфігурації спокійно обмежується storage, moderation і notifications:

# src/test/resources/application-test.yml

contenthub:
  storage:
    # У тестах зберігаємо файли в build/, щоб не чіпати «бойові» каталоги
    root: "build/test-storage"
  moderation:
    # У тестовому середовищі часто дивимося на локальний мок/стаб
    base-url: "http://localhost:8089"
    # Duration: одиниці часу теж читаються «по-людськи»
    timeout: 250ms
  notifications:
    # Для більшості тестів сповіщення простіше вимкнути
    enabled: false

Зверніть увагу на «людську» читабельність. Навіть без знання Java-коду видно: у тестах ми пишемо файли в build/test-storage, moderation «дивиться» на локальний URL, сповіщення вимкнені. Це важливий психологічний ефект: тестова конфігурація читається як історія, а не як набір випадкових чисел.

З attachments вийде інша корисна історія: якщо цих ключів немає у зовнішній конфігурації, ліміти приїдуть із полів AttachmentProperties, і це вже можна перевірити окремим тестом.

5. AttachmentProperties: значення за замовчуванням

@ConfigurationProperties зазвичай роблять максимально нудним класом. І це комплімент. Тут не має бути бізнес-логіки, обчислень і «розумних» методів. Цей клас — контейнер для налаштувань. Чим простіше, тим краще: він має бути зрозумілим навіть у понеділок зранку, коли кава ще не допомогла.

Почнемо з лімітів вкладень. Нам важливо, щоб типи були правильні: розмір — це DataSize, кількість — це int. І щоб у коді був безпечний базовий рівень: якщо налаштування не вказали, ми все одно не хочемо повної відсутності ліміту.

import jakarta.validation.constraints.Min;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.unit.DataSize;
import org.springframework.validation.annotation.Validated;

@ConfigurationProperties("contenthub.attachments")
@Validated // Під час біндингу вмикаємо валідацію Jakarta Bean Validation
public class AttachmentProperties {

    // За замовчуванням: безпечний ліміт на розмір, якщо в YAML його не задали
    private DataSize maxSize = DataSize.ofMegabytes(5);

    // За замовчуванням: безпечний ліміт на кількість
    @Min(1) // Конфігурація — це теж вхідні дані: забороняємо 0 і відʼємні значення
    private int maxCount = 3;

    // getters/setters
}

Тут є дві важливі ідеї. Перша — значення за замовчуванням прямо в полях. Це зручно: у класу є «базова комплектація», і застосунок не розвалюється, якщо ви забули ключ у YAML. Друга — валідація через @Min(1) і @Validated. Бо конфігурація — це теж вхідні дані, тільки не від користувача, а від оточення. І якщо оточення прислало сміття, ми хочемо дізнатися про це відразу, а не після трьох годин відлагодження.

І ще один практичний момент, який новачків часто ловить на рівному місці: @ConfigurationProperties-клас має стати Spring bean-ом. Тобто його потрібно зареєструвати. Найпростіший і «курсовий» шлях — увімкнути сканування properties у застосунку:

import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;

@SpringBootApplication
@ConfigurationPropertiesScan // Кажемо Spring Boot шукати й реєструвати класи @ConfigurationProperties
public class ContentHubApplication {
}

Якщо @ConfigurationPropertiesScan забути, ви отримаєте дуже чесну помилку на кшталт “No qualifying bean of type AttachmentProperties”. Це не Spring «капризує» — це він намагається вам сказати: «Я не знаю, що це за клас, я його не створював».

6. Duration і DataSize

Щойно ви починаєте зберігати в конфігурації таймаути й розміри, з’являється спокуса зробити їх рядками: "250ms", "5MB", "2s". І це… працює, але рівно до моменту, коли десь у коді починається ручний парсинг. А ручний парсинг у 2026 році — це приблизно як писати свій ArrayList: можна, але без причини краще не треба.

Spring Boot уміє конвертувати значення з properties у Duration і DataSize. Це означає дві приємні речі. Перша: ваш код стає типобезпечним, бо ви не можете випадково скласти «5MB» і «250ms». Друга: YAML стає читабельним, бо ви пишете одиниці прямо в конфігурації.

Приклад для moderation-налаштувань:

import jakarta.validation.constraints.NotBlank;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

@ConfigurationProperties("contenthub.moderation")
@Validated // Валідація під час старту контексту
public class ModerationProperties {

    // Базовий URL до moderation-сервісу. Порожнє значення вважаємо помилкою конфігурації.
    @NotBlank
    private String baseUrl = "http://localhost:8089";

    // Таймаут запитів до moderation-сервісу
    private Duration timeout = Duration.ofSeconds(2);

    // getters/setters
}

І відповідна YAML-частина (наприклад, у тестах):

contenthub:
  moderation:
    base-url: "http://localhost:8089"
    timeout: 250ms

Важливо, що значення на кшталт 250ms, 2s, 1m читаються майже як людська мова. І в тесті ви теж будете порівнювати не рядки, а нормальні об’єкти:

// У тестах порівнюємо типізоване значення, а не рядок із YAML
Duration timeout = properties.getTimeout();
assertThat(timeout).isEqualTo(Duration.ofMillis(250));

Є ще один бонус: коли у вас тип Duration, IDE підказує методи на кшталт toMillis(), а коли у вас рядок, IDE підказує лише… сум.

Валідація налаштувань: @Validated і «падаємо швидко»

Валідація конфігурації — це про психологічний комфорт. Ви хочете, щоб застосунок падав якомога ближче до причини проблеми. Не через 20 хвилин після старту, не посеред обробки запиту, не «десь усередині RestClient». А прямо на старті, з понятним повідомленням: «погана конфігурація».

Механіка проста: ви ставите @Validated на properties-клас і додаєте Jakarta Validation-анотації до полів. Тоді під час біндингу Spring Boot валідує об’єкт, і якщо обмеження порушені — контекст не стартує.

Наприклад, у attachments-налаштуваннях ми вже поставили @Min(1) на maxCount. У moderation-налаштуваннях ми поставили @NotBlank на baseUrl. Це означає, що такі значення вважаються недопустимими:

contenthub:
  moderation:
    base-url: ""   # порожній рядок -> впадемо на старті

Звучить «жорстко», але це правильна жорсткість. Зовнішня інтеграція без base URL — це не «може, потім розберемося», а «застосунок фізично не знає, куди йти». І краще дізнатися про це відразу.

Так само варто ставитися до лімітів. Конфігурація max-count: 0 — майже завжди помилка. Вона може бути «усвідомленою спробою вимкнути вкладення», але тоді краще зробити окремий прапорець enabled, а не намагатися виразити сенс через нелегальний ліміт.

7. Тест біндингу @ConfigurationProperties

Тести для @ConfigurationProperties приємно писати, бо ви перевіряєте рівно одну річ: «коли я задаю такі-то ключі, я отримую такий-то об’єкт». Це не тест бізнес-логіки, не тест HTTP, не тест репозиторію. Це тест конфігураційного контракту.

Найпростіший і зрозумілий для початківців спосіб — підняти контекст через @SpringBootTest, задати властивості через properties = { ... }, підхопити тестовий профіль через @ActiveProfiles("test"), а потім просто заінжектити properties-клас.

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.util.unit.DataSize;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest(properties = {
        // Перевизначаємо потрібні ключі прямо в анотації тесту
        "contenthub.attachments.max-size=10MB",
        "contenthub.attachments.max-count=5"
})
@ActiveProfiles("test")
class AttachmentPropertiesBindingTest {

    @Autowired
    AttachmentProperties properties; // Інжектимо вже збінджений типізований об’єкт

    @Test
    void bindsAttachmentLimits() {
        // Перевіряємо, що DataSize розпарсився коректно
        assertThat(properties.getMaxSize()).isEqualTo(DataSize.ofMegabytes(10));
        // Перевіряємо, що числовий ліміт теж застосувався
        assertThat(properties.getMaxCount()).isEqualTo(5);
    }
}

Зверніть увагу на кілька дисциплінарних моментів. Ми задаємо ключі в тому вигляді, у якому застосунок їх реально читає (contenthub.attachments.*). Ми взагалі не використовуємо @Value — тестуємо саме properties-клас як об’єкт. І ми порівнюємо типи, а не рядки: DataSize.ofMegabytes(10) замість "10MB".

Якщо хочеться перевірити саме значення полів за замовчуванням, тут важлива одна умова: contenthub.attachments.* не мають бути задані ні в application.yml, ні в application-test.yml, ні в локальних overrides. Тоді відсутність значень справді означає «беремо те, що задано в самому класі».

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.util.unit.DataSize;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest // Не задаємо overrides — дивимося, як працюють значення полів за замовчуванням без зовнішніх значень для цих ключів
class AttachmentPropertiesDefaultsTest {

    @Autowired
    AttachmentProperties properties;

    @Test
    void usesDefaultsWhenNoOverridesProvided() {
        // Значення за замовчуванням захищають від повної відсутності ліміту, якщо ключі забули в YAML
        assertThat(properties.getMaxSize()).isEqualTo(DataSize.ofMegabytes(5));
        assertThat(properties.getMaxCount()).isEqualTo(3);
    }
}

Якщо зовнішній YAML усе-таки задає ті самі ключі, такий тест уже перевіряє не значення за замовчуванням класу, а порядок property sources — і це інша задача. І так, цей тест залежить від того, що ви справді зареєстрували AttachmentProperties як bean і що значення за замовчуванням задані в коді. Але це якраз те, що ми й хочемо захищати: «у класі є безпечна базова конфігурація».

8. Негативний сценарій: невалідний конфіг

Негативні кейси в @ConfigurationProperties трохи підступні. Якщо конфігурація невалідна, Spring-контекст не підніметься — а отже, звичайний @SpringBootTest-клас навіть не дійде до методу @Test. Тому перевірку «застосунок має впасти» зручніше робити так: запускати застосунок вручну всередині тесту і перевіряти, що він падає очікуваним чином.

Це виглядає приблизно так:

import org.junit.jupiter.api.Test;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;

import static org.assertj.core.api.Assertions.assertThatThrownBy;

class AttachmentPropertiesValidationFailureTest {

    @Test
    void failsFastWhenMaxCountIsInvalid() {
        assertThatThrownBy(() ->
                new SpringApplicationBuilder(ContentHubApplication.class)
                        // Нам не потрібен вебсервер: тестуємо лише старт контексту та біндинг
                        .web(WebApplicationType.NONE)
                        .properties(
                                // Активуємо тестовий профіль і підставляємо завідомо неправильний ліміт
                                "spring.profiles.active=test",
                                "contenthub.attachments.max-count=0"
                        )
                        .run()
        )
        // Для нас важливий стійкий сигнал: контекст не стартує через невалідний біндинг/валідацію.
        .hasMessageContaining("contenthub.attachments")
        .hasStackTraceContaining("max-count");
    }
}

Конкретний обгортковий клас у ланцюжку винятків може змінюватися між версіями Boot і біндера. Інженерно тут важливіше інше: застосунок падає одразу на старті, і зі стек-трейсу видно, що проблема саме в contenthub.attachments.max-count, а не десь далеко в бізнес-коді.

І ще один практичний момент: WebApplicationType.NONE тут допомагає не піднімати вебсервер. Ми перевіряємо конфігурацію, а не HTTP. Тому немає сенсу робити тест ще дорожчим.

9. Типові помилки під час роботи з @ConfigurationProperties

Помилка №1: properties-клас написали, але забули зареєструвати.
Найчастіший сценарій: клас позначили @ConfigurationProperties, написали поля, а потім @Autowired AttachmentProperties — і «No qualifying bean». У голові це відчувається як «Spring мене не любить», але насправді це просто факт: Spring не створює bean, про який не знає. Найчастіше вирішується @ConfigurationPropertiesScan на ContentHubApplication або явним @EnableConfigurationProperties у конфігурації (але другий варіант зазвичай менш зручний, якщо класів багато).

Помилка №2: замість Duration і DataSize використовують String, а потім парсять вручну.
Це зазвичай починається з думки «та що там парсити, один рядок». Потім з’являється другий формат (секунди vs мілісекунди), потім третій, потім тести на парсер, потім баг “5mb не розпізналося, бо хтось написав малими літерами”. Spring Boot уже вміє це робити, тож краще не змагатися з ним в олімпіаді «хто швидше напише велосипед».

Помилка №3: змішують в одному properties-класі кілька префіксів.
Наприклад, в одному класі тримають і storage.root, і moderation.base-url, і attachments.max-size. Спочатку здається зручно: «один клас — усі налаштування». Потім виявляється, що цей клас потрібен усюди, тести тягнуть його звідусіль, і ви отримуєте конфігураційного «об’єкта-бога». Один клас — один префікс, одна підсистема, одна відповідальність. Це сильно спрощує і читання, і тестування.

Помилка №4: забувають увімкнути валідацію (@Validated) і чекають, що анотації обмежень спрацюють самі.
Поставити @Min(1) на поле і не поставити @Validated на клас — це як поставити сигналізацію, але не вставити батарейки. Обмеження формально є, але ніхто його не запускає. У підсумку застосунок стартує з некоректною конфігурацією, а проблеми вилізають пізно й неприємно.

Помилка №5: роблять значення за замовчуванням лише в YAML і перетворюють код на «порожню оболонку».
Якщо значення за замовчуванням живуть лише в YAML, то ваш Java-клас стає не самостійною моделлю, а просто відображенням файла. Це не завжди погано, але часто знижує стійкість: достатньо забути ключ в одному середовищі — і застосунок змінює поведінку. Безпечні значення за замовчуванням для ключових лімітів і таймаутів зазвичай краще мати в коді, а YAML використовувати для перевизначення під середовище.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ