JavaRush /Курси /Spring REST & MVC /Збирання слайсу контролера

Збирання слайсу контролера

Spring REST & MVC
Рівень 30 , Лекція 1
Відкрита

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. Аналогічно для CommentControllerCommentService, для AttachmentControllerAttachmentService. Справжні репозиторії в пам’яті, файлове сховище і початкові дані в ці тести не підтягуємо: інакше це вже не 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 працює швидко саме тому, що не підіймає зайве.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ