1. Брак залежностей у @WebMvcTest
Коли контролер уже вибрано, а slice не розповзся на весь API, постає цілком приземлене запитання: чим закрити його залежності.
Коли ви вперше запускаєте @WebMvcTest, він часто зустрічає вас не зеленим тестом, а дуже чемною помилкою під час старту контексту. І це насправді добра новина: Spring прямо каже: «Я спробував створити контролер як справжній bean, але ви не дали мені того, що йому потрібно». У звичайному застосунку залежності контролера підтягуються з ApplicationContext, а в slice‑тесті цей контекст навмисно урізаний.
Уявіть, що @WebMvcTest — це перевірка роботи «пункту видачі замовлень» (контролера) без складів і виробничої частини (репозиторіїв і БД). Пункт видачі має вміти прийняти запит, запитати «у системи» потрібні дані та коректно віддати відповідь клієнту. Але в тесті «система» (service‑layer) не піднята — і це нормально: зараз ми не доводимо бізнес-правила, ми перевіряємо HTTP-границю.
Наочно границя виглядає приблизно так:
flowchart TD
R[HTTP-запит] --> MVC[Інфраструктура Spring MVC]
MVC --> C[PublicArticleController]
C --> S["PublicArticleService підміняємо в тесті"]
S -.-> DB["БД / репозиторії у slice не піднімаються"]
Контролер майже завжди пишуть через constructor injection (і в нашому проєкті це правило зафіксовано). Тому, якщо в контексті немає біну PublicArticleService, Spring не зможе створити PublicArticleController, і тест не запуститься. Для slice‑тесту це очікувано: ми маємо явно закрити залежності контролера — зазвичай через mock, іноді через spy, іноді через імпорт невеликого допоміжного компонента.
Мініприклад «як виглядає залежність у контролера»:
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/public/articles")
class PublicArticleController {
// Залежність контролера: у реальному застосунку вона прийде з ApplicationContext,
// а в @WebMvcTest її потрібно підставити явно (зазвичай через @MockitoBean).
private final PublicArticleService publicArticleService;
// Constructor injection: якщо біну немає — Spring не зможе створити контролер,
// і slice-тест упаде під час старту контексту.
PublicArticleController(PublicArticleService publicArticleService) {
this.publicArticleService = publicArticleService;
}
}
Тут контролер чесно каже: «Мені потрібен PublicArticleService». У @WebMvcTest цього біну, найімовірніше, не буде. І саме тут на сцену виходить @MockitoBean.
2. @MockitoBean: Mockito‑mock, який бачить Spring
На рівні чистого Mockito ми вже вміємо писати @Mock і отримувати гарний «гумовий об’єкт», який робить тільки те, про що ви його заздалегідь попросили. Але в @WebMvcTest недостатньо мати mock як звичайне поле тестового класу — контролер створює Spring, і залежності йому також підставляє Spring. Отже, нам потрібен mock, зареєстрований всередині ApplicationContext як bean.
@MockitoBean робить саме це: створює Mockito‑mock і реєструє його як Spring bean (та/або замінює існуючий bean такого типу в контексті тесту). У результаті конструктор контролера отримує не справжній сервіс, а наш керований mock.
У цій лінійці курсу і @MockitoBean, і @MockitoSpyBean ми беремо з spring-test — org.springframework.test.context.bean.override.mockito.... Це і є наш канонічний шлях для сучасного стека Boot 4 / Spring 7.
Часта «помилка за звичкою» виглядає так (і вона не розв’язує проблему залежності):
import org.mockito.Mock;
@WebMvcTest(PublicArticleController.class)
class PublicArticleControllerWebMvcTest {
// Mockito mock, але НЕ Spring bean: Spring не знає, що це за об’єкт,
// і не зможе впровадити його в контролер через DI.
@Mock
PublicArticleService publicArticleService;
}
Так, publicArticleService буде mockʼом. Але Spring під час створення контролера взагалі про нього не дізнається, бо цей mock не зареєстровано як bean. У підсумку контекст усе одно не запуститься.
А ось так — правильно для MVC slice:
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@WebMvcTest(PublicArticleController.class)
class PublicArticleControllerWebMvcTest {
// Mockito mock, зареєстрований у Spring-контексті тесту:
// контролер отримає саме його через constructor injection.
@MockitoBean
PublicArticleService publicArticleService;
}
Тут PublicArticleService стає повноцінним учасником контексту тесту. Для Spring це звичайний bean, просто його поведінку ви контролюєте через Mockito‑stubbing.
Є ще один важливий практичний момент. У slice‑тестах ми намагаємося, щоб кожен тестовий метод був самодостатнім і не залежав від налаштувань десь зверху. Тому stubbing зазвичай пишуть прямо в тесті (або в дуже акуратних helper‑методах), а не один раз «на все» в @BeforeEach. Це робить тести зрозумілішими і зменшує ризик, що один сценарій випадково вплине на інший.
3. Stubbing для контролера: задаємо зміст відповіді
Коли у вас є @MockitoBean, наступний крок — навчитися задавати контролеру передбачувану зовнішню поведінку його залежності. І тут важливо тримати в голові: у controller‑тесті сервіс для нас — це «чорна скринька», яка за входом повертає результат. Ми не перевіряємо, як він його отримав, і не намагаємося відтворити всі внутрішні виклики. Ми просто кажемо: «коли контролер попросить X — поверни Y».
Для початку зручно використовувати BDD-стиль Mockito: given(...).willReturn(...). Він читається майже як людська мова: «дано, що сервіс поверне ось це».
Нехай публічна картка статті повертається як DTO (у проєкті це нормальна практика: назовні — лише DTO):
// DTO для зовнішнього контракту: те, що реально побачить клієнт.
public record ArticleDetailsResponse(
String slug,
String title,
String summary
) {}
Тепер у тесті ми хочемо: за slug "spring-slice" контролер повертає 200 OK і JSON з title = "Spring Slice".
Скелет тесту (поки без деталей HTTP-механіки, лише щоб побачити зв’язок «stub → виклик → відповідь»):
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(PublicArticleController.class)
class PublicArticleControllerWebMvcTest {
// MockMvc — наш «клієнт» для виклику endpointʼів контролера.
@Autowired MockMvc mockMvc;
// Замінюємо сервіс на mock, щоб тест перевіряв web-границю,
// а не піднімав бізнес-шар.
@MockitoBean PublicArticleService publicArticleService;
@Test
void returnsArticleDetails() throws Exception {
// stubbing буде тут
}
}
Сам stubbing — короткий і змістовний:
import static org.mockito.BDDMockito.given;
// Ми задаємо передбачувану відповідь сервісу для конкретного входу (slug),
// щоб контролер міг сформувати стабільну HTTP-відповідь.
given(publicArticleService.getBySlug("spring-slice"))
.willReturn(new ArticleDetailsResponse(
"spring-slice",
"Spring Slice",
"Короткий опис"
));
Зверніть увагу на тонкість: ми повертаємо саме DTO, яке контролер віддає назовні. Це ідеально лягає в ідею controller‑тесту: перевірити web‑контракт і маршрут даних, а не бізнес-логіку.
Якщо хочете побачити, що stub справді впливає на результат контролера, мінімальний виклик через MockMvc може виглядати так:
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
// Викликаємо endpoint так, як це зробив би клієнт, і перевіряємо базовий контракт:
// контролер відповідає 200 OK.
mockMvc.perform(get("/api/public/articles/spring-slice"))
.andExpect(status().isOk());
Зверніть увагу, ми тут не розписуємо багаті перевірки тіла відповіді. Це свідомо: у межах розмови про залежності нам достатньо зрозуміти, що контролер «живе» і отримує дані від замоканого сервісу. Чим більше ви навантажите цей приклад JSON-перевірками, тим швидше опинитеся в темі HTTP-механіки замість теми «як правильно підміняти залежності».
4. verify(...) у controller‑тестах
Коли ви вже навчилися задавати stub для сервісу, руки зазвичай тягнуться до verify(...): «А давайте ще перевіримо, що сервіс точно було викликано». І тут дуже легко перейти межу між корисною перевіркою і перевіркою сценарію викликів заради самого сценарію.
У controller‑тесті verify зазвичай доречний, коли ви хочете захистити саме web‑сенс. Наприклад, ви хочете довести, що path variable {slug} справді передається в сервіс, а не ігнорується або не підміняється чимось дивним. Це не перевірка бізнес-логіки, а перевірка правильної межі між web- і service-шаром.
Мініприклад:
import static org.mockito.Mockito.verify;
// Перевіряємо важливий web-сенс: що slug з URL реально передали в сервіс.
verify(publicArticleService).getBySlug("spring-slice");
А ось де verify стає сумнівним: коли ви починаєте перевіряти кількість викликів «про всяк випадок», порядок викликів, і особливо коли додаєте verifyNoMoreInteractions(...) на кожен тест. У web‑шарі легко з’явиться маленький технічний виклик (наприклад, логування, метрика, підготовка контексту), і ваш тест почне падати не тому, що зламався контракт API, а тому, що «контролер став робити на один виклик більше». Це і є класична крихкість.
Добре внутрішнє правило звучить так: якщо прибрати verify, і тест усе одно доводить зовнішній контракт endpointʼа — найчастіше verify не обов’язковий. Якщо без verify тест перестає захищати важливий сенс, наприклад коректне використання вхідних параметрів, — тоді verify виправданий.
5. @MockitoSpyBean: реальний Spring‑bean, але під мікроскопом
Spy — це інструмент, який звучить як «mock, але по-дорослому». На практиці він ближчий до фрази «залиш усе як є, але дай мені в одному місці втрутитися». У spy за замовчуванням викликаються реальні методи об’єкта, а Mockito дає змогу або підглядати виклики, або точково перевизначити поведінку пари методів.
@MockitoSpyBean робить spy не для звичайного об’єкта, а для Spring-біну всередині контексту тесту. Тобто Spring створює справжній bean, а ми отримуємо поверх нього «обгортку-шпигуна».
У web slice це може бути корисно, коли поруч із контролером є невеликий чистий компонент, який ви вважаєте частиною web‑шару, і ви не хочете мокати його повністю. Типовий приклад — простий mapper, formatter або невеликий helper без I/O.
Важливо пам’ятати: щоб @MockitoSpyBean спрацював, бін має реально існувати в контексті. Якщо компонент не потрапляє в slice автоматично, його потрібно зареєструвати в контексті (як саме — питання техніки, але сенс простий: без реального біну шпигувати нема за чим).
Мініскелет, як це виглядає в тесті:
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
@WebMvcTest(PublicArticleController.class)
@Import(PublicArticleMapper.class) // бін має бути в контексті
class PublicArticleControllerWebMvcTest {
// Spy обгортає РЕАЛЬНИЙ бін: за замовчуванням будуть викликані справжні методи,
// але ми можемо точково підмінити поведінку або перевірити виклики.
@MockitoSpyBean
PublicArticleMapper publicArticleMapper;
}
Тепер два дуже практичні нюанси про spy.
Перший: spy викликає реальні методи, отже, якщо реальний метод робить щось важке або нестабільне (I/O, час, мережа, файлова система), ви отримаєте «веселі» тести: повільні, flaky і не дуже зрозумілі. Для таких залежностей spy — погана ідея. Там краще або mock, або реальний тест на іншому рівні тестування.
Другий: коли ви хочете застабити метод spy, часто безпечніше використовувати стиль doReturn(...).when(...), а не when(spy.method(...)).thenReturn(...). Причина проста і неприємна: when(spy.method()) у момент налаштування може реально викликати метод, а він може впасти або зробити зайву роботу.
Невеликий приклад «акуратного» stubbing для spy:
import static org.mockito.Mockito.doReturn;
// Важливо: doReturn(...) не викликає реальний метод під час налаштування стабу.
doReturn("Spring Slice")
.when(publicArticleMapper)
.normalizeTitle("SPRING SLICE");
Навіть якщо normalizeTitle у реальності робить щось складніше (або несподівано падає на null), doReturn гарантує, що під час налаштування stub реальний метод не спрацює.
І ще раз: @MockitoSpyBean — це інструмент для рідкісних випадків. У більшості controller‑тестів вам вистачає @MockitoBean для сервісів і реальних MVC-компонентів.
6. Вибір: real bean, mock або spy
Коли у вас у руках є і mock, і spy, з’являється спокуса «контролювати взагалі все». Але мета slice‑тесту — не створити ляльковий театр, де кожен рух прописаний. Мета — швидко і локально перевірити поведінку web-границі. Тому корисно тримати простий орієнтир вибору і не ускладнювати раніше часу.
Нижче — компактна таблиця-шпаргалка, яка зазвичай рятує від зайвої магії:
| Що ви робите із залежністю контролера | Як поводиться за замовчуванням | Коли це доречно в MVC slice | Головний ризик |
|---|---|---|---|
| Залишаєте реальний bean | Працює як у справжньому застосунку | Якщо це частина web-шару і вона маленька, детермінована (наприклад, простий mapper) | Тест стане складнішим через «зайву» реальну логіку |
| Ставите @MockitoBean | Робить тільки те, що ви застабили | Для границі контролера: сервіси/фасади, які не входять у web‑slice і не повинні перевірятися тут | Можна ненароком замокати занадто багато і втратити сенс тесту |
| Ставите @MockitoSpyBean | Викликає реальний код, але ви можете втрутитися | Рідко: коли хочете зберегти реальну поведінку, але потрібно точково підмінити 1–2 деталі або подивитися виклики | Легко отримати side effects, крихкість і дивні падіння |
Якщо сумніваєтеся — майже завжди починайте з @MockitoBean для сервісів. Spy варто діставати лише тоді, коли ви точно розумієте, навіщо вам потрібна «частково реальна» поведінка.
Тут корисно відокремити два класи залежностей. Сервіси і фасади майже завжди залишаються поза controller slice і йдуть через @MockitoBean. А ось маленькі сусіди самої web-границі — mapper, @ControllerAdvice, properties-based helper — це вже інша розмова: їх іноді вигідніше залишити реальними і додати точково, а не перетворювати все на ляльковий театр.
7. @WebMvcTest без лялькового театру
Найпідступніша проблема controller‑тестів — не в тому, що вони складні. А в тому, що їх дуже легко зробити зеленими й марними. Якщо замокати взагалі все (включно з маперами, конвертерами, хелперами), ви в підсумку тестуєте не контролер, а власні заглушки. Це особливо небезпечно для початківців: тести зелені, упевненість висока, а реальний endpoint ламається від першої ж зміни wiringʼу.
Тримайте в голові просту межу. У @WebMvcTest ми перевіряємо, що MVC-шар правильно обробляє запит і формує відповідь. Отже, ми залишаємо реальними контролер, інфраструктуру Spring MVC і все, що справді стосується web-поведінки. А от service-шар — це зовнішня залежність для контролера, і його майже завжди потрібно підмінити через @MockitoBean.
Є й архітектурний сигнал, який добре ловиться саме на цьому етапі. Якщо ваш контролер потребує п’ять різних сервісів, два мапери, один «менеджер транзакцій про всяк випадок» і ще три загадкові фасади, то тест з @MockitoBean раптом стане величезним і незручним. І проблема тут зазвичай не в тесті, а в тому, що контролер перевантажено відповідальностями. Slice‑тести корисні ще й тим, що показують такі місця без довгих міркувань: «Занадто багато моків = занадто багато обов’язків в одному місці».
8. Типові помилки під час використання @MockitoBean і @MockitoSpyBean
Коли переходите від unit‑тестів до @WebMvcTest, здається, що все те саме, тільки з анотацією зверху. На практиці тут є кілька граблів, на які наступають майже всі, і це нормально. Важливо просто впізнавати їх за звуком і не сперечатися з реальністю (вона все одно переможе).
Помилка №1: використовувати @Mock, очікуючи, що контролер отримає його через DI.
@Mock створює mock для Mockito, але Spring про нього не знає. Контекст упаде через відсутній bean залежності. У slice‑тестах, де контролер створює Spring, залежності контролера теж потрібно давати Springʼу — через @MockitoBean.
Помилка №2: стабити «все й одразу» в @BeforeEach, а потім губитися, що саме важливо в тесті.
Так тест перетворюється на квест «знайдіть, де налаштовано потрібну поведінку». Набагато читабельніше, коли stubbing лежить поруч із конкретним сценарієм. Тоді тест читається як історія: які дані ми очікуємо від сервісу і що контролер із ними робить.
Помилка №3: перетворювати кожен controller‑тест на перевірку кількості викликів через verify(...).
Перевірки взаємодій корисні, але лише тоді, коли вони захищають сенс. Якщо ви перевіряєте кожен виклик «про всяк випадок», тести починають ламатися від дрібних технічних правок, хоча зовнішній контракт endpointʼа не змінився. У підсумку ви лагодите тести, а не захищаєте API.
Помилка №4: використовувати @MockitoSpyBean на бінах із побічними ефектами.
Spy викликає реальний код. Якщо реальний код ходить у зовнішні ресурси, читає файли, залежить від часу або просто «важкий», ви отримаєте повільні й нестабільні тести. Spy доречний лише для дуже передбачуваних компонентів, які не роблять I/O і не тягнуть за собою половину застосунку.
Помилка №5: стабити spy через when(spy.method())...
Це класика Mockito: ви хотіли підмінити поведінку, але в момент stubbing викликали справжній метод, і він упав. Для spy безпечніше використовувати doReturn(...).when(...) (особливо якщо метод може падати на неготових даних).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ