1. Збирання слайсу контролера та підміна залежностей
@WebMvcTest виглядає як мрія: написали анотацію, отримали MockMvc, надіслали запит — і ніби «API протестовано». Але межа тут уже жорстко визначена: ми тримаємо фокус на зовнішньому HTTP-контракті, а не тягнемо в тест увесь виконуваний простір. Тому реальність швидко повертає нас на землю: контролер — це не одинак, він майже завжди інжектує сервіси, мапери й різні допоміжні залежності. А slice-тест за задумом не підіймає весь контекст застосунку, тому ці залежності раптом опиняються «у відпустці». І ось ваш тест ще навіть не дійшов до mockMvc.perform(...), а вже падає під час старту контексту.
Типовий симптом — довге повідомлення Spring, у якому десь ближче до кінця схована головна причина: «не можу створити TaskController, тому що не знайдено TaskService». І це не баг Spring, а його чесність. У slice-тесті ми хочемо тестувати web-рівень, отже сервісний шар або підключаємо явно, або підміняємо.
У Spring Boot 4 це особливо важливо правильно розуміти: сам @WebMvcTest в актуальній документації показують як спосіб підняти web-слайс навколо контролера, а не «запустити весь застосунок у мініатюрі». Зверніть увагу: у прикладах Spring Boot 4 шлях імпорту анотації виглядає як org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest.
Що підміняємо в Task Tracker API
Коли ви пишете тест web-рівня, ви насправді хочете перевірити «як клієнт бачить API»: коректний шлях, коректний статус, коректні заголовки й коректну JSON-форму відповіді. А ось бізнес-логіка, алгоритми фільтрації та правила переходу між статусами — це вже інший шар. Якщо намагатися запхати все це в один тест, ви отримаєте «тест-всесвіт», де збій може бути через що завгодно, а розбиратися доведеться, мов у детективі рівня «хто вбив збірку Gradle».
Тому в controller slice ми залишаємо справжнім контролер і все, що стосується MVC-пайплайна (прив’язування, перетворення повідомлень, валідація), а сервісну залежність робимо керованою: ми заздалегідь кажемо, що вона поверне або який виняток кине. Так ми можемо сфокусуватися на поведінці web-рівня, а не на тому, як у репозиторії в пам’яті сортуються елементи.
Зручно тримати це в голові як просту схему (контролер — реальний, сервіс — «пульт керування сценарієм»):
flowchart LR T["Тест (JUnit)"] -->|Запит MockMvc| MVC["MVC-слайс"] MVC --> C["TaskController (реальний)"] C --> S["TaskService (мок за допомогою @MockitoBean)"] S -->|налаштування: повернення/виняток| C C -->|HTTP-відповідь| T
У контексті Task Tracker API це означає, що в тесті TaskController ми зазвичай підміняємо TaskService. Аналогічно для CommentController — CommentService, для AttachmentController — AttachmentService. Справжні репозиторії в пам’яті, файлове сховище і початкові дані в ці тести не підтягуємо: інакше це вже не slice, а мініінтеграційний тест, який повільніший, шумніший і складніший.
2. @MockitoBean для підміни бінів
Раніше багато хто звик до @MockBean, але на сучасній Spring-лінії основний інструмент для «підмінити бін на мок» — це @MockitoBean. Сама ідея максимально прямолінійна: ви оголошуєте поле в тесті, Spring створює Mockito-мок, кладе його в ApplicationContext і, якщо є відповідний бін, замінює реальну залежність на мок. Це буквально описано в Javadoc: @MockitoBean використовують, щоб перевизначити бін у тестовому ApplicationContext Mockito-моком.
Важливо і те, як Spring розуміє, який бін потрібно підмінити. Коли @MockitoBean стоїть на полі, тип біна виводиться з типу поля. Якщо в контексті кілька кандидатів одного типу, можна підказати через @Qualifier, а якщо @Qualifier немає — Spring намагається використати ім’я поля як запасний кваліфікатор.
Ще одна деталь, яку рідко обговорюють на початку, але вона економить час: @MockitoBean може не лише замінювати наявний бін, а й створити мок як новий бін, якщо відповідного ще немає. А якщо ви хочете, щоб тест падав, коли бін відсутній, тобто ви очікували override, а отримали «тихо створений новий мок», в анотації є прапорець enforceOverride=true.
Мінімальний каркас тесту в нашому стилі виглядає так (зверніть увагу: це шлях імпорту Boot 4 для @WebMvcTest і шлях Spring Test для @MockitoBean):
// У Spring Boot 4 використовуємо цей імпорт для web-slice-тестів
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
// Анотація для підміни біна Mockito-моком у тестовому контексті
import org.springframework.test.context.bean.override.mockito.MockitoBean;
// Підіймаємо лише MVC-слайс навколо вказаного контролера, без усього застосунку
@WebMvcTest(TaskController.class)
class TaskControllerWebMvcTest {
// Підміняємо сервісний бін моком: контролер залишиться справжнім, а сервіс — керованим
@MockitoBean
TaskService taskService;
}
Щойно ви додали @MockitoBean для всіх обов’язкових залежностей контролера, тестовий контекст починає стабільно підніматися. Це той самий момент, коли ви вперше відчуваєте, що Spring-тестування — не магія, а механіка: немає біна — контекст не стартує; підмінили бін — контекст стартує.
3. Налаштування моків: return і throw
Сам мок за замовчуванням поводиться дуже нудно: якщо не налаштувати поведінку, він повертатиме null, 0, false та інші варіанти на кшталт «я нічого не знаю, я просто мок». Це часто призводить до дивного ефекту: тест ніби «про контролер», а падає NullPointerException всередині контролера, тому що сервіс повернув null. І ось ви вже тестуєте не API-контракт, а стійкість до null, хоча такого сценарію не планували.
Налаштування поведінки мока — це коли ви кажете: «коли викличуть такий-то метод із такими-то аргументами, поверни ось це» або «кинь ось цей виняток». Саме це й робить slice-тест детермінованим: ми заздалегідь знаємо, у якому світі існує контролер під час тесту.
Найпростіше налаштування — thenReturn():
import static org.mockito.Mockito.when;
// Налаштовуємо мок: для конкретного id сервіс поверне підготовлений DTO,
// щоб контролер міг сформувати коректну HTTP-відповідь
when(taskService.getTaskById("task-1"))
.thenReturn(new TaskDetailsResponse("task-1", "Write tests"));
Тут є важлива методична думка: ми не намагаємося «симулювати справжню бізнес-логіку», ми просто задаємо контролеру вхідні дані від сервісу. Якщо ваш контролер далі мапить, формує Location, додає заголовки — це вже його зона відповідальності.
Другий класичний випадок — коли сервіс сигналізує про проблему винятком. Наприклад, «задачу не знайдено»:
// Налаштовуємо мок на негативний сценарій: сервіс кидає доменний виняток,
// а далі ми перевіряємо, як web-рівень (контролер/handler) його обробить
when(taskService.getTaskById("missing-task"))
.thenThrow(new TaskNotFoundException("missing-task"));
Зверніть увагу: на рівні цієї лекції нас цікавить не те, як TaskNotFoundException перетворюється на ProblemDetail (це в наступній лекції про негативний сценарій), а те, що ми вміємо підготувати такий сценарій для контролера.
Нарешті, окрема категорія — void-методи. Наприклад, delete-сценарій часто виглядає як taskService.deleteTask(id) і повертає void. Для void-методів частіше використовують doThrow() або doNothing().
import static org.mockito.Mockito.doThrow;
// Для void-методів використовуємо doThrow(...).when(mock).method(...):
// так ми моделюємо ситуацію, коли видаляти нічого (умовно: "не знайдено")
doThrow(new TaskNotFoundException("task-404"))
.when(taskService)
.deleteTask("task-404");
Ці три прийоми (thenReturn, thenThrow, doThrow) закривають більшу частину практичних сценаріїв controller slice: успішний сценарій, not found, conflict, заборона операції і так далі. Решта магії Mockito існує, але на рівні курсу нам зараз не потрібна: не перетворюємо фінальний рівень на окремий семестр із тестування.
4. Тіло запиту: JSON і ObjectMapper
Коли ви тестуєте контролер, вам майже завжди потрібно надіслати body запиту. І ось тут у новачків є два режими: або «я зліплю JSON рядком», або «я не хочу збирати JSON вручну, я ж не компілятор». Обидва режими мають право на життя, але другий зазвичай приносить менше болю в довгостроковій перспективі.
Рядок корисний, коли JSON маленький і ви тестуєте щось дуже точкове: наприклад, некоректний JSON або відсутність обов’язкового поля. Але щойно DTO стає трохи складнішим, ручний рядок перетворюється на мінігру «вгадай лапку», і тест починає падати не тому, що API зламано, а тому, що ви забули кому.
Тому в controller slice зручно використовувати ObjectMapper, який Spring Boot піднімає в тестовому контексті. Це дає одразу два бонуси: по-перше, JSON виходить коректним; по-друге, він серіалізується тим самим мапером, який використовує застосунок (модулі, налаштування дат тощо), і ви не роз’їжджаєтесь між «як тест придумав JSON» і «як застосунок реально чекає JSON».
Мінікаркас із інжектом ObjectMapper виглядає так:
// Беремо ObjectMapper із тестового контексту: це той самий мапер, що використовує застосунок
@Autowired
ObjectMapper objectMapper;
І далі ви можете зібрати body зі справжнього request DTO:
// Збираємо DTO запиту так само, як це робить реальний клієнт (логічно)
TaskCreateRequest req = new TaskCreateRequest("Write tests", "Check web layer");
// Довіряємо серіалізацію Jacksonʼу, а не збираємо JSON рядком вручну
String body = objectMapper.writeValueAsString(req);
Якщо ви робите POST, не забувайте про Content-Type — інакше ви тестуєте не створення задачі, а реакцію сервера на «не зрозуміло що надіслали»:
mockMvc.perform(post("/api/v1/tasks")
// Явно говоримо, що надсилаємо JSON: без цього контролер може не прийняти тіло як слід
.contentType(MediaType.APPLICATION_JSON)
// Передаємо серіалізоване тіло запиту
.content(body));
Поки що не перевіряємо .andExpect(...) — це наступна лекція, де ми почнемо прямо фіксувати контракт. Тут нам важливо, що ми вміємо: (1) зробити JSON коректно, (2) надіслати його в контролер як реальний клієнт, (3) контролювати, що робить сервіс.
5. Підготовка тестів: константи і @BeforeEach
Коли тестів стає більше трьох, з’являється спокуса зробити один величезний @BeforeEach, де ви налаштовуєте все одразу «про всяк випадок». Це майже завжди закінчується однаково: ви відкриваєте тест, а там сценарій незрозуміло де задається, тому що він захований у сетапі. Це особливо болісно в web-layer тестах, тому що нам важливо бачити поруч: URI, тіло запиту й очікувану поведінку.
Хороше правило для цього курсу звучить нудно, зате працює: намагайтеся, щоб налаштування мока жили або прямо в тесті, або в дуже маленьких helper-методах, які не приховують сенс. Наприклад, винести ідентифікатори й часті рядки в константи — нормально, а ось «згенерувати величезний світ» у @BeforeEach — уже ризик.
Приклад простих констант:
// Часті значення виносимо в константи, щоб не плодити копіпасту й описки в тестах
private static final String TASK_ID = "task-1";
private static final String API_TASKS = "/api/v1/tasks";
Приклад маленької фікстури (без спроби побудувати весь Task Tracker в одному об’єкті):
// Маленька фікстура: мінімум даних, рівно під сценарій тесту
private TaskDetailsResponse taskDetails() {
return new TaskDetailsResponse(TASK_ID, "Write tests");
}
І вже в тесті ви робите налаштування мока явно, читабельно й поруч зі сценарієм:
// Налаштовуємо мок прямо біля тесту: так сценарій не ховається в "магічному" сетапі
when(taskService.getTaskById(TASK_ID))
.thenReturn(taskDetails());
Такий стиль особливо дружній до вашого майбутнього «я». Тому що майбутній ви — це той, хто відкриває тест через два місяці й щиро сподівається, що там немає «психологічного квесту». Якщо ви пишете тести зараз і розумієте, що «без дебагера я не можу збагнути, що відбувається», значить час зробити код простішим.
6. Розширення slice: @Import і сканування
Іноді ви чесно підмінили сервіс моками, але контекст усе одно не піднімається або поводиться дивно. Винуватий тут не ваш характер і не ретроградний Меркурій, а правила slice-тестів: вони працюють через обмеження component scanning. У документації Spring Boot це сформульовано прямим текстом: slice-тести обмежують сканування компонентів «обмеженим набором компонентів за їхнім типом», а біни, які не створюються через component scanning (наприклад, створюються через @Bean у @Configuration), slice-тест може не включити в контекст автоматично.
Саме тому іноді потрібно явно додати в slice потрібний бін через @Import. Для нашого проєкту це найчастіше трапляється не зі службами (їх ми мокимо), а з «прикордонними» компонентами API-шару: наприклад, мапер, який оформлено як @Component, або конкретний @ControllerAdvice, якщо ви тестуєте слайс максимально ізольовано і хочете явно вказати, які advice мають бути в контексті.
Приклад — підключити мапер як справжній бін:
import org.springframework.context.annotation.Import;
// Явно підключаємо бін, який нам потрібен у MVC-слайсі (наприклад, мапер DTO),
// якщо slice за замовчуванням його не підтягнув
@WebMvcTest(TaskController.class)
@Import(TaskMapper.class)
class TaskControllerWebMvcTest {
}
Сенс тут простий: сервіс ми замінюємо моками, а ось мапінг DTO та інші «API-внутрішності», які впливають на форму відповіді, іноді корисно тримати справжніми — щоб тест реально захищав контракт.
І останнє, але дуже практичне: якщо у вас у контексті кілька бінів одного інтерфейсу (наприклад, два різні сервіси або різні реалізації), @MockitoBean може вимагати уточнення. У Spring Framework прямо описано, що за множинних кандидатів можна використовувати @Qualifier, а якщо його немає — запасним варіантом може стати ім’я поля. Це не частий кейс у навчальному проєкті, але знати про нього корисно: коли одного разу побачите помилку «кілька кандидатів», ви хоча б не думатимете, що Spring намагається сказати вам щось давньоельфійською.
7. Типові помилки під час збирання слайсу контролера
Перехід до тестів часто ламається не на «розумних» речах, а на простих: контекст не піднявся, мок не підставився, JSON не той. Це нормально. Важливо не вмикати героя і не лікувати симптом «давайте замінимо @WebMvcTest на @SpringBootTest — і воно запрацює». Запрацює, але ви втратите сенс slice-підходу та швидкість тестів.
Помилка №1: забули підмінити обов’язкову залежність контролера.
Якщо контролер інжектує TaskService, а ви не оголосили @MockitoBean TaskService, то @WebMvcTest чесно скаже: «не можу створити контролер». Це не «Spring зламався», це тест чесно показав, що MVC-слайс не знає, де взяти сервіс.
Помилка №2: мок є, але налаштування забули — і сервіс повертає null.
Мок сам по собі не «симулює бізнес-логіку». Якщо не налаштувати when(...).thenReturn(...), ви дуже легко отримаєте NullPointerException всередині контролера. Після цього багато хто починає додавати захист від null у контролер — і ось так випадково тест змушує вас погіршити архітектуру. Правильніше просто налаштувати поведінку мока під конкретний сценарій.
Помилка №3: налаштування зроблено «на все одразу» у величезному @BeforeEach, і тест став нечитабельним.
Так, спільний сетап виглядає як економія часу. На практиці це перетворюється на ситуацію, де ви читаєте тест, а сенс сценарію схований десь угорі файла в 40 рядках конфігурації. Web-layer тести мають бути «як специфікація контракту», тому налаштування краще тримати поруч із конкретним тестом.
Помилка №4: JSON збирають вручну, а потім тест «лагодять» лапки.
Це класична пастка. Ви думаєте, що тестуєте API, але насправді тестуєте свою здатність бути JSON-валідатором. Якщо у вас уже є ObjectMapper у контексті — використовуйте його, і нехай комп’ютер робить те, у чому він традиційно хороший: серіалізацію.
Помилка №5: очікують, що @WebMvcTest побачить усі @Configuration і @Bean, як у реальному застосунку.
Slice-тести спеціально обмежують сканування і набір бінів. Spring Boot окремо підкреслює, що такі біни можуть не потрапити в контекст, і тоді їх потрібно імпортувати явно. Це не недолік, а «ціна швидкості»: slice працює швидко саме тому, що не підіймає зайве.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ