JavaRush /Курси /Spring Test /Коли потрібен @SpringBootT...

Коли потрібен @SpringBootTest

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

1. Критерій вибору @SpringBootTest

Якщо ви щойно навчилися піднімати застосунок цілком, виникає спокуса потягнутися до @SpringBootTest приблизно так само, як рука тягнеться до кнопки «Ctrl+C / Ctrl+V»: швидко, звично і майже завжди з наслідками. Але повний контекст — це не просто ще одна анотація, а дорогий спосіб отримати впевненість. І ціна тут цілком реальна: час запуску, складність діагностики та «шум» від зайвих компонентів.

Після першого smoke-тесту та одного сценарію збирання з’являється небезпечна думка: якщо повний контекст уміє ловити стільки всього, може, зробити його тестом за замовчуванням? Ось тут і потрібен фільтр. Інакше @SpringBootTest дуже швидко перетворюється з корисного інструмента на дорогу звичку.

У unit-тесті ви перевіряєте локальну логіку й точно знаєте, що саме впало: один клас, один метод, одна помилка. У slice-тесті ви перевіряєте межу шару й теж тримаєте ситуацію під контролем. А в @SpringBootTest ви запускаєте маленький всесвіт: автоконфігурації, сканування компонентів, datasource, міграції, security-конфігурації, різні @ConfigurationProperties та інші «приємні сюрпризи».

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

Сформулюємо просте правило для людини, а не для філософії: @SpringBootTest доречний тоді, коли ви можете чесно сказати собі — і тимліду, і майбутньому собі через три місяці: «Цей дефект не видно на дешевшому рівні». Якщо такої фрази немає — це не тест, а, перепрошую, ритуал викликання зеленого кольору.

2. Унікальна цінність повного контексту

Коли кажуть «повний контекст», у новачка часто виникає картинка: «Ну все, я підняв Spring Boot — отже, я перевірив усе». Це приблизно як сказати: «Я завів машину — отже, я доїхав до моря». Запуск — це важливо, але це лише старт, а не подорож. Повний контекст дає особливу впевненість, але ця впевненість має цілком конкретну форму.

@SpringBootTest особливо корисний, коли ризик лежить у збиранні системи: у тому, як реальні біни знаходяться, створюються, зв’язуються один з одним і як у це зв’язування вплітаються налаштування та автоконфігурації. У ContentHub це може проявлятися банально: ви перейменували пакет, і раптом @ComponentScan перестав бачити сервіс; ви додали @ConfigurationProperties, але забули ввімкнути їхнє зв’язування; ви змінили налаштування datasource у application-test.yml, і міграції Flyway перестали застосовуватися; ви змінили сигнатуру конструктора сервісу, і Spring більше не може зібрати бін.

Важливо чесно проговорити: @SpringBootTest не зобов’язаний доводити, що всі бізнес-правила коректні; він не зобов’язаний доводити, що конкретний JSON-контракт стабільний; він не зобов’язаний доводити, що репозиторій правильно сортує результати; він не зобов’язаний доводити, що контролер коректно валідовує DTO. Для всього цього у вас уже є дешевші рівні.

Щоб не плутатися, зручно тримати в голові маленьку таблицю «питання → інструмент». Це не догма, а швидкий компас, коли мозок втомився, а тест написати треба.

Питання, на яке відповідає тест Мінімально достатній інструмент (зазвичай) Чому це дешевше/точніше
«Чи працює локальне правило/алгоритм без Spring?» Unit-тест (звичайний JUnit 6) Миттєво, без контексту, точна діагностика
«Чи стабільний JSON-контракт DTO?» @JsonTest Ловить серіалізацію/десеріалізацію без MVC і сервісів
«Чи коректна HTTP-границя контролера (status/validation/error contract)?» @WebMvcTest Піднімається web-рівень, але не весь застосунок
«Чи працює JPA mapping/query/constraints?» @DataJpaTest Дешевше за повний контекст, фокус на persistence
«Чи збирається застосунок як система і чи сходяться реальні біни разом?» @SpringBootTest Єдиний рівень, який по-справжньому перевіряє full wiring

Якщо ви дивитеся на таблицю й бачите, що ваше питання про SlugService.toSlug(...), а ви вже відкрили файл @SpringBootTest, — це добрий момент для маленької паузи, глибокого вдиху й акуратного кроку назад.

3. Дерево рішень для вибору рівня тесту

Зараз ми зберемо просте дерево рішень, яке допомагає дійти до @SpringBootTest як до висновку, а не як до звички. Це не має бути чек-лист на сто пунктів; навпаки, він має бути настільки простим, щоб ви могли прогнати його в голові за 15 секунд, поки Gradle ще не встиг остаточно загіпнотизувати вас логами.

Нижче — схематична блок-схема, яка відображає саме ідею «мінімально достатнього тесту». Зверніть увагу: @SpringBootTest тут майже в кінці — не тому, що він «поганий», а тому що він дорогий.

flowchart TD
    A["Що саме я хочу довести?"] --> B{"Чи можна перевірити без Spring?"}
    B -->|Так| U["Unit-тест (звичайний JUnit 6)"]
    B -->|Ні| C{"Це про DTO/JSON-контракт?"}
    C -->|Так| J["@JsonTest"]
    C -->|Ні| D{"Це про HTTP-границю одного контролера?"}
    D -->|Так| W["@WebMvcTest"]
    D -->|Ні| E{"Це про JPA/query/constraints?"}
    E -->|Так| P["@DataJpaTest"]
    E -->|Ні| F{"Ризик у wiring/конфігурації/збиранні системи?"}
    F -->|Так| S["@SpringBootTest (повний контекст)"]
    F -->|Ні| G["Схоже, питання сформульоване нечітко — уточніть ризик"]

Тепер — важлива частина: це дерево працює тільки якщо ви чесно формулюєте ризик. Не «хочу протестувати метод approve()», а «хочу довести, що в реальному контексті коректно збирається ланцюжок: сервіс → репозиторій → транзакція → збереження в БД». Не «хочу протестувати контролер», а «хочу довести, що контролер правильно віддає 400 і ApiProblem за невалідного DTO». Формулювання ризику робить вибір рівня майже очевидним.

На практиці в ContentHub багато речей вирішуються саме на дешевших рівнях. Наприклад, правила зміни статусу статті ви вже тестували unit-тестами через PublicationPolicy. JSON-формат ArticleDetailsResponse захищається @JsonTest. Пагінацію й query params ми ловили в MVC slice. Репозиторії та міграції — у data slice. Повний контекст залишається для того, що важко побачити інакше: а чи справді все це разом працює?

4. Приклади на ContentHub

Коли критерії звучать абстрактно, їх легко «зрозуміти» і так само легко не застосувати. Тому розберімо кілька типових ситуацій із ContentHub у стилі: що хочемо довести — який рівень беремо — чому. Я спеціально покажу і неправильний вибір, і правильний, щоб ви побачили різницю не як теорію, а як інженерний компроміс.

Почнімо з класичної помилки: тестувати локальну рядкову логіку через повний контекст. Це виглядає солідно, але насправді схоже на спробу чистити зуби пожежним гідрантом: вода, звісно, є… але навіщо стільки?

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

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

@SpringBootTest // Піднімаємо весь Spring-контекст (і платимо часом) — хоча він тут не потрібен
class SlugServiceHeavyTest {

    @Test
    void generatesSlug() {
        // Навіть без @Autowired уже видно: контекст підняли, а сенсу не додалося
        // Перевіряється чиста рядкова логіка, яка не залежить від Spring
        assertThat(new SlugService().toSlug("Hello Spring Boot"))
                .isEqualTo("hello-spring-boot");
    }
}

Цей тест майже гарантовано буде повільнішим, ніж потрібно, і при цьому не дає нової впевненості. Якщо SlugService — звичайний клас без Spring-магії, його перевірка має бути дешевою й швидкою.

import org.junit.jupiter.api.Test;

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

class SlugServiceTest {

    // Явно створюємо об’єкт: це звичайний unit-тест без Spring-контексту
    private final SlugService slugService = new SlugService();

    @Test
    void generatesSlug() {
        // Швидко й зрозуміло: впаде рівно те, що перевіряємо
        assertThat(slugService.toSlug("Hello Spring Boot"))
                .isEqualTo("hello-spring-boot");
    }
}

Тут ідеальний збіг: питання локальне, відповідь — unit-тест. Він швидкий, зрозумілий і легко діагностується.

Тепер інша часта помилка: тестувати repository query через повний контекст. Репозиторії та JPA — це окрема зона ризику, але в неї є свій спеціалізований інструмент — @DataJpaTest. Він дає вам чесну перевірку persistence-шару без підняття web і сервісів.

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

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

@DataJpaTest // Піднімаємо лише JPA-інфраструктуру, а не весь Boot-контекст
class ArticleRepositoryDataJpaTest {

    @Autowired ArticleRepository articleRepository; // Справжній бін репозиторію з persistence-slice

    @Test
    void findsBySlug() {
        // У реальному тесті зазвичай потрібно підготувати дані (persist/save) перед пошуком
        // Тут важлива сама відповідність: ризик у mapping/query, а не в збиранні всього застосунку
        assertThat(articleRepository.findBySlug("spring-boot-testing")).isPresent();
    }
}

Цей тест швидко відповідає на питання «репозиторій знаходить за slug?», і йому не потрібні контролери, сервіси та повний контекст. Якщо ви напишете те саме на @SpringBootTest, ви отримаєте повільніше, складніше і, що важливо, частіше ловитимете падіння, не пов’язані з репозиторієм.

Третя ситуація — контролери. Якщо ви хочете перевірити HTTP-контракт одного контролера, краще підійде @WebMvcTest, тому що він піднімає web-рівень, але не тягне за собою JPA, міграції, репозиторії й половину застосунку.

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;

@WebMvcTest(PublicArticleController.class) // Піднімається лише MVC-шар і вибраний контролер
class PublicArticleControllerWebMvcTest {

    @Test
    void returns200ForListEndpoint() {
        // Тут головне — HTTP-границя (status/headers/body), а не збирання всього застосунку
        // У реальному тесті зазвичай підключають MockMvc/MockMvcTester і мокають залежності контролера
    }
}

У цьому тесті (у реальному проєкті) ви перевірятимете статус, заголовки й JSON-тіло відповіді через MockMvc/MockMvcTester. Але навіть без деталей видно: якщо ризик — в HTTP-границі, ви берете web-slice, а не повний контекст.

Тепер — випадки, коли @SpringBootTest чесно потрібен. Перший — smoke-тест. Він може бути мінімальним і водночас дуже корисним: ловить поломки збирання застосунку, які не видно unit- і slice-тестам. Наприклад, ви випадково видалили потрібний бін із конфігурації або зіпсували зв’язування властивостей у application-test.yml.

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

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) // Перевіряємо лише збирання контексту, без web-сервера
@ActiveProfiles("test") // Явно фіксуємо профіль, щоб тест був відтворюваним
class ContentHubApplicationSmokeTest {

    @Test
    void contextLoads() {
        // Тест навмисно порожній:
        // якщо контекст не підніметься (біна немає / конфігурацію зламано / властивості не зв’язуються) — він упаде сам
    }
}

Зверніть увагу: webEnvironment = SpringBootTest.WebEnvironment.NONE підкреслює, що ми перевіряємо саме контекст, а не web-транспорт. Це чесний і дуже короткий тест.

Другий виправданий випадок — full wiring test. Його сенс у тому, що unit- і slice-тести окремо можуть бути зеленими, але разом реальні біни починають розходитися в неочікуваних місцях. Наприклад, сервіс викликає репозиторій, репозиторій потребує коректної схеми БД, схема потребує міграцій, а міграції — коректних налаштувань datasource.

Покажу ядро сценарію — лише те, що справді доводить «wiring».

import org.junit.jupiter.api.Test;

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

class ArchiveFlowSnippet {

    @Test
    void archive_persistsArchivedStatus() {
        // 1) Підготовка даних: зберігаємо статтю в БД, щоб отримати реальний id
        Long id = articleRepository.save(ArticleBuilder.published().build()).getId();

        // 2) Дія: запускаємо бізнес-операцію через реальний сервіс (перевірка wiring)
        workflowService.archive(id);

        // 3) Перевірка результату: читаємо з БД і перевіряємо підсумковий стан
        ArticleStatus status = articleRepository.findById(id).orElseThrow().getStatus();
        assertThat(status).isEqualTo(ArticleStatus.ARCHIVED);
    }

    // articleRepository і workflowService тут реальні @Autowired у @SpringBootTest-класі
}

Сенс такої перевірки не в тому, що ми «перевірили весь workflow». Ми перевірили один критичний перехід у реальному зв’язку сервіс + репозиторій + база. І це важливо: full wiring test не має перетворюватися на «епопею на 200 рядків», інакше він втрачає читабельність і перетворюється на дорогий квест із налагодження.

Якщо порівняти два підходи — «все через @SpringBootTest» і «@SpringBootTest точково для wiring + решта через unit/slices» — то другий майже завжди перемагає. Він швидший, зрозуміліший і краще пояснює, де саме живе ризик.

5. Швидкість suite і кеш контексту

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

Гарна стратегія виглядає так: базові значення для тестів живуть у application-test.yml, профіль вмикається явно через @ActiveProfiles("test"), а локальні перевизначення використовуються рідко й усвідомлено. Якщо ви до кожного тестового класу додаєте унікальний properties = ..., ви майже гарантовано збільшуєте кількість контекстів, а отже — час прогонів.

Це особливо помітно, коли повний контекст доходить до web-границі: і mocked MVC, і live server використовують той самий дорогий старт застосунку. Якщо ризик не вимагає full context, ви просто розмножуєте повільні тести в іншій упаковці.

У ContentHub ще одна важлива практика — відтворювана підготовка даних. Якщо вам потрібен початковий стан, краще підготувати його передбачувано: через builder/fixture factory або через @Sql. І важливо не плутати це з «почистимо контекст». Часта помилка новачка — хапатися за @DirtiesContext, бо «так простіше». Насправді це як лагодити розетку кувалдою: працює, але ціна надмірна. У більшості сценаріїв дані потрібно відкотити транзакціями, міграціями й чистим setup’ом, а контекст залишати кешованим.

Ще один тверезий момент: @SpringBootTest не має дублювати те, що вже надійно закрито дешевше. Якщо ви вже перевірили PublicationPolicy unit-тестами, не потрібно у full-context тестах знову перебором перевіряти матрицю переходів статусів. У full-context краще перевіряти те, чого unit не бачить: наприклад, що ArticleWorkflowService справді зберігає результат у БД, що потрібні біни реально створюються, що конфігурація не зламана.

Обґрунтування одним реченням

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

Приклад із коротким поясненням у JavaDoc виглядає майже смішно простим, але працює напрочуд добре — як нагадування: не використовуйте важке без причини.

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;

/**
 * Тест повного контексту: доводимо, що в test-профілі узгоджуються сервіс, JPA і міграції.
 * Тобто ризик саме у wiring/конфігурації, а не в локальній бізнес-логіці.
 */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) // Не запускаємо web-сервер: тест саме про контекст
@ActiveProfiles("test") // Фіксуємо профіль, щоб не залежати від середовища
class ArticleWorkflowFullWiringTest {
    // Зазвичай тут буде принаймні один сценарій, який реально торкається сервісу/репозиторію
}

А якщо вам хочеться зробити це більш «тестово», використовуйте @DisplayName на сценарії. Це допомагає не лише вам, а й звітам, і колегам, які читають результати прогонів.

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

@DisplayName("Повне зв’язування: архівація опублікованої статті зберігає статус ARCHIVED") // Коротко пояснюємо, навіщо тут повний контекст
class ArchiveFlowDisplayNameSnippet {

    @Test
    void archive_persistsArchivedStatus() {
        // Тут сценарій короткий, а сенс зрозумілий навіть без деталей реалізації
        // Головне: перевірити підсумковий стан у реальному wiring: сервіс + репозиторій + БД
    }
}

Тут важливо не прагнути до літератури. Достатньо однієї фрази, яка пояснює: чому не можна було взяти unit/slice. Якщо такої фрази немає — це привід переглянути рівень тесту.

6. Типові помилки під час вибору @SpringBootTest

Помилка №1: починати з анотації, а не з ризику.
Коли ви відкриваєте файл тесту й насамперед пишете @SpringBootTest, ви ніби заздалегідь вирішили, що потрібен повний контекст. Але правильний порядок інший: спочатку формулюємо, що хочемо довести, і лише потім обираємо мінімально достатній рівень. В іншому разі ви отримуєте повільний тест без чіткої зони відповідальності.

Помилка №2: перевіряти локальну логіку через повний контекст «заради впевненості».
Перевірка SlugService, PublicationPolicy або будь-якої чистої бізнес-логіки через @SpringBootTest майже завжди означає, що ви платите за те, що вам не потрібно. Впевненість тут хибна: тест не став «правильнішим», він став просто «дорожчим».

Помилка №3: дублювати одну й ту саму поведінку на трьох рівнях без нової користі.
Іноді з’являється спокуса: «нехай буде і unit, і slice, і full-context — тоді точно надійно». На практиці ви отримуєте три тести, які падають одночасно при одній зміні, але не додають упевненості. Хороша стратегія — коли різні рівні закривають різні ризики, а не повторюють один і той самий.

Помилка №4: перетворювати full wiring test на «епопею» з усіх сценаріїв.
Full wiring test має бути про один осмислений потік і одну ключову перевірку підсумкового стану. Якщо ви запихаєте в нього create → submit → approve → publish → archive → ще п’ять перевірок, ви отримуєте складний сценарій, який важко читати й важко лагодити. Краще кілька коротких, але ясних тестів.

Помилка №5: ламати кеш контекстів випадковими перевизначеннями й «налаштуванням під кожен тест».
Коли кожен тестовий клас живе у своєму маленькому світі налаштувань, Spring не може ефективно кешувати контекст, і набір тестів починає гальмувати. Набагато надійніше винести базу в application-test.yml, вмикати @ActiveProfiles("test"), а локальні перевизначення застосовувати лише тоді, коли вони справді змінюють перевірювану гілку поведінки.

1
Опитування
Spring Тести, рівень 19, лекція 4
Недоступний
Spring Тести
Контекст і перевірка запуску
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ