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"), а локальні перевизначення застосовувати лише тоді, коли вони справді змінюють перевірювану гілку поведінки.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ