1. Ручний старт усередині тесту
Повного Boot-контексту вже достатньо, щоб перевірити наявність біна або зв’язування властивостей. Але щойно ви хочете самі керувати моментом старту — передати аргументи, увімкнути профіль, спіймати очікуване падіння, — @SpringBootTest стає трохи менш зручним напарником. Він піднімає контекст до виконання тестового методу, а отже не завжди дає вам у руки зручні важелі керування: інколи ви хочете стартувати застосунок усередині assertThrows, інколи — стартувати його без веб-рівня, інколи — передати «командний рядок», як під час реального java -jar.
У цій лекції ми робимо крок у бік «black-box» мислення. Ми перестаємо дивитися на контекст як на магічну річ, яку «підніме анотація», і починаємо ставитися до старту як до звичайної операції: створили SpringApplication, передали аргументи, отримали ConfigurableApplicationContext, перевірили потрібне — і акуратно закрили. Це дуже близько до реального запуску catalog-service поза IDE, тільки замість термінала у нас — тестовий метод.
Щоб не плутатися, корисно тримати в голові просте порівняння:
| Підхід | Як запускається застосунок | Коли зручно | Де болить |
|---|---|---|---|
| JUnit без Spring | Викликається лише ваш Java-код | Перевірити enum, value object, parsing, невелику логіку | Не ловить broken wiring, broken config і старт |
| @SpringBootTest | Автоматично, до тестового методу | Перевірити, що контекст узагалі збирається, отримати біни через @Autowired | Важко спіймати очікувану помилку старту всередині assertThrows |
| SpringApplication | Усередині @Test, коли ви вирішили | Перевірити різні параметри запуску, профілі, негативні сценарії | Потрібно не забувати закривати контекст, інакше тести можуть зависати |
Схема вибору проста: від найдешевшого інструмента до найбільш керованого. Звичайний JUnit добре підходить для чистої Java-логіки, @SpringBootTest — коли важливо, що застосунок збирається, а ручний SpringApplication — коли ви хочете самі диригувати стартом.
У catalog-service це особливо актуально, бо проєкт сильно залежить від конфігурації (CatalogProperties) і від fail-fast: якщо конфіг зламаний, ми хочемо дізнатися про це на старті, а не «колись потім».
2. SpringApplication, WebApplicationType.NONE і закриття контексту
Давайте почнемо з найпростішого: стартуємо наш застосунок CatalogServiceApplication вручну в тесті й перевіряємо лише факт, що контекст справді піднявся. Тут є три практичні причини робити це саме так. По-перше, ми можемо стартувати застосунок із параметрами так само, як він стартує в реальності. По-друге, ми можемо вимкнути веб-рантайм, щоб тест не намагався піднімати Tomcat і не займав порти. По-третє, ми можемо явно закрити контекст і гарантувати, що після тесту нічого «живого» не залишиться: ні потоків, ні таймерів, ні інших дрібних радощів.
Ключовий трюк — WebApplicationType.NONE. Він каже Boot: «Збери контекст, але не роби з цього веб-застосунок». Для smoke-перевірок старту це часто краще, ніж піднімати servlet-рантайм.
Приклад тесту (умовно файл src/test/java/com/example/catalogservice/startup/CatalogStartupSmokeTest.java):
import org.junit.jupiter.api.Test;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.context.ConfigurableApplicationContext;
import static org.junit.jupiter.api.Assertions.assertNotNull;
class CatalogStartupSmokeTest {
@Test
void startsWithValidConfiguration() {
// Створюємо SpringApplication вручну, щоб керувати стартом із тіла тесту
SpringApplication app = new SpringApplication(CatalogServiceApplication.class);
// У smoke-тестах старту зазвичай не потрібні веб-рантайм і порти
app.setWebApplicationType(WebApplicationType.NONE);
// Важливо: контекст потрібно закрити, інакше тестовий прогін може зависнути через живі потоки
try (ConfigurableApplicationContext context = app.run()) {
assertNotNull(context);
}
}
}
Зверніть увагу на try-with-resources: ConfigurableApplicationContext уміє закриватися, і це ваш найкращий друг. Якщо ви не закриєте контекст, тести інколи продовжують жити власним життям: не завжди помітно відразу, але одного дня ви побачите: «Чому Gradle висить на test?» — і почнете підозрювати, що Java знову образилася.
Якщо хочеться краще зрозуміти, що саме відбувається, можна уявити це як короткий ланцюжок:
flowchart TD A["JUnit 6 @Test"] --> B[SpringApplication] B --> C["app.run(...)"] C --> D[Піднято ConfigurableApplicationContext] D --> E[assertNotNull / будь-які перевірки] E --> F["context.close() через try-with-resources"]
І ось тепер у нас є зручна «кнопка запуску застосунку» прямо в тесті. Далі почнемо додавати керування: аргументи, профілі та негативні сценарії.
3. Аргументи запуску: імітація java -jar
Коли ви запускаєте Spring Boot-застосунок у реальності, ви досить часто керуєте ним через параметри: --spring.profiles.active=dev, --server.port=9090, --app.catalog.max-featured-count=3 і так далі. У попередніх лекціях ми багато говорили про зовнішню конфігурацію та пріоритети, а зараз нарешті використовуємо це як тестовий інструмент: передаємо параметри в app.run(...) і перевіряємо, що застосунок стартував саме з ними.
Важливо розуміти, що аргументи app.run("--key=value") у тесті — це не «якийсь особливий тестовий режим». Це той самий механізм, що й під час справжнього запуску. Тобто ви тестуєте не лише свої біни, а й конфігураційний шар: binder, валідацію, дефолти, пріоритети.
Давайте передамо одну властивість і переконаємося, що вона справді потрапила в CatalogProperties. Це вже не просто «контекст піднявся», а старт із очікуваною конфігурацією.
import org.junit.jupiter.api.Test;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.context.ConfigurableApplicationContext;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CatalogArgsStartupTest {
@Test
void appliesCommandLineProperty() {
SpringApplication app = new SpringApplication(CatalogServiceApplication.class);
app.setWebApplicationType(WebApplicationType.NONE); // без веб-сервера: швидше й стабільніше
// Передаємо "командний рядок", як під час реального запуску: java -jar ... --key=value
try (ConfigurableApplicationContext context =
app.run("--app.catalog.max-featured-count=3")) {
// Перевіряємо, що значення справді дійшло до типізованої конфігурації
CatalogProperties props = context.getBean(CatalogProperties.class);
// Фіксуємо один конкретний факт: maxFeaturedCount = 3
assertEquals(3, props.maxFeaturedCount());
}
}
}
Зверніть увагу на важливу методичну деталь: ми не перевіряємо «весь застосунок». Ми перевіряємо рівно один факт: параметр запуску справді дійшов до типізованої конфігурації. Для рівня «мінімального тестового baseline» це ідеальний баланс корисності та простоти.
І ще один нюанс: такі тести дуже допомагають ловити помилки на кшталт «я змінив ім’я властивості в YAML» або «я перейменував компонент record у класі properties, але забув, що ключі тепер інші». Вручну це помічається не завжди, а тест змушує бути чесним.
4. Профільний старт: перевірка активного профілю
Профілі (local/dev/prod) — це не прикраса конфігурації. Це ваш спосіб чесно описати, у яких режимах живе застосунок. Тому стартовий smoke-тест за профілем — дуже практична річ: він дає змогу зафіксувати, що застосунок узагалі може стартувати з конкретним профілем, і що профіль справді активувався.
Перевіряти профіль можна дуже просто: після старту ми читаємо Environment із контексту й дивимося getActiveProfiles(). Це не якась «магія тестів» — це звичайні API Spring, які ми вже використовуємо в застосунку.
Приклад (знову без веб-рантайму):
import org.junit.jupiter.api.Test;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.context.ConfigurableApplicationContext;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CatalogProfileStartupTest {
@Test
void startsWithDevProfile() {
SpringApplication app = new SpringApplication(CatalogServiceApplication.class);
app.setWebApplicationType(WebApplicationType.NONE); // веб-рівень не потрібен для перевірки профілю
// Активуємо профіль так само, як під час запуску jar
try (ConfigurableApplicationContext context =
app.run("--spring.profiles.active=dev")) {
// Читаємо активні профілі з Environment уже піднятого контексту
List<String> profiles = List.of(context.getEnvironment().getActiveProfiles());
// Перевіряємо мінімальний факт: профіль справді увімкнувся
assertTrue(profiles.contains("dev"));
}
}
}
Тут ми перевіряємо одразу дві речі. Перша — що застосунок узагалі стартує з профілем dev, тобто профільний конфіг не зламаний. Друга — що профіль справді активувався, а не лише «десь там мав би бути». Іноді люди думають, що профіль активний, бо «IDE так показала», а насправді під час запуску jar профіль узагалі не увімкнувся. Smoke-тест допомагає позбутися цього класу сюрпризів.
І знову ж таки: ми не йдемо в перевірку конкретних значень логування, Actuator exposure та іншого. Ми фіксуємо найпростіший факт: профіль увімкнувся, старт пройшов.
5. Зламана конфігурація: fail-fast тести
Найцікавіша частина сьогоднішньої лекції — навчитися писати тест, у якому застосунок має не стартувати. Так, це звучить дивно: «Ми ж хочемо зелені тести!» А ось і ні. Ми хочемо зелені тести, які підтверджують, що система поводиться правильно. Якщо в нас увімкнена валідація CatalogProperties, то за зламаного конфігу правильною поведінкою є швидке, голосне падіння без напівтонів.
І ось тут ручний старт розкривається особливо сильно. З @SpringBootTest ви часто не можете зручно написати assertThrows, бо падіння стається до виконання методу. А за ручного старту ви запускаєте застосунок усередині лямбди, і assertThrows працює як годинник.
Тут є одна важлива передумова: валідація для CatalogProperties у вас уже справді увімкнена в проєкті, а обмеження беруть участь у binding-і. Якщо залишити лише @Min на компоненті record, але не підключити модель валідації, fail-fast із цього сам не виросте.
Приклад: maxFeaturedCount за правилами проєкту має бути більшим за 0. Давайте передамо 0 і перевіримо, що старт справді падає.
import org.junit.jupiter.api.Test;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import static org.junit.jupiter.api.Assertions.assertThrows;
class CatalogBrokenConfigTest {
@Test
void failsFastWhenMaxFeaturedCountIsZero() {
SpringApplication app = new SpringApplication(CatalogServiceApplication.class);
app.setWebApplicationType(WebApplicationType.NONE); // веб-рівень тут лише заважає
// Ключова ідея: запускаємо всередині assertThrows, щоб падіння старту було очікуваною поведінкою
assertThrows(Exception.class, () ->
app.run("--app.catalog.max-featured-count=0")); // завідомо невалідне значення
}
}
Цей тест — дуже «Boot-centric» за змістом. Він перевіряє не вашу бізнес-логіку і не контролери. Він перевіряє базову філософію застосунку: конфігурація — це частина контракту старту. Якщо контракт порушено, ми не вдаємо, що «ну гаразд, потім розберемося». Ми не стартуємо.
Якщо хочеться побачити механіку, можна уявити процес так:
flowchart TD
A["app.run('--app.catalog.max-featured-count=0')"] --> B[Binder зв'язує властивості]
B --> C[Validation перевіряє обмеження]
C --> D{"валідно?"}
D -- ні --> E[Виняток, старт перервано]
D -- так --> F[Контекст піднято]
Чому в прикладі assertThrows(Exception.class, ...), а не щось конкретніше? Бо на нашому рівні важливий сам факт fail-fast. Конкретний тип винятку може змінюватися — залежно від того, де саме стається падіння: на binding, на validation чи під час створення конкретного біна. А ви не хочете робити тест крихким. У великому «testing-курсі» ми б обговорювали unwrap причин і точну класифікацію. Тут — ні. Тут ми тренуємо правильну звичку: «зламав конфіг — застосунок не стартує — тест це фіксує».
І ще один тонкий момент: інколи студент очікує, що «зламаний конфіг» проявиться як помилка десь у логах, але застосунок усе одно запуститься. Це дуже небезпечна ілюзія. Коли конфігурація визначає критичні інваріанти — як у нас: ліміти, обов’язкові поля, валюта, курси, — то частковий старт майже завжди гірший за падіння. Частковий старт — це як машина, яка «майже завелася», але гальма в неї десь потім з’являться. Спойлер: не з’являться.
6. Нюанси: швидкість і чистота smoke-тестів
Ручний старт застосунку всередині тесту — потужний інструмент, але, як будь-який інструмент, він любить міру. Піднімати Boot-контекст дорого за часом. У навчальному проєкті це нормально: тестів небагато, і вони потрібні як базова перевірка. Але все одно варто пам’ятати просте правило: не треба перетворювати один тест на «комбайн», який перевіряє профіль, три властивості, наявність бінів і ще заодно друкує половину Environment у консоль. Такий тест швидко починає жити власним життям і стає незрозумілим.
Практично це означає, що краще тримати маленькі, вузько спрямовані тести: один старт — одна ідея. Окремо тест на «стартує з валідною конфігурацією», окремо тест на «стартує з dev-профілем», окремо тест на «падає на зламаній властивості». Це робить тести читабельними навіть для людини, яка відкриє проєкт через місяць і вже не пам’ятатиме, що таке maxFeaturedCount (а таке трапляється).
І ще один нюанс: ми свідомо ставили WebApplicationType.NONE. Це одночасно про швидкість і про стабільність. У такому режимі ви не залежите від портів і не запускаєте embedded server. Так, наш проєкт загалом веб-сервіс, але старт контексту і старт веб-рантайму — це різні рівні перевірки. Сьогодні ми фіксуємо базовий рівень: контекст і конфіг. Якщо його немає, жодного веб-рантайму «потім» усе одно не буде.
7. Типові помилки під час smoke-тестів старту
Помилка № 1: не закривати ConfigurableApplicationContext після успішного старту.
Це класика, яка виглядає безневинно: «Ну й що, тест же короткий». Насправді незакритий контекст може залишити живі потоки, таймери або ресурси, і в якийсь момент тести почнуть дивно зависати чи сповільнюватися. try-with-resources — найпростіший запобіжник: якщо контекст піднявся, його потрібно закрити.
Помилка № 2: випадково піднімати веб-рантайм там, де тест про конфігурацію.
Якщо ви не задасте WebApplicationType.NONE, застосунок стартує як servlet-web, бо в нас spring-boot-starter-webmvc. Тоді тест може неочікувано почати вимагати вільний порт, а ще виконуватиметься довше. Коли мета — лише wiring і конфіг, веб-сервер — зайвий персонаж на сцені.
Помилка № 3: намагатися в одному тесті перевірити одразу профіль, властивості й «а ще нехай падає на неправильному значенні».
Такий тест виглядає «економним», але швидко стає нечитабельним: незрозуміло, що саме він перевіряє і чому він падає. Набагато здоровіше зробити три короткі тести, кожен з одним стартовим сценарієм і однією ідеєю.
Помилка № 4: очікувати надто конкретний тип винятку в broken-config тесті.
Коли ви пишете assertThrows(BeanCreationException, ...), тест може стати крихким: невелика зміна валідації або порядку створення бінів змінить тип винятку, хоча сенс лишиться тим самим — конфіг зламаний, старт перервано. На сьогоднішньому рівні краще фіксувати сам факт fail-fast і не перетворювати тест на «вгадай точний exception».
Помилка № 5: забути, що старт усередині тесту використовує ту саму конфігурацію, що й звичайний запуск.
Інколи очікують, що тест «сам усе підмінить», а насправді він чесно читає ваш application.yaml, файли профілів та імпорти. Тому якщо базова конфігурація проєкту вже зламана, то навіть тест «на broken config» може падати не в тому місці, де ви очікуєте. Це неприємно, але справедливо: спочатку сервіс має стартувати нормально, а потім ми додаємо перевірку, що в ненормальному режимі він падає.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ