1. Вступ
spring-boot-starter-test уже дав нам спільний базовий набір інструментів. Але сам по собі він не відповідає на головне питання: коли тесту взагалі потрібен Spring-контекст, а коли він лише ускладнює перевірку й робить її менш зрозумілою.
Коли ви тільки починаєте тестувати застосунок Spring Boot, є класична пастка: здається, що якщо застосунок «на Spring», то й тести мають бути «на Spring». У цей момент рука тягнеться до великої червоної кнопки @SpringBootTest — а мозок тихо шепоче: «Запущу все, і точно буде надійно». Звучить логічно, як «якщо вагаєтесь — беріть найбільший молоток». Але в тестах це не завжди стратегія. Іноді це просто спосіб витратити час і отримати розмиту діагностику.
Згадайте, що ми робили в блоці unit-тестів. Ми перевіряли правило переходів статусів статті або генерацію slug — і нам узагалі не потрібен був Spring. Жодного контейнера, жодних профілів, жодного YAML. Ми просто створювали об’єкт і викликали метод. Саме з цього й починається головне питання: чи потрібна нам у цьому тесті інфраструктура Spring, чи ми перевіряємо локальну логіку, яка чудово живе як звичайний Java-клас.
Однак є речі, які без Spring перевірити важко або безглуздо. Наприклад, коректність зв’язування — чи правильно зібралися залежності, коректність MVC-межі — контролери, серіалізація, обробка помилок, коректність рівня repository — JPA-мапінг, запити, коректність інтеграційного адаптера — як ми ходимо назовні по HTTP через RestClient. Для цього в Spring є тестові режими, і важливо розуміти два основні поняття з сьогоднішньої лекції: повний контекст і test slice.
2. Мінісловник: контейнер і контекст
Якщо слова «контейнер», «біни» й «контекст» поки що звучать як заклинання з книжки про магію, це нормально. Spring справді любить звучати так, ніби він розумніший за вас, а ви просто натискаєте кнопки. На практиці все простіше: Spring — це фабрика та координатор об’єктів, просто дуже потужний.
Усередині звичайного застосунку в нас є багато об’єктів: контролери, сервіси, репозиторії, конфігурації, адаптери для файлової системи та зовнішніх HTTP-сервісів. У простому Java-застосунку ви самі створюєте ці об’єкти через new і вручну передаєте залежності. У застосунку Spring Boot ми частіше хочемо, щоб це робила інфраструктура: щоб один об’єкт отримав інший через конструктор, щоб налаштування прийшли з application.yml, щоб потрібні біни з’явилися автоматично.
Ось тут і з’являється Spring-контейнер. Це «середовище», яке знає, які об’єкти треба створити, в якому порядку й як їх зв’язати. Об’єкт, яким керує контейнер, називається bean (бін). А ApplicationContext — це програмний інтерфейс до цього контейнера: через нього можна отримати біни, перевірити, що вони існують, і взагалі «помацати» контейнер руками.
Щоб було відчутніше, ось маленький приклад: один і той самий клас може бути просто класом або стати Spring-біном, якщо ми позначимо його стереотипною анотацією.
import org.springframework.stereotype.Service;
@Service // Кажемо Spring: "створи цей клас як бін і керуй його життєвим циклом"
public class SlugService {
public String toSlug(String title) {
// Дуже спрощена логіка: для реального виробничого середовища зазвичай потрібен нормальний slugify
return title.trim().toLowerCase().replace(" ", "-");
}
}
Якщо SlugService живе в потрібному пакеті, який потрапляє до сканування компонентів, Spring створить його як бін. Далі інший бін може залежати від нього через конструктор, і Spring «впустить» залежність.
І ось важливий момент для тестів: щойно тест запускає Spring-контекст, він отримує ApplicationContext і живе за правилами контейнера. Це не добре й не погано — це просто інший режим реальності. В одному режимі ми перевіряємо методи напряму й тримаємо тест швидким. В іншому режимі ми перевіряємо, що контекст збирається, біни створюються, конфігурація працює, а межі застосунку поводяться правильно.
Щоб візуально закріпити цю модель, уявіть, дуже спрощено, «коробку» ApplicationContext:
flowchart TB
subgraph C["ApplicationContext (контейнер Spring)"]
Controller["Біни контролерів"]
Service["Біни сервісів"]
Repo["Біни репозиторіїв"]
Config["@Configuration / біни властивостей"]
Infra["Біни інтеграції / сховища / HTTP-клієнта"]
end
Питання цієї лекції: коли нам потрібна вся коробка цілком, а коли ми хочемо дістати лише одну «поличку»?
3. Full context: повний запуск у тесті
Повний контекст — це ситуація, коли в тесті піднімається весь Spring Boot застосунок, майже так само, якби ви натиснули bootRun, тільки в тестовому режимі. Це означає, що Spring Boot застосовуватиме auto-configuration, читатиме налаштування, створюватиме ваші beans — контролери, сервіси, репозиторії, — налаштовуватиме інфраструктуру й збиратиме весь ApplicationContext.
Найчастіше цей режим вмикається анотацією @SpringBootTest. І психологічно важливо не плутати два різні сенси фрази «тест піднімає застосунок». У звичайному мовленні здається, що якщо застосунок «піднявся», то він уже «працює». Насправді тест, який «підняв контекст», довів лише те, що контекст зміг зібратися (і, можливо, що деякі біни доступні). Це корисно, але не замінює перевірки поведінки.
Повний контекст доречний тоді, коли предмет перевірки — зв’язка кількох шарів. Наприклад, ви хочете побачити, що контролер правильно викликає сервіс, сервіс правильно ходить у репозиторій, а все разом працює з тими бінами, які реально створює застосунок. Це вже не unit-тест і не slice-тест; це перевірка зв’язування та інтеграції всередині застосунку.
Мініскетч тесту, який «помацає» повний контекст, може виглядати так:
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest // Піднімаємо майже весь контекст застосунку (auto-config + ваші біни)
class ContentHubApplicationSmokeTest {
@Autowired ApplicationContext context; // Дістаємо "коробку" Spring, щоб перевірити наявність бінів
@Test
void loadsSlugServiceBean() {
// Smoke-перевірка: бін існує, і контекст зміг його зібрати
assertThat(context.getBean(SlugService.class)).isNotNull();
}
}
Так, це тест майже «ні про що» — він не перевіряє бізнес-логіку, не надсилає запити й не переконується в коректності процесу. Але він чесно відповідає на питання: «Мій застосунок взагалі збирається як система бінів? Чи існують ключові компоненти?» На ранній стадії це буває корисно: іноді ви додаєте конфігурацію, і застосунок перестає стартувати через помилку в налаштуваннях. Повний контекст ловить такі проблеми.
Важливо розуміти межі full context. Він не робить ваші тести автоматично «сильними». Він робить їх автоматично дорогими — за часом запуску й за складністю діагностики, — тому що тепер у гру вступає все: конфігурація, інфраструктура, зв’язування, auto-configuration. І якщо тест падає, вам потрібно зрозуміти, чи впав він через логіку, чи через конфігурацію, чи через відсутню залежність, чи через профіль… У unit-тесті все простіше: впало — значить, правило зламалося. У full context тесті причин більше. Це нормально, але це ціна.
4. Test slice: вузький контекст для шару
Слово «slice» у тестуванні Spring Boot — це буквально «відрізати шматок застосунку» й підняти лише той шматок, який потрібен, щоб перевірити конкретну межу. Важливо: slice — це не «урізаний full context на удачу». Це свідомо зібраний мінімальний контекст під конкретний шар. Саме тому він і дешевший, і швидший, і діагностується простіше.
Уявіть ContentHub як будинок. Full context — це коли ви ввімкнули світло, воду, газ, інтернет, запустили опалення й запросили всіх мешканців. Test slice — це коли вам потрібно перевірити лише розетку на кухні, і ви не викликаєте пожежників, оркестр і нотаріуса, щоб це зробити. І так, у реальному житті здається, що «ну будинок же один, яка різниця». Але в тестах різниця перетворюється на десятки секунд, сотні секунд і іноді на «чому в мене тести запускаються 8 хвилин».
Ключова думка: slice-тест теж піднімає Spring, просто в меншому обсязі. Там теж є ApplicationContext, теж буде частина auto-configuration, просто її набір сильно обмежений. Slice зазвичай фокусується на одній межі: web-шар, JSON-контракт, data-шар, HTTP-клієнт тощо. Зараз нам важлива сама ідея: slice — це Spring-контекст, але «вузький».
Прикладом web slice є @WebMvcTest. Він потрібен, коли ви тестуєте controller-layer і HTTP-межу — статуси, заголовки, серіалізацію DTO, обробку помилок на рівні MVC, — але не хочете піднімати сервіси, репозиторії та весь інший світ.
Ось мінімальний тест-скелет, який показує ідею web slice:
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
@WebMvcTest(PublicArticleController.class) // Піднімаємо web-шар (MVC), а не весь контекст застосунку
class PublicArticleControllerWebMvcTest {
@Test
void sliceStarts() {
// Якщо контекст піднявся — значить web-шар зібрався.
// Важливо: залежності контролера (наприклад, сервіси) зазвичай доведеться підміняти в тесті.
}
}
Такий тест виглядає надто простим, але він ілюструє центральний сенс: ми запускаємо лише web-частину, а не весь застосунок. Це особливо корисно, коли ви хочете швидко перевірити, що контролери взагалі піднімаються, що налаштування серіалізації не зламалися, що @ControllerAdvice (якщо він підключений) працює в цьому режимі тощо.
Чесне попередження, щоб не було відчуття «мене обманули»: щойно ви почнете писати реальні web slice-тести, ви побачите, що контролер майже завжди залежить від якогось сервісу. У slice-режимі сервісів може не бути — і вам потрібно буде підмінити залежності. Це окрема навичка. Зараз важливе інше: slice-тест за визначенням не зобов’язаний піднімати всі біни застосунку. Він має піднімати рівно те, що потрібно шару.
5. Unit vs slice vs full context: що доводимо
Якщо дивитися на тести як на «судові докази» — і так, у цьому курсі ми трохи граємо в адвокатів якості, — то різні рівні тестів доводять різні речі. Іноді початківці намагаються порівняти тести за шкалою «який надійніший». Насправді корисніше порівнювати за шкалою «який ризик він ловить» і «яку зону відповідальності покриває». Інакше ми будемо писати дорогі тести, які доводять не те, що треба.
Зведімо картину в одну таблицю. Вона не про те, що «краще», а про те, що «про що».
| Рівень тесту | Що запускаємо | Що доводимо | Чого принципово не доводимо | Приклад із ContentHub |
|---|---|---|---|---|
| Звичайний unit-тест | Лише Java-об’єкти через new | Локальні бізнес-правила й алгоритми | Зв’язування Spring, конфігурацію, MVC, JPA | PublicationPolicyTest, SlugServiceTest |
| Test slice | Вузький ApplicationContext для одного шару | Поведінку однієї межі (web/JSON/data/клієнт) у Spring-режимі | Наскрізний сценарій «через усі шари» | @WebMvcTest для публічного контролера, @DataJpaTest для репозиторію |
| Full context | Майже весь застосунок як система бінів | Зв’язування та спільну роботу кількох частин | Що «все працює» в усіх сценаріях (один тест не охопить усе життя) | @SpringBootTest для ключового наскрізного флоу |
Зверніть увагу на формулювання. Unit-тест «доводить бізнес-правило». Slice-тест «доводить межу шару». Full context-тест «доводить, що система як набір бінів зібралася й частини разом не конфліктують». Це три різні твердження. Вони не конкурують; вони доповнюють одне одного.
6. Один сценарій — три рівні тестів
Щоб не залишатися в абстракції, візьмімо один «людський» сценарій ContentHub. Припустімо, редактор створив чернетку й відправляє статтю на ревʼю. Усередині застосунку це зачіпає купу речей: правила статусів, виклик сервісу, можливу валідацію, можливо, взаємодію з модераційним клієнтом, оновлення дати submittedAt тощо. Дуже легко захотіти «одним тестом перевірити все». Але майже завжди вигідніше розкласти ризик на компоненти й перевіряти його правильним рівнем.
Якщо нас цікавить бізнес-правило «чи можна переводити DRAFT у IN_REVIEW», найчесніший і найдешевший тест — unit. Він не знає нічого про Spring, базу даних і контролери. Він просто перевіряє правило в лоб.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PublicationPolicyTest {
@Test
void allowsDraftToMoveToReview() {
// Unit-тест: створюємо об'єкт напряму, без Spring-контексту
PublicationPolicy policy = new PublicationPolicy();
// Перевіряємо конкретне бізнес-правило
assertThat(policy.canMove(ArticleStatus.DRAFT, ArticleStatus.IN_REVIEW)).isTrue();
}
}
Тепер уявіть інший дефект. Припустімо, хтось змінив JSON-формат поля статусу статті або випадково зламав серіалізацію дати. Бізнес-правило при цьому може бути ідеально правильним. Unit-тести будуть зеленими. Але клієнт API отримає інший формат відповіді й почне падати. Це не проблема правила, це проблема межі — контракту. Тут проситься slice-тест, тому що ми хочемо перевірити шар, який відповідає за HTTP/JSON, а не піднімати всю систему заради одного поля.
І, нарешті, третій тип ризику: зв’язування. Наприклад, ви додали новий бін, змінили конфігурацію, додали умовне налаштування, і раптом зв’язка controller → service → repository перестала збиратися. У unit-тестах ви все ще створюєте new ArticleWorkflowService(...) і моки, і все чудово. А застосунок у реальності може не стартувати або падає на старті контексту. Ось це якраз зона full context: тест має підняти застосунок як Spring-систему й показати, що він збирається.
Сенс тут не в тому, щоб перелічити всі можливі тести на сценарій, а в тому, щоб відчути: один і той самий «бізнес-сюжет» розпадається на кілька різних ризиків. І різні рівні тестів дають різні види впевненості. Якщо ви почнете перевіряти все через full context, ви отримаєте впевненість, що застосунок як система стартує, але будете повільно отримувати зворотний зв’язок про локальні правила. Якщо ви почнете перевіряти все лише unit-тестами, ви отримаєте впевненість у локальній логіці, але можете пропустити те, що API-контракт або зв’язування зламалися.
7. Типові помилки вибору рівня
Помилка № 1: повний контекст за звичкою.
Найчастіша помилка тут — брати повний контекст за звичкою, особливо якщо ви вже вмієте запускати застосунок і вам психологічно комфортно «увімкнути все». Але повне завантаження контексту не робить тест автоматично кориснішим: воно просто розширює зону можливих причин падіння й збільшує час запуску.
Помилка № 2: очікувати від slice-тесту доказів, які він не зобов’язаний давати.
Наприклад, @WebMvcTest не має перевіряти роботу репозиторію й не зобов’язаний ходити в базу даних.
Помилка № 3: використовувати slice як «інтеграційний тест усього».
Якщо ви спробуєте використати web slice як «інтеграційний тест усього», ви або впертеся в необхідність тягнути залежності шарами вгору, або отримаєте крихкий тестовий контекст, який схожий на full context, але менш зрозумілий. Це зазвичай закінчується тим, що тести починають падати «з дивних причин», а розробник звинувачує Spring, долю й ретроградний Меркурій.
Помилка № 4: дублювати одне й те саме твердження на кількох рівнях «просто про всяк випадок».
Наприклад, ви вже перевірили PublicationPolicy unit-тестами, а потім у full context-сценарії починаєте стверджувати всі ті самі переходи статусів покроково й дублювати матрицю правил. У результаті тести стають довшими, дорожчими, і падіння одного правила приводить до лавини червоних тестів на всіх рівнях. У хорошому наборі тестів кожен рівень має мати власний сенс і не підміняти інші.
Помилка № 5: думати анотаціями замість ризиків.
Тобто починати з питання «яку анотацію поставити», а не «який ризик я ловлю». Анотація — це лише механіка запуску потрібного середовища. Спочатку ви формулюєте, що хочете довести, і лише потім обираєте, який тип тесту — і який обсяг контексту — для цього потрібен. Інакше легко отримати тести, які запускають красиву інфраструктуру, але перевіряють випадкові дрібниці — приблизно як купити професійну кавомашину, щоб кип’ятити в ній воду для локшини.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ