1. Дерево рішень проти рефлексу @SpringBootTest
Ми вже розклали Boot-тести за межами: JSON, MVC, JPA, outbound client і повний контекст. Але сама карта ще не відповідає на практичне запитання: який шлях обрати для конкретного дефекту, щоб не хапатися за @SpringBootTest за звичкою.
Коли людина вперше бачить тестування Spring Boot, у неї виникає цілком природна думка: «Якщо @SpringBootTest піднімає весь застосунок, то це, мабуть, найнадійніший спосіб. Поставлю його всюди — і матиму щастя». Саме в цей момент тестова стратегія перетворюється на релігію: «вірю в повний контекст», а не «обираю інструмент під ризик».
Проблема не в тому, що @SpringBootTest поганий. Проблема в тому, що він дорогий і ширококутний. Він перевіряє багато зв’язків одразу, але за це ви платите часом запуску, складністю ізоляції причин падіння і схильністю до дублювання. Тести починають «чіпати все», і зрештою стає складно зрозуміти, що саме ви довели: бізнес-правило, JSON-контракт, wiring, поведінку репозиторію, налаштування, фільтри, серіалізацію або просто те, що Земля досі обертається навколо Сонця.
Дерево рішень — це спосіб зупинити цей рефлекс. Воно змушує починати не з анотації, а з формулювання проблеми. А потім, крок за кроком, ставити собі короткі запитання рівня «чи потрібен Spring узагалі?» і «яка межа системи зараз під підозрою?». У результаті ви майже фізично відчуваєте, де вам справді потрібен повний контекст, а де ви випадково платите за нього, як за підписку на “all inclusive”, коли вам потрібна лише вода без газу.
2. Крок 0: формулюємо ризик і шар
Перед тим як обирати анотацію, потрібно навчитися формулювати дефект або ризик у термінах шару. Це звучить абстрактно, але на практиці це дуже конкретна дисципліна: ви перестаєте говорити «щось зламалося» і починаєте говорити «зламалася межа Х, а отже я перевірятиму її такими-то засобами». У тестуванні це майже суперсила.
Гарне формулювання ризику відповідає на три запитання: що бачить клієнт або інший шар, що може піти не так і де це живе. Наприклад, «endpoint GET /api/public/articles/{slug} повертає поле publishedAt у неправильному форматі» — це не «проблема контролера» і не «проблема бази», а проблема JSON-контракту відповіді. А «репозиторій перестав фільтрувати статті за статусом PUBLISHED» — це не проблема HTTP, а проблема межі запитів і збереження. І, нарешті, «під час submit на review стаття переходить у неправильний статус» — це або чиста бізнес-логіка (unit), або наскрізне зв’язування кількох шарів (full context), і тут уже треба уточнювати.
На прикладі ContentHub зручно тримати в голові, що в нас є різні типи «зовнішнього спостерігача». В одному випадку спостерігач — це unit-тест, який дивиться на метод Java-класу. В іншому — «клієнт API», якому важливі JSON і HTTP-семантика. У третьому — база даних як система, що має справді забезпечувати constraints і коректні вибірки. У четвертому — зовнішній HTTP-сервіс модерації, з яким ми спілкуємося через RestClient. Дерево рішень починається саме звідси: спершу ви називаєте спостережувану поведінку, а вже потім обираєте, який «мікроскоп» потрібен, щоб цю поведінку побачити.
3. Крок 1: коли вистачає plain unit-test
Це запитання здається образливим, тому що ви ніби прийшли на курс про тестування Spring Boot, а я пропоную почати з «а може, не треба Spring». Але саме в цьому й полягає доросла інженерна звичка: якщо інструмент дорогий, його не можна вмикати за замовчуванням. Unit-тест — це як велосипед: не найкрутіший транспорт у світі, але на короткій дистанції й у місті він перемагає майже все.
Якщо логіка, яку ви тестуєте, живе у звичайному класі та не залежить від Spring-магії, контейнера, автоконфігурації, серіалізації, транзакцій та інших “платних опцій”, то правильна відповідь найчастіше — unit-test. У ContentHub таких місць багато: правила переходів статусів у PublicationPolicy, генерація slug у SlugService, локальна перевірка вкладень у AttachmentValidationService. Навіть ArticleWorkflowService ми вже тестували на unit-рівні, тому що стабілізували час через Clock і поточного користувача через провайдер, замість того щоб тягнути весь застосунок.
Важливо пам’ятати: запитання «чи потрібен Spring» — це не запитання «чи використовується Spring десь у проєкті». Це запитання «чи потрібен Spring для доведення конкретного твердження». Якщо ви хочете довести, що Spring Boot Testing перетворюється на spring-boot-testing, то Spring вам не допоможе. Він просто сидітиме поруч і споживатиме ресурси, як кіт, що прийшов на кухню «просто подивитися», але раптом з’їв половину сметани.
Невеликий приклад, який нагадує, що unit-тест залишається нормою навіть у Spring Boot-проєкті:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class SlugServiceTest {
@Test
void convertsTitleToSlug() {
// Arrange: створюємо сервіс, який тестуємо, без Spring-контексту.
SlugService service = new SlugService();
// Act + Assert: викликаємо метод і перевіряємо результат.
assertThat(service.toSlug("Spring Boot Testing"))
.isEqualTo("spring-boot-testing");
}
}
Тут немає жодної Spring-анотації — і це прекрасно. Тест дешевий, швидкий, передбачуваний і падає рівно там, де зламалася логіка.
4. Крок 2: якщо Spring потрібен — обираємо межу перевірки
Коли відповідь «без Spring не обійтися» вже пролунала, не потрібно знову розгортати весь список анотацій. Тут важливіше швидко зіставити підозрювану межу з тим, що тест узагалі має довести.
Зламався JSON-контракт DTO → @JsonTest. Він фіксує серіалізацію та десеріалізацію, не тягнучи MVC, security і БД.
Під підозрою HTTP-межа контролера → @WebMvcTest. Він перевіряє mapping, status codes, headers, binding і validation на рівні MVC.
Ризик живе в межі збереження → @DataJpaTest. Він перевіряє JPA-мепінг, constraints, queries, ordering і роботу репозиторіїв.
Проблема у зовнішньому HTTP-клієнті → @RestClientTest. Він фіксує поведінку адаптера і контракт спілкування назовні.
Дефект проявляється лише на стику кількох шарів → @SpringBootTest. Він перевіряє wiring і кілька ключових наскрізних зв’язків, а не локальну механіку одного шару.
Цього короткого набору достатньо, щоб пройти дерево рішень далі. Тут нам важливий не другий атлас анотацій, а швидкий перехід від ризику до мінімально достатнього контексту.
5. Крок 3: slice або full context — про доведення, а не зручність
Коли ви вже знаєте, що Spring потрібен, і пам’ятаєте ціну контексту, дуже легко переплутати мотиви. Найнебезпечніший мотив звучить так: «Мені простіше написати тест у повному контексті, тому що там усе вже є». Так, простіше. Але це «простіше» зазвичай оплачується пізніше: у вигляді повільного прогону, нестабільної діагностики й спокуси почати робити один великий тест замість кількох сильних.
Slice-тест — це вузький контекст, який піднімає лише частину застосунку. Він робить тест швидшим і точнішим за змістом, тому що ви тестуєте саме межу. Full context — це тест «системного складання» застосунку, який корисний як страховка wiring’а і як кілька ключових наскрізних сценаріїв, але шкідливий як універсальний молоток.
Усередині дерева рішень це запитання можна сформулювати чесно: «Зламатися могло всередині одного шару чи лише на стику шарів?» Якщо всередині одного шару, full context не підвищує доказову силу, він лише додає шуму. Якщо ж дефект з’являється тільки тоді, коли шари взаємодіють, slice-тест може виявитися надто вузьким і просто не відтворити проблему.
І ось тут з’являється практична хитрість: якщо ви вагаєтеся, спробуйте почати з вузького тесту, який прямо відповідає підозрюваному шару. Якщо він не ловить проблему або ви не можете коректно висловити перевірку в межах шару, це вже сигнал, що дефект системний, і тоді можна розширюватися до повного контексту. Тобто full context — це «друга спроба», а не перша реакція.
6. Схема дерева рішень і як нею користуватися
Схеми люблять за те, що вони перетворюють «відчуття» на алгоритм. Але важливо пам’ятати: дерево рішень — не закон природи. Це інструмент, щоб не робити дурниць за інерцією. Користуйтеся ним як навігатором: він не заборонить вам з’їхати з маршруту, але принаймні спитає: «Ви впевнені?».
Нижче — проста схема вибору рівня тесту для ContentHub. Вона починається з ризику, а не з анотації. І це ключовий момент: поки ви не можете сформулювати ризик, ви не обираєте тест — ви обираєте «настрій».
flowchart TD
A["Є дефект або ризик. Що саме хочемо довести?"] --> B{"Чи можна довести без Spring?"}
B -->|Так| U["Звичайний unit-test (JUnit 6 + AssertJ + Mockito за потреби)"]
B -->|Ні| C{"Яка межа є предметом перевірки?"}
C --> J["JSON-контракт DTO"] --> JT["@JsonTest"]
C --> W["HTTP-межа контролера (MVC)"] --> WT["@WebMvcTest"]
C --> D["Межа збереження: mapping/constraints/queries"] --> DT["@DataJpaTest"]
C --> R["Вихідний HTTP-клієнт (адаптер RestClient)"] --> RT["@RestClientTest"]
C --> F["Стики шарів / wiring усього застосунку"] --> FT["@SpringBootTest"]
Як користуватися цією схемою на практиці? Дуже просто: ви берете конкретну «поломку», формулюєте її як твердження, яке хочете довести, і проходите дерево. Якщо на першій розвилці ви не можете чесно відповісти, чи потрібен Spring, це зазвичай означає, що ви ще не зрозуміли, що саме тестуєте. І краще витратити 2 хв на уточнення формулювання, ніж 20 хв на запуск непотрібного контексту.
7. Застосовуємо дерево рішень до ContentHub: живі ситуації
Теорія корисна рівно до того моменту, поки не зіткнулася з реальним багом. Тому давайте візьмемо типові ситуації ContentHub і подивимося, як дерево рішень приводить нас до мінімально достатнього тесту. Тут важливо не лише «яка анотація», а й «чому саме ця анотація дає потрібне доведення».
Зламалися правила переходів статусів статті
Уявімо, що хтось випадково дозволив перехід PUBLISHED → IN_REVIEW. У UI це може виглядати як «стаття вже опублікована, але її знову можна відправити на review». Жодних контролерів і БД тут не потрібно, тому що це чисте бізнес-правило. Отже, дерево рішень скаже: Spring не потрібен, беремо unit-test.
Мініскелет тесту тут буде максимально нудним — і це комплімент:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PublicationPolicyTest {
@Test
void doesNotAllowPublishedToReview() {
// Arrange: тестуємо бізнес-правило як звичайний Java-код.
PublicationPolicy policy = new PublicationPolicy();
// Assert: фіксуємо заборону конкретного переходу статусів.
assertThat(policy.canMove(ArticleStatus.PUBLISHED, ArticleStatus.IN_REVIEW))
.isFalse();
}
}
Такий тест ловить дефект швидко і дешево. І, що приємно, він не зламається, якщо ви зміните Spring-конфігурацію або контролери, тому що це взагалі не його робота.
Клієнт перестав розуміти JSON: змінили імʼя поля в DTO
Припустімо, у ArticleDetailsResponse поле authorUsername перейменували на author, бо «так красивіше». З погляду Java-коду все може бути ідеально: обʼєкт будується, контролер повертає, статус 200, сервіс працює. Але зовнішній клієнт раптово отримує інший JSON і падає. Це не проблема web-mapping. Це саме проблема JSON-контракту. Отже, @JsonTest.
Тут важливо: ми не зобов’язані піднімати контролер, щоб перевірити серіалізацію DTO. Нам достатньо JSON-slice.
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.json.JsonTest;
@JsonTest
class ArticleDetailsResponseJsonTest {
@Test
void serializesAuthorUsernameField() {
// Тут буде перевірка наявності/імені поля в JSON (не пишемо реалізацію зараз).
// Ідея: тест падає, якщо контракт DTO змінюється “випадково”.
}
}
Так, тест порожній, але вибір правильний: ми націлили тест на контракт. І якщо контракт змінюється випадково, це місце загориться першим.
Endpoint повертає неправильний статус або не приймає параметр
Тепер інший тип проблеми. Уявімо, що GET /api/public/articles/{slug} раптово почав віддавати 404 навіть для наявної опублікованої статті. Або контролер перестав приймати параметри sort/page. Це web boundary: mapping, path variables, query params, статусна семантика. Тут unit-тест сервісу може бути зеленим, тому що сервіс узагалі не знає, як із URL отримується метод контролера. Data-тест теж не допоможе, тому що БД може бути ідеальною.
Отже, @WebMvcTest.
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
@WebMvcTest(PublicArticleController.class)
class PublicArticleControllerWebMvcTest {
@Test
void returnsOkForExistingSlug() {
// Тут буде перевірка HTTP-поведінки контролера (статус, JSON, параметри).
// Важливо: предмет тесту — контролер як “перекладач”, а не бізнес-логіка сервісу.
}
}
Сенс вибору такий: ми тестуємо «перекладача» між HTTP і сервісом. І це саме те місце, де подібні дефекти живуть.
Репозиторій перестав фільтрувати опубліковані статті
Припустімо, публічний список раптом почав показувати статті DRAFT. Це майже напевно persistence/query issue: або неправильний запит, або неправильна фільтрація в репозиторії, або хибний мепінг статусу. Тестувати це через @WebMvcTest безглуздо: у web-slice репозиторіїв немає. Тестувати це через @SpringBootTest можна, але це дорого і може розмити причину.
Отже, @DataJpaTest.
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
@DataJpaTest
class PublishedArticleQueryDataJpaTest {
@Test
void findsOnlyPublishedArticles() {
// Тут буде перевірка поведінки запиту (які статті потрапили до вибірки).
// Ідея: готуємо дані в БД, виконуємо запит репозиторію, перевіряємо склад результатів.
}
}
Це той випадок, коли data-slice дає максимальну доказову силу для конкретного ризику: ми не перевіряємо HTTP, не перевіряємо серіалізацію, ми перевіряємо, що межа збереження говорить правду.
Клієнт модерації неправильно обробляє відповідь зовнішнього сервісу
Ще одна типова історія: moderation service починає повертати новий формат відповіді або інший статус, а наш RestClientModerationClient не вміє це читати. У результаті submit на review падає, хоча бізнес-логіка і репозиторії в порядку. Це outbound integration boundary: клієнтський адаптер.
Правильний вибір — @RestClientTest.
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
@RestClientTest(RestClientModerationClient.class)
class RestClientModerationClientTest {
@Test
void mapsBlockDecisionFromExternalService() {
// Тут буде перевірка: запит/відповідь/мапінг payload модерації.
// Важливо: ми фіксуємо контракт взаємодії із зовнішнім HTTP-сервісом.
}
}
Чому це мінімально достатньо? Тому що ми не перевіряємо весь workflow, ми перевіряємо конкретний ризик: «адаптер правильно спілкується по HTTP». І це можна довести без підняття всього застосунку.
Баг на стику шарів → @SpringBootTest
І нарешті, найнеприємніший клас дефектів: окремо все виглядає правильно. Unit-тести сервісів зелені, JSON-контракт DTO начебто коректний, репозиторій по-своєму працює. Але щойно запускаєте застосунок, реальний запит падає: чи то через wiring, чи то через конфігурацію, чи то через те, що один шар очікує одне, а інший віддає інше.
Ось тут з’являється підстава для @SpringBootTest. Тому що вам потрібно довести саме зв’язку: controller → service → repository, плюс налаштування та інфраструктура. Такий тест дорогий, але він ловить дефекти «між шарами», які неможливо спіймати локально.
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class ArticlePublicationFlowIntegrationTest {
@Test
void fullContextWiringWorks() {
// Тут буде наскрізна перевірка: контекст збирається, шари звʼязані.
// Зазвичай це місце для кількох ключових happy-path/critical-path сценаріїв.
}
}
І це ключовий момент дерева рішень: full context — це відповідь на конкретний тип ризику, а не «універсальна звичка».
8. Типові помилки під час вибору типу Boot-тесту
Помилка №1: починати з анотації, а не з ризику.
Найчастіша пастка звучить так: «Мені потрібен тест… мабуть, @SpringBootTest». Це схоже на ситуацію «мені треба поїсти… мабуть, замовити все меню». У результаті ви пишете дорогий тест, який незрозуміло що довів, і ще незрозуміліше, чому впав. Рішення просте: спершу формулюйте твердження, яке хочете довести, і лише потім обирайте мінімальний контекст.
Помилка №2: піднімати Spring там, де вистачає unit-тесту.
Це особливо боляче, тому що виглядає «професійно»: тест із Spring-анотацією, контекст, автоконфігурація — краса. Але якщо ви тестуєте SlugService або PublicationPolicy, Spring додає лише ціну, а не цінність. Такий тест буде повільнішим, складнішим у налагодженні і зрештою частіше «не хочеться запускати». А тест, який не хочеться запускати, у критичний момент зазвичай і не запускається.
Помилка №3: обирати надто вузький slice для системного дефекту.
Буває і зворотна крайність: ви підозрюєте, що проблема на стику шарів, але намагаєтеся довести її slice-тестом, де половини потрібних компонентів просто немає. У результаті тест «не відтворює баг», і ви робите хибний висновок, що все гаразд. Правильна дисципліна тут така: якщо дефект проявляється лише у зв’язці, ви тестуєте зв’язку. Slice хороший, коли ризик справді живе всередині одного шару.
Помилка №4: дублювати одну й ту саму перевірку на трьох рівнях без нової доказової сили.
Іноді хочеться «для надійності» перевірити одне й те саме і unit-тестом, і @WebMvcTest, і @SpringBootTest. На практиці це часто перетворюється на потрійну ціну і потрійну крихкість, а не на потрійну впевненість. Набагато корисніше розподіляти перевірки по шарах так, щоб кожен тест доводив щось своє: unit — бізнес-правило, slice — контракт межі, full context — wiring.
Помилка №5: намагатися «скласти» кілька slice-анотацій в один супер-тест.
Інтуїція підказує: «А якщо я поставлю @WebMvcTest і @DataJpaTest разом, то отримаю web+data тест». Але так це не працює: slices спеціально зроблені як взаємовиключні вузькі контексти, а не як конструктор Lego. Якщо вам потрібен повний набір шарів, це вже full context-історія. Якщо вам потрібен лише один шар — обирайте один slice.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ