contextLoads ( ) без міфів

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

1. Міфи навколо contextLoads()

Звичайний @Test ми вже вміємо писати. Але щойно йдеться про Boot-проєкт, майже одразу спливає найдивніший шаблонний тест — contextLoads(). І в новачків він часто викликає два протилежні відчуття: або «якась магія, що робить важливі перевірки», або «порожня формальність, яку можна видалити без жалю». У цій лекції ми спокійно розберемо, де правда, а де — легенди зі світу «потім розберуся». І заодно з'ясуємо, чому порожній метод іноді корисніший за деякі непорожні.

Почнімо з ключової думки: contextLoads() — це не спеціальна команда Spring Boot. Це не «секретний тестовий API» і не заклинання, яке потрібно промовляти правильним тоном. Це лише звичайна назва тестового методу. Сенс з'являється не в назві, а в тому, що відбувається до запуску цього методу.

contextLoads() як назва методу

Коли ви пишете тест на JUnit 6, JUnit бачить методи з анотацією @Test і запускає їх. Усе. JUnit не знає ні про Spring, ні про ApplicationContext, ні про ваші біни. Якщо ви не під’єднали Spring-інтеграцію — тест буде максимально чесним: запуститься звичайний Java-метод. Порожній метод, як неважко здогадатися, «перевірить», що порожнеча… залишається порожнечею.

Подивіться на такий тест:

import org.junit.jupiter.api.Test;

class CatalogContextTest {

    @Test
    void contextLoads() {
        // Важливо: без Spring-інтеграції це просто запуск порожнього методу.
        // Такий тест пройде, навіть якщо ваш застосунок взагалі не стартує.
    }
}

Він пройде. І це не перемога. Це просто JUnit виконав порожній метод і не натрапив на виняток — ось і все.

Ось чому в Boot-проєктах важливо розрізняти дві речі: тестовий метод і тестове середовище. Сам метод contextLoads() може бути порожнім, але оточення навколо нього — зовсім не порожнє, якщо ви попросили Spring Boot підняти контекст.

І тут з'являється головний герой сьогоднішньої лекції: @SpringBootTest.

2. Перевірка через @SpringBootTest

У Spring Boot тест із contextLoads() цінний не тим, що написано всередині, а тим, що JUnit + Spring піднімають ваш застосунок перед тим, як дійти до тіла @Test-методу. Якщо контекст не піднявся — тест падає ще «на розігріві», і до рядка void contextLoads() виконання взагалі не доходить. Парадоксально, але порожній метод у такому разі схожий на контрольну лампочку на приладовій панелі: лампочка нічого не лагодить, але чесно загоряється, якщо щось пішло не так.

Мінімальна «правильна» версія виглядає так:

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest // Просимо Spring Boot підняти ApplicationContext для тесту
class CatalogContextTest {

    @Test
    void contextLoads() {
        // Якщо ми дійшли сюди — отже контекст успішно піднявся.
        // Будь-яка помилка на старті (зв'язування/конфіг/auto-config) призведе до падіння тесту раніше.
    }
}

Тепер давайте проговоримо, що саме робить @SpringBootTest людською мовою. Він каже Spring Boot приблизно таке: «Підніми ApplicationContext майже так само, як під час звичайного запуску застосунку, і лише після цього запускай тестові методи».

Зручно уявити це як невеликий сценарій:

sequenceDiagram
    participant J as JUnit
    participant S as "Тест Spring (SpringExtension)"
    participant B as Spring Boot
    participant C as ApplicationContext

    J->>S: "Запустіть тестовий клас"
    S->>B: "Потрібно підняти контекст (@SpringBootTest)"
    B->>C: "Створення контексту, бінів і прив'язування конфігурації"
    C-->>B: "Контекст готовий або сталася помилка"
    B-->>S: "Ок / помилка"
    S->>J: "Запуск @Test-методу (лише якщо все піднялося)"

І ось тут з'являється важливий, іноді несподівано корисний ефект: contextLoads() падає на найраніших і найбільш «інфраструктурних» помилках. Наприклад, порушили зв'язування, забули бін, зробили конфігурацію невалідною, розмістили клас не в тому пакеті — і застосунок уже не стартує.

До речі, Boot уміє кешувати контекст між тестами, щоб не піднімати його заново для кожного тестового методу чи класу, якщо конфігурація одна й та сама. Тому «підняти контекст» звучить страшніше, ніж часто відчувається на практиці, особливо в невеликому проєкті на кшталт catalog-service.

3. Перевірки smoke-тесту contextLoads()

Час зафіксувати: у такому тесті перевіряється не «логіка методу», а факт успішного старту Boot-застосунку в тестовому середовищі. Але «успішний старт» у Spring Boot — це не одна галочка, а цілий набір речей, які мають зійтися.

Підняття ApplicationContext і реєстрація бінів

Якщо у вашому проєкті порушили component scan, неправильно розставили анотації або клас опинився не в тому пакеті, Boot не зможе зібрати контекст. contextLoads() це ловить.

Типовий приклад із життя: ви створили сервіс із constructor injection і додали залежність, але не зареєстрували потрібний бін.

import org.springframework.stereotype.Service;

interface CatalogExporter {
    // Цей інтерфейс сам по собі не стає біном.
    // Потрібні реалізація та @Component/@Service або @Bean у конфігурації.
}

@Service
class CourseCatalogService {

    CourseCatalogService(CatalogExporter exporter) {
        // Якщо CatalogExporter ніде не зареєстрований як бін,
        // контекст упаде на старті з UnsatisfiedDependencyException.
    }
}

Якщо CatalogExporter ніде не оголошений як бін (ні через @Component, ні через @Bean), застосунок не стартує. І contextLoads() впаде з помилкою, схожою на UnsatisfiedDependencyException. У цьому й цінність smoke-тесту: він ловить такі поломки до того, як ви взагалі почнете перевіряти бізнес-логіку.

Auto-configuration і «інфраструктура з classpath»

У Boot багато що піднімається автоматично: MVC-інфраструктура, Jackson для JSON, Actuator, логування. Але якщо ви випадково зламали залежності, конфлікт версій або зробили щось надто творче в конфігурації — auto-configuration може почати падати. contextLoads() спіймає це як «застосунок не стартує», і це чесний сигнал: неважливо, яку кінцеву точку ви хотіли перевірити — вона все одно не запрацює, якщо контекст не піднявся.

Binding @ConfigurationProperties і валідація конфігурації

Для catalog-service цей пункт особливо важливий. За архітектурою курсу багато поведінки прив'язано до CatalogProperties, а ще тут є validation і fail-fast. Це означає, що зламаний конфіг — не «ну потім розберемося», а причина падіння на старті. Нижче — фрагмент саме з такої моделі: припускається, що validation для @ConfigurationProperties у проєкті вже справді ввімкнена, а обмеження беруть участь у binding-і.

Уявімо дуже спрощений фрагмент нашої конфігураційної моделі:

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

// Щоб обмеження справді зупиняли старт, properties-модель має брати участь у validation.
@Validated
@ConfigurationProperties(prefix = "app.catalog") // Біндинг значень із конфігурації за префіксом app.catalog
public record CatalogProperties(
        @Min(1) int maxFeaturedCount // Валідація: значення має бути >= 1
) {
}

Якщо в YAML хтось поставить:

app:
  catalog:
    # Помилка в конфігурації: порушуємо @Min(1), значення 0 невалідне
    max-featured-count: 0

то застосунок має впасти під час старту через порушення інваріанта, і це правильно: краще впасти одразу, ніж тихо працювати «якось дивно». І ось тут contextLoads() стає не «порожньою формальністю», а дуже дешевим детектором того, що конфігурація та її правила взагалі узгоджені.

Якщо ви ловили в попередньому модулі відчуття «ну в мене ж усе в YAML, чому воно не застосувалося», то smoke-тест допомагає хоча б зафіксувати базову річ: конфігурація читається, binding проходить, валідатор задоволений, контекст стартує.

Стартові хуки та «прихована логіка старту»

Ще один нюанс, який новачки часто не очікують: якщо у вас є @PostConstruct, ApplicationRunner, CommandLineRunner або якась стартова логіка, вона теж може запускатися під час підняття контексту в тесті. Тобто contextLoads() здатен упіймати помилки на старті, наприклад NPE у стартовому коді або нелогічну валідацію під час запуску.

І це водночас корисно і небезпечно. Корисно — бо ранні помилки ловляться рано. Небезпечно — бо якщо ви поклали надто важку роботу в startup, то ваш smoke-тест стане повільним. Але це вже не проблема тесту — це симптом того, що startup-логіка потребує дисципліни.

Коротка «карта можливостей» smoke-тесту

Щоб було простіше тримати межі в голові, ось невелика таблиця. Вона не про «ідеальний світ», а про типові очікування саме від contextLoads().

Що відбувається Приклад проблеми Чи спіймає contextLoads()
Контекст не стартує через wiring немає біна для інтерфейсу під час впровадження через конструктор Так
Зламана конфігурація @Min(1) і значення 0 у YAML Так
Падає startup-логіка помилка в @PostConstruct / runner Так
Порт не піднявся ви очікували справжній сервер, а його немає Не зовсім (це не його завдання)
Кінцева точка віддає «не той JSON» серіалізація / контракт Ні
Бізнес-логіка фільтрації неправильна сервіс повертає не те Ні

Сенс такий: contextLoads() — це тест «застосунок узагалі живий». Він не про коректність усієї функціональності, а про те, що фундамент не тріснув.

4. Межі contextLoads()

Дуже важливо не перетворювати contextLoads() на джерело хибної впевненості. Він корисний, але в нього є чесні обмеження. Якщо ви тримаєте їх у голові, тест працює як добрий інструмент. Якщо забуваєте — перетворюється на «амулет».

По-перше, він не доводить, що ваш HTTP API працює правильно. Навіть якщо контекст піднявся, ваш контролер може повертати не ті поля, не так серіалізувати Money, не так фільтрувати курси. Контекст міг стартувати ідеально, а логіка все одно бути неправильною — і це нормально: smoke-тест не зобов'язаний бути функціональним тестом.

По-друге, він не доводить, що ваші кінцеві точки доступні мережею в реальному сенсі. Часто в @SpringBootTest web-оточення піднімається в «тестовому» (mock) режимі: ви отримуєте web-біни, але не обов'язково слухаєте справжній порт, як під час java -jar. Тому якщо ви запускаєте ./gradlew test і очікуєте, що тепер можна відкрити браузер і піти на localhost:8080, то ви просто переплутали режими.

По-третє, він не доводить, що застосунок швидко стартує або мало споживає пам'яті. Smoke-тест — не профайлер. Він відповідає на питання «стартує чи падає», а не «наскільки добре і швидко».

І нарешті, він не замінює звичайні unit-тести на чисту Java-логіку. Якщо у вас є метод фільтрації, який можна перевірити без Spring — краще перевіряти без Spring. Піднімати контекст заради перевірки, що 2 + 2 = 4, — це не інженерія, а драматургія.

І ця вузькість — не недолік. contextLoads() відповідає лише на питання «фундамент живий чи ні?». Щойно потрібно вже не просто дочекатися старту, а адресно перевірити конкретний бін, CatalogProperties або властивості, задані через override, одного порожнього smoke-тесту замало.

5. Приклад із catalog-service

Давайте тепер приземлимо все до нашого проєкту. У типовому Spring Boot-проєкті Initializr створює тест приблизно в такому місці: src/test/java у кореневому пакеті застосунку. Для catalog-service це буде щось на кшталт com.example.catalogservice.

Найчастіше файл виглядає так:

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest // Smoke-тест: перевіряємо, що контекст застосунку піднімається
class CatalogServiceApplicationTests {

    @Test
    void contextLoads() {
        // Тіло тесту спеціально порожнє:
        // основна перевірка відбувається на етапі підняття ApplicationContext.
    }
}

І ось це якраз та версія, яка має сенс. Метод порожній, але тест не порожній: він змушує Boot зібрати контекст.

Окремо підкреслю: якщо ви перейменуєте метод, нічого не зламається. Spring Boot не шукає «спеціальну назву». Наприклад, так теж нормально:

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class CatalogServiceApplicationTests {

    @Test
    void bootContextStarts() {
    }
}

Назва — це лише людська документація. У командах це навіть корисно: за назвою тесту одразу видно, що він саме про підняття контексту, а не про доменну логіку.

Іноді розробники намагаються «покращити» тест і додають туди десяток перевірок, половина з яких уже про функціональність, половина — про конфігурацію, а третя половина (так, я теж умію ділити на три половини) — про настрій автора. Намагайтеся цього уникати. У межах мінімального базового рівня contextLoads() має залишатися коротким і нудним. Нудні тести — це комплімент: вони передбачувані.

Тест пройшов, але порт не відкрився

З цим питанням стикаються майже всі, хто вперше побачив @SpringBootTest. Ви запускаєте тест, він зелений, а в браузері нічого не відкрилося. І це нормально.

Річ у тім, що тест «контекст завантажився» не зобов'язаний піднімати справжній вбудований сервер на фіксованому порту, як під час звичайного запуску застосунку. У тестах часто використовується «mock web environment»: Boot створює web-шар, реєструє контролери, конвертери, Jackson, але не робить із вашого тестового прогону «живий сервіс» на localhost:8080.

Для contextLoads() це навіть зручно: тест швидший, не конфліктує із зайнятим портом, не залежить від зовнішнього світу. Його завдання простіше: довести, що застосунок можна зібрати як Spring-застосунок.

Якщо вам потрібно тестувати саме «живий» старт сервісу — це вже інший сценарій і інша форма smoke-перевірки. Зараз же ми фіксуємо лише базову впевненість: wiring + конфігурація + підняття контексту не зламані.

6. Типові помилки під час роботи з contextLoads()

Помилка № 1: думати, що слово contextLoads() має магію.
Іноді здається, що Spring «впізнає» цей метод і робить щось особливе. На практиці Spring Boot взагалі не цікавить, як ви назвали тест. Важлива анотація @SpringBootTest (і пов'язана з нею інтеграція Spring Test), а назва методу — просто звичний ярлик. Якщо прибрати @SpringBootTest, тест перетворюється на звичайний порожній JUnit-метод і дає хибне відчуття безпеки.

Помилка № 2: забути @SpringBootTest і радіти зеленому тесту.
Це класична пастка: ви бачите «зелений», думаєте «усе ок», а насправді ви взагалі не піднімали ApplicationContext. Зазвичай це трапляється після рефакторингу або копіпасту тесту «під себе». У таких випадках добре тримати в голові критерій: якщо тест задуманий як boot smoke-тест, він зобов'язаний піднімати контекст. Інакше це вже інший тест.

Помилка № 3: перетворювати contextLoads() на мішок непов'язаних перевірок.
Коли тест починає перевіряти і наявність біна, і значення властивостей, і «правильний список курсів», і «що featured рівно чотири», він перетворюється на крихкий комбайн. Будь-яка дрібна правка буде ламати цей тест, хоча старт застосунку при цьому може бути абсолютно нормальним. У підсумку команда або починає ігнорувати тести, або «лагодить» їх абияк. Краще тримати contextLoads() як вузьку перевірку: контекст стартує, крапка.

Помилка № 4: вважати, що успішний contextLoads() доводить коректність застосунку.
Цей тест ловить ранні поломки, але не замінює функціональні перевірки. Контекст може стартувати, а JSON може бути «кривим», фільтрація — неправильною, а опис курсу — не тим. Це не недолік, це чесна спеціалізація. Найбільша користь smoke-тестів — ловити катастрофи на старті, а не перевіряти якість усієї логіки.

Помилка № 5: переносити тестовий клас у «лівий» пакет і отримувати дивні помилки під час пошуку конфігурації.
Spring Boot любить, коли тести лежать у тому ж кореневому пакеті, що й @SpringBootApplication (або в підпакеті). Якщо винести тест далеко вбік, ви можете отримати повідомлення на кшталт «не знайдено @SpringBootConfiguration». Новачок у цей момент зазвичай думає, що «все зламалося», хоча насправді зламалася лише структура. Для catalog-service найпростіше тримати smoke-тест поруч із пакетом застосунку.

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