1. Обмеження перевизначення властивостей
Перевизначення властивостей — це як підкрутити ручку гучності: зручно, швидко, безпечно. Але в нього є межа: властивості можуть змінювати числа, рядки та прапорці, проте не здатні замінити «проводку» всередині застосунку. Наприклад, ви можете встановити contenthub.notifications.enabled=false, але якщо ваш сервіс усе одно викликає реальний PublicationNotificationSender, у тесті зʼявиться побічний ефект. Або ви можете вказати шлях contenthub.storage.root, але якщо реалізація сховища все одно ходить у «бойовий» каталог, сюрприз забезпечено. Коли потрібно змінити поведінку — час, поточного користувача, зовнішні адаптери — ми переходимо до test-only wiring: акуратно додаємо або підміняємо біни саме в тестовому контексті.
Трохи груба, але корисна таблиця відмінностей — її можна тримати в голові як «шпаргалку для вибору інструмента»:
| Що ви хочете змінити в тесті | Достатньо properties? | Потрібен test-only bean? | Приклад із ContentHub |
|---|---|---|---|
| Просте значення (ліміт, прапорець, URL) | Так | Іноді | contenthub.attachments.max-count=1 |
| «Динамічне» значення (temp dir, випадковий порт) | Іноді | Іноді | storage root «на льоту» |
| Джерело часу | Ні | Так | фіксуємо Clock |
| Поточний користувач | Ні (зазвичай) | Так | CurrentUserProvider повертає стабільне імʼя |
| Зовнішній адаптер / побічний ефект | Іноді | Так | фіктивний sender, фіктивний moderation client |
2. Test-only wiring: коли і як
Test-only wiring — це ситуація, коли для тесту ви збираєте трохи інший ApplicationContext, ніж у продакшені: не тому, що «так простіше», а тому, що тест має бути детермінованим, безпечним і швидким. Це схоже на те, як у кіно знімають дощ: на вулиці може світити сонце, але на майданчику вмикають дощувальну установку, бо за сценарієм потрібен дощ. У реальному світі дощ — річ непередбачувана. У тестах так само: час, файлова система, зовнішні URL і «поточний користувач» — джерела хаосу, а нам потрібен керований хаос, тобто його відсутність.
Корисно уявити це так: production-код лишається тим самим, а змінюється тільки «обв’язка» навколо нього в тестовому середовищі.
flowchart TD A["Продакшн ApplicationContext"] --> B["ArticleWorkflowService"] A --> C["Clock (реальний)"] A --> D["CurrentUserProvider (реальний)"] A --> E["PublicationNotificationSender (реальний)"]
flowchart TD T["Тестовий ApplicationContext"] --> TB["ArticleWorkflowService"] T --> TC["Clock (фіксований)"] T --> TD["CurrentUserProvider (тестовий)"] T --> TE["PublicationNotificationSender (recording / no-op)"]
Головна дисципліна тут у тому, щоб test-only wiring не перетворювався на «альтернативний застосунок». Ми не хочемо жити у двох світах, де все працює по-різному. Нам потрібно лише зафіксувати те, що інакше буде випадковим, шумним або небезпечним.
3. @TestConfiguration для тестових бінів
@TestConfiguration — це зручний спосіб оголосити набір @Bean-ів, які призначені лише для тестів. Сенс у тому, що ви не забруднюєте src/main/java тестовими костилями, не додаєте «якщо test, то…» у production-конфігурацію і не розпочинаєте вічну суперечку, чи можна тримати тестові класи поруч із бойовими. Можна, але тільки в src/test/java і лише через спеціальні механізми, які явно кажуть Spring Boot: «це потрібно для тестів».
Важлива деталь: test configuration має бути маленькою і конкретною. Вона має відповідати на питання: «Що саме ми робимо детермінованим?». Якщо ви ловите себе на думці «давайте в тестах піднімемо пів застосунку, бо щось не стартує», — це вже не test-only wiring, а спроба втекти від проблеми архітектури.
Мінімальний приклад test-only конфігурації з одним біном:
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
@TestConfiguration
public class FixedClockTestConfig {
@Bean
@Primary
Clock fixedClock() {
// Якщо в основному контексті вже є production Clock,
// цей test bean має явно вигравати за типом.
// Сам час фіксований, щоб прибрати нестабільність через годинник і часовий пояс.
return Clock.fixed(Instant.parse("2026-01-10T10:15:30Z"), ZoneOffset.UTC);
}
}
Якщо в основному контексті вже є production Clock, test bean того самого типу має явно вигравати. Тому тут безпечніше одразу зробити його @Primary, а імʼя методу лишити нейтральним — без гри в глобальне bean overriding.
Зверніть увагу на «приземленість»: один клас, один @Bean, жодної бізнес-логіки. @TestConfiguration — не місце, де ми мудруємо. Це місце, де ми вимикаємо випадковість.
4. @Import для точкового підключення
Найчастіша інженерна помилка початківців — зробити одну «базову тестову конфігурацію» і підключити її всюди, а потім дивуватися, чому різні тести несподівано впливають один на одного. @Import допомагає тримати вплив локальним: ви підключаєте тестову конфігурацію рівно в той тест, якому вона потрібна. Так ви читаєте тест і відразу розумієте, чому він працює в особливому режимі, — це видно в анотаціях зверху.
На практиці це виглядає дуже просто: беремо @SpringBootTest (або інший тестовий режим), додаємо @Import(...) і отримуємо потрібний бін.
import java.time.Clock;
import java.time.Instant;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@ActiveProfiles("test")
@Import(FixedClockTestConfig.class) // Підключаємо саме ту test config, яка потрібна цьому тесту.
class FixedClockWiringTest {
@Autowired
Clock clock; // Витягуємо Clock із контексту: перевіряємо, що підміна справді спрацювала.
@Test
void usesFixedClock() {
// Перевіряємо, що "поточний час" не залежить від реального годинника машини / CI.
assertThat(clock.instant())
.isEqualTo(Instant.parse("2026-01-10T10:15:30Z"));
}
}
Якщо вам потрібно кілька test configʼів, їх можна імпортувати масивом. Це читається майже як «рецепт контексту»: ось тест, ось профіль, ось додаткові біни.
5. Фіксований Clock у тестах
Час у тестах — це той самий персонаж, який завжди приходить без запрошення і ламає вечірку. Сьогодні тест зелений, завтра — червоний, бо timezone, перехід на літній час, запуск о 23:59:59, а ще хтось просто посеред методу зробив Instant.now(). Тому в ContentHub ми заздалегідь тримаємо «вихід до поточного часу» через Clock, щоб час можна було замінити в тестах без фокусів.
Приклад шматка production-логіки, спрощено, де час береться через clock:
import java.time.Clock;
import java.time.Instant;
public class ArticleWorkflowService {
private final Clock clock;
public ArticleWorkflowService(Clock clock) {
// Явно впроваджуємо залежність "поточний час" через Clock,
// щоб у тестах можна було підмінити її на фіксовану.
this.clock = clock;
}
public Instant now() {
// Важливо: не Instant.now(), а саме clock.instant().
// Тоді час стає керованим через wiring.
return clock.instant();
}
}
Так, метод now() виглядає смішно. Але він виконує важливу роль: робить залежність від часу явною, а отже — тестованою. Якщо ви замість цього напишете Instant.now() просто в бізнес-коді, тести почнуть перетворюватися на ворожіння на кавовій гущі: «а чому в нас createdAt відрізняється на 2 мілісекунди?»
У тестах ми фіксуємо час через @TestConfiguration, як уже робили вище, і отримуємо детермінованість. Ключовий момент тут не в самій анотації, а в дисципліні: час завжди приходить через бін, отже в тестів завжди є важіль керування.
6. Тестовий CurrentUserProvider
Поточний користувач — друге класичне джерело хаосу. Якщо production-код читає користувача прямо з SecurityContextHolder, то unit-тести перетворюються на «завантажити пів Spring, щоб добути username». У ContentHub ми намагаємося тримати залежність від користувача через маленький інтерфейс-провайдер: бізнес-логіці не потрібно знати, звідки береться користувач, їй важливо лише «хто зараз діє».
Уявімо мінімальний контракт:
public interface CurrentUserProvider {
// Контракт максимально простий: бізнес-логіці потрібен лише username.
// Звідки він береться (security context, header, mock) — не її турбота.
String username();
}
У production буде реалізація, яка бере імʼя з security-контексту або іншого механізму. А в тесті ми хочемо, щоб «поточний користувач» був стабільний, як стілець на кухні: завжди той самий, без сюрпризів.
Ось test-only wiring, який робить користувача «editor-test»:
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
@TestConfiguration
public class TestUserConfig {
@Bean
@Primary
CurrentUserProvider currentUserProvider() {
// Тестова реалізація: завжди повертає одне й те саме ім'я,
// щоб сценарії не залежали від security-налаштувань і контексту.
return () -> "editor-test";
}
}
Зверніть увагу на @Primary. Це важливий практичний нюанс: якщо в основному застосунку вже є CurrentUserProvider, у контексті опиняться два біни одного типу. Spring чесно скаже: «я не знаю, який обрати». @Primary — ввічливий спосіб сказати: «у тестах обирай цей».
І підключення в тест:
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@ActiveProfiles("test")
@Import(TestUserConfig.class) // Підміняємо лише провайдера користувача.
class CurrentUserProviderWiringTest {
@Test
void testContextUsesStableUsername(CurrentUserProvider currentUserProvider) {
// Spring може прокинути бін просто параметром методу: зручно для коротких тестів.
assertThat(currentUserProvider.username()).isEqualTo("editor-test");
}
}
Тут ми використовуємо ще одну зручну дрібницю Spring: можна не @Autowired-ити поле, а прийняти залежність параметром тестового методу. Це робить тест коротшим і трохи менш «анотаційно важким».
7. Recording PublicationNotificationSender
Побічні ефекти — це все, що «відлітає назовні»: надсилання сповіщень, виклик зовнішнього moderation service, запис файлів, публікація подій. У тестах вони особливо небезпечні, бо можуть залежати від мережі, потоків, швидкості машини та місячної фази. Я не стверджую, але іноді здається саме так. Тому розумний підхід такий: у більшості тестів побічний ефект треба або вимкнути, або зробити спостережуваним і безпечним.
Нехай у нас є порт сповіщень:
public interface PublicationNotificationSender {
// Порт побічного ефекту: у проді це може бути email / HTTP / черга,
// а в тестах — запис факту виклику або no-op.
void sendArticlePublished(long articleId);
}
У тестах зручно мати «записувальну» реалізацію, яка нічого не надсилає, але зберігає факт виклику:
import java.util.ArrayList;
import java.util.List;
public class RecordingNotificationSender implements PublicationNotificationSender {
// Зберігаємо "що було опубліковано" для подальшої перевірки в тесті.
private final List<Long> published = new ArrayList<>();
@Override
public void sendArticlePublished(long articleId) {
// Замість реального надсилання фіксуємо виклик.
published.add(articleId);
}
public List<Long> publishedArticleIds() {
// Повертаємо копію, щоб тести не могли випадково зіпсувати внутрішній стан.
return List.copyOf(published);
}
}
Тепер підключимо це як test-only wiring так, щоб production-код і далі бачив PublicationNotificationSender, але ми могли в тесті дістати саме recording-обʼєкт і подивитися, що сталося:
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
@TestConfiguration
public class RecordingNotificationsConfig {
@Bean
RecordingNotificationSender recordingNotificationSender() {
// Окремим bean'ом реєструємо конкретний клас, щоб у тесті можна було отримати його за типом.
return new RecordingNotificationSender();
}
@Bean
@Primary
PublicationNotificationSender publicationNotificationSender(RecordingNotificationSender recording) {
// А назовні (як порт) віддаємо його ж, щоб усі production-компоненти використовували test-double.
return recording;
}
}
І дуже маленький тест-перевірка wiring без переходу в складні сценарії:
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@Import(RecordingNotificationsConfig.class) // Підключаємо recording-реалізацію замість реального надсилання.
class NotificationSenderWiringTest {
@Test
void allowsObservingSideEffect(RecordingNotificationSender sender) {
// Імітуємо побічний ефект: у проді це був би "send", у тесті — запис факту виклику.
sender.sendArticlePublished(42L);
// Перевіряємо, що "сповіщення" відбулося — але безпечно і детерміновано.
assertThat(sender.publishedArticleIds()).containsExactly(42L);
}
}
Це здається «занадто простим», але в цьому й мета: ми зараз вчимося контролювати оточення. Щойно оточення стає контрольованим, тести бізнес-сценаріїв стають у рази легшими, бо ви не воюєте з інфраструктурою.
8. Безпечне файлове середовище
Файлова система в тестах — як кішка: вона ніби домашня, але робить що хоче. Якщо не задати правила, ви отримаєте файли в неочікуваних місцях, конфлікти між тестами, проблеми на Windows через шляхи і вічну загадку: «а чому локально працює, а на CI — ні?». Тому мінімум, який має бути в проєкті на кшталт ContentHub: у тестовому профілі має бути безпечний storage root, а в окремих тестах — можливість ізолювати каталог ще сильніше.
Базовий і дуже практичний варіант — тримати в application-test.yml щось на кшталт:
contenthub:
storage:
root: "build/test-storage"
Це забезпечує хоча б те, що тести не писатимуть у домашній каталог розробника або в «бойовий» шлях. Але є ситуації, коли ви хочете абсолютної ізоляції: один тест — один тимчасовий каталог. Тоді добре працює JUnit-анотація @TempDir. Вона створює каталог і гарантує, що він буде унікальним для тесту, а зазвичай ще й очищається після нього.
Приклад: у нас є адаптер файлового сховища, якому потрібен Path root. Схема може бути такою: значення contenthub.storage.root біндиться в StorageProperties, а потім використовується під час створення бін-а FileStorageService. Якщо нам потрібно зробити root динамічним, ми можемо це зробити або через dynamic property — ми вже бачили цю ідею — або через test-only bean.
З погляду теми сьогоднішньої лекції зручніше показати саме підміну бін-а: ми взагалі не чіпаємо реальну файлову систему, а даємо in-memory сховище.
Умовний порт:
public interface FileStorageService {
// Зберігаємо байти "під ключем" (у проді — у файл, у тесті — у пам'ять).
void save(String key, byte[] content);
// Завантажуємо байти за ключем.
byte[] load(String key);
}
Тестова реалізація:
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class InMemoryFileStorageService implements FileStorageService {
// Потокобезпечна структура на випадок паралельних тестів / викликів.
private final Map<String, byte[]> files = new ConcurrentHashMap<>();
@Override
public void save(String key, byte[] content) {
// Імітуємо збереження: просто кладемо байти в Map.
files.put(key, content);
}
@Override
public byte[] load(String key) {
// Імітуємо читання: дістаємо байти з Map.
// Примітка: якщо ключа немає, повернеться null — це важливо враховувати в тестах / контракті.
return files.get(key);
}
}
І test-only wiring:
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
@TestConfiguration
public class InMemoryStorageConfig {
@Bean
InMemoryFileStorageService inMemoryFileStorageService() {
// Реєструємо конкретну реалізацію, щоб за потреби отримати її напряму в тестах.
return new InMemoryFileStorageService();
}
@Bean
@Primary
FileStorageService fileStorageService(InMemoryFileStorageService impl) {
// А як основний порт файлового сховища віддаємо in-memory варіант.
return impl;
}
}
Так, це «не справжній диск». Але це нормально, якщо тест не про файлову систему, а про бізнес-логіку навколо вкладень. Ми не підміняємо контракт: сервіс усе одно зберігає і читає байти за ключем. Ми просто прибираємо I/O і робимо тести стабільнішими.
9. Карта рішень для середовища
Детерміноване середовище — це не одна анотація і не один файл. Це комбінація рівнів, які ви обираєте усвідомлено: що живе в application-test.yml, що перевизначається локально властивостями, що задається динамічно, а що робиться через test-only beans. І тут приємно, що у вас уже є інструменти з попередніх лекцій: тепер ви просто додаєте ще один важіль — @TestConfiguration + @Import.
Корисна «карта» у вигляді короткого decision flow:
flowchart TD
A["Потрібно змінити поведінку тесту"] --> B{"Це просто значення?"}
B -->|Так| C["application-test.yml / properties / @TestPropertySource"]
B -->|Ні| D{"Це runtime-значення?"}
D -->|Так| E["@DynamicPropertySource"]
D -->|Ні| F{"Потрібно замінити залежність (bean)?"}
F -->|Так| G["@TestConfiguration + @Import (+ @Primary за конфлікту)"]
F -->|Ні| H["Перевірте дизайн: можливо, залежність прихована і її треба винести"]
Зверніть увагу на останню гілку. Якщо ви не можете зробити середовище детермінованим ні через властивості, ні через підміну бін-а, це зазвичай означає одну з двох речей. Або ви справді намагаєтеся протестувати занадто великий шматок системи — і тоді варто зменшити межу тесту, — або в production-коді є прихована глобальна залежність, яку треба зробити явною. У ContentHub ми якраз і намагаємося тримати залежності на кшталт часу, користувача, storage та зовнішніх адаптерів у вигляді явних портів / бінів.
І ще один дуже практичний нюанс: чим більше ви плодите унікальних @Import(...) наборів, тим більше різних тестових контекстів запускатиметься. Це не погано, це просто ціна. Тому хороший стиль — мати невеликі, повторно використовувані конфігурації, наприклад FixedClockTestConfig і TestUserConfig, та імпортувати їх точково там, де вони справді потрібні, а не всюди «про всяк випадок».
10. Типові помилки під час test-only wiring
Помилка №1: класти test-only конфігурацію в src/main/java і сподіватися, що «ну вона ж нікому не заважає».
Щойно тестова конфігурація потрапляє в бойовий код, вона починає жити своїм життям: випадково потрапляє в component scan, випадково імпортується, випадково активується профілем, а іноді взагалі «вилазить» у продакшені. Правильна межа проста: усе тестове — у src/test/java, і краще через @TestConfiguration, щоб намір був очевидним.
Помилка №2: намагатися «перебити» production bean увімкненням bean overriding глобально.
Іноді хочеться зробити швидко: увімкнути spring.main.allow-bean-definition-overriding=true і просто оголосити бін із тим самим імʼям. Це майже завжди робить тести менш читабельними й збільшує шанс прихованих конфліктів. Значно спокійніше оголосити другий бін і вибрати його через @Primary, або взагалі підмінити залежність на вищому рівні — порт / адаптер, — не граючи в «хто кого перевизначить».
Помилка №3: тестовий бін порушує контракт і дає «нереалістично хорошу» поведінку.
Наприклад, тестовий ModerationClient завжди повертає OK і ніколи не кидає виняток, а бізнес-логіка в результаті не бачить реальних гілок. Або in-memory storage приймає будь-які ключі, а реальний filesystem потім скаржиться на недопустимі символи. Test double має спрощувати інфраструктуру, але не змінювати сенс контракту. Інакше ви отримаєте зелені тести, які доводять лише те, що ваші тестові заглушки працюють.
Помилка №4: занадто великий @TestConfiguration, який перетворюється на «другий застосунок».
Коли в тестовій конфігурації зʼявляються десятки @Bean, умовні гілки, складні обчислення, читання файлів і ще трохи філософії, ви перестаєте розуміти тестовий контекст. У якийсь момент тести починають падати через помилки в тестовій конфігурації, а не через код, що перевіряється. Тримайте test config маленьким: одне джерело недетермінізму — одна локальна підміна.
Помилка №5: статика і спільний стан у test beans без контролю.
Recording-обʼєкти, in-memory сховища та будь-які колекції всередині test beans небезпечні, якщо вони випадково переживають кілька тестів в одному контексті й «памʼятають минуле життя». Якщо ви зберігаєте стан, переконайтеся, що він скидається, або створюється заново там, де це потрібно. Інакше ви отримаєте тест, який залежить від порядку запуску, а це вже майже рівень містики — тільки в поганому сенсі.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ