1. Один контролер — один тестовий клас
Якщо дивитися на @WebMvcTest як на «мікроскоп», то контролер — це обʼєкт, який ми кладемо на предметне скло. Можна, звісно, спробувати засунути туди одразу три обʼєкти, пару гайок і кота (кіт завжди знайде, як потрапити в кадр), але тоді ви перестаєте розуміти, що саме розглядаєте. Правило «один контролер — один тестовий клас» допомагає зберегти фокус: один тестовий клас захищає один HTTP-контракт і одну зону відповідальності.
На практиці це правило розвʼязує одразу дві буденні проблеми. По-перше, воно різко зменшує кількість «випадкових залежностей»: коли ви тестуєте публічний контролер, вам не потрібні моки й звʼязування з editor- і admin-контролерами. По-друге, воно робить падіння тестів легшими для діагностики. Якщо впав PublicArticleControllerWebMvcTest, ви майже завжди починаєте шукати проблему саме в публічному web-шарі, а не в «якомусь із 27 ендпоінтів, які ми чомусь тестували в одному класі».
Давайте швидко зафіксуємо, що саме маємо на увазі під «одним контролером» у межах нашого проєкту ContentHub. У нас є різні поверхні API: публічна — для читання опублікованих статей, editor — для створення, редагування та відправлення на ревʼю, а admin — для модерації, публікації й архівування. Ці поверхні можуть працювати з одним і тим самим доменним словником («Article»), але це все одно різні контракти. Саме тому тести теж мають жити окремо.
Ось правильний «скелет» тестового класу під один контролер:
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
// Важливо: явно вказуємо контролер, який кладемо під «мікроскоп»
@WebMvcTest(PublicArticleController.class)
class PublicArticleControllerWebMvcTest {
// Тут будуть лише залежності й сценарії публічного контракту
}
А ось формально робочий, але методологічно небезпечний варіант:
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
// Небезпечно: без параметрів Spring Boot підніме всі знайдені контролери
@WebMvcTest
class ArticleApiWebMvcTest {
// Контекст розростається, і тест раптово стає «про все на світі»
}
У другому випадку ви ніби говорите Spring Boot: «Підніми мені web-слайс, але контролери я не уточню — здогадайся сам». Spring Boot здогадається: він підніме всі знайдені контролери в цьому слайсі. А далі починається типова ланка подій: тестів ніби й один клас, але залежностей і «чому він узагалі не стартує» — наче ви вирішили підняти пів застосунку.
2. Ризики універсального @WebMvcTest
Є дуже людське бажання: «Зроблю один великий тестовий клас, щоб не плодити файли». Це бажання зрозуміле й навіть економить кілька секунд на створення класу. Але в тестах така «економія» часто перетворюється на кредит під 300% річних: відсотки ви сплачуєте щотижня, коли щось змінюється в коді. Універсальний @WebMvcTest майже завжди розмиває межі слайсу і перетворює вузький web-тест на дивний гібрид — «тестуємо все і нічого».
Найчастіше це виглядає так: контекст не стартує, тому що один із контролерів у слайсі тягне залежність, якої в @WebMvcTest за замовчуванням немає. І ви раптово лагодите не тест публічного API, а підміни залежностей для admin-ендпоінта — просто тому, що обидва опинилися в одному контексті.
Уявімо спрощену картину. Коли ви запускаєте один «універсальний» @WebMvcTest, фактично ви робите ось це:
flowchart TD Test["ArticleApiWebMvcTest (@WebMvcTest без списку)"] --> C1["PublicArticleController"] Test --> C2["EditorArticleController"] Test --> C3["AdminArticleController"] C1 --> S1["PublicArticleService (потрібен мок)"] C2 --> S2["EditorArticleService (потрібен мок)"] C3 --> S3["AdminArticleService (потрібен мок)"]
Ви можете сказати: «Ну і що? Замокаю все». Так, можна. Але тоді тестовий клас стає не про один контракт, а про складання колекції моків. А якщо ви випадково додали новий контролер або новий аргумент у конструктор контролера, цей «універсальний» тест починає падати, навіть якщо ви взагалі не писали жодного тестового методу під новий ендпоінт. У підсумку тестовий клас починає поводитися як сигналізація в підʼїзді: кричить не лише тоді, коли пожежа, а й коли хтось відчинив вікно.
Майже завжди це призводить до таких наслідків, і це важливо відчути, а не просто запамʼятати як гасло. По-перше, в одному тестовому класі починає накопичуватися занадто багато @MockitoBean. Навіть якщо кожен мок «невинний», загальна картина перетворюється на шум. По-друге, тести стають логічно повʼязаними: ви правите admin-контролер, а у вас раптом падають публічні тести, тому що контекст тепер вимагає ще один бін. По-третє, ви непомітно втрачаєте сенс слайсу: він стає ширшим, ніж потрібно для конкретної перевірки.
Ось коротка ілюстрація того, як «універсальний» тест змушує вас тягнути моки не по ділу:
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
@WebMvcTest
class ArticleApiWebMvcTest {
// І це лише початок...
// public, editor, admin — усе в одній коробці
// Чим більше контролерів потрапило в слайс, тим більше моків знадобиться просто для старту контексту
}
А тепер порівняйте з фокусним підходом:
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
// Хороша практика: тестуємо один контракт і один контролер
@WebMvcTest(AdminArticleController.class)
class AdminArticleControllerWebMvcTest {
// Тут ви думаєте лише про admin-контракт,
// а не про весь API сервісу.
}
Різниця не в кількості рядків коду. Різниця в тому, про що ви змушені думати, коли відкриваєте цей файл.
3. Поверхні API: public / editor / admin
У ContentHub є простий, але дуже життєвий факт: один і той самий ресурс «стаття» виглядає по-різному в різних зонах доступу. Публічний API — для читання опублікованого контенту. Editor API — для створення та редагування «свого». Admin API — для ухвалення рішень і керування статусами. Навіть якщо URL-и схожі, смисли різні: різні DTO, різні статуси помилок, різні очікування клієнта. Тому тести краще організовувати не «за сутністю Article», а за реальними web-поверхнями.
Правило «один контролер — один тестовий клас» ідеально лягає на цю модель. У вас зʼявляються три основні тестові файли, і кожен із них стає маленькою документацією своєї поверхні API. Ви дивитеся на назву — і вже розумієте, куди потрапили та навіщо. Це дуже схоже на хорошу структуру папок на компʼютері: коли є Фото/2025/Відпустка, ви не зберігаєте туди ж «Податки» й «Рецепт борщу». (Хоча борщ — важлива частина виживання, сперечатися не буду.)
Практично це виглядає як окремі тестові класи:
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
// Публічний: лише публічні ендпоінти
@WebMvcTest(PublicArticleController.class)
class PublicArticleControllerWebMvcTest {
}
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
// Editor: лише editor-ендпоінти
@WebMvcTest(EditorArticleController.class)
class EditorArticleControllerWebMvcTest {
}
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
// Admin: лише admin-ендпоінти
@WebMvcTest(AdminArticleController.class)
class AdminArticleControllerWebMvcTest {
}
Тут важлива тонкість: ми зараз говоримо саме про структуру набору тестів, а не про механіку запитів. Тому в цій лекції ми не намагаємося «в одному тесті довести все». Ми будуємо каркас, у якому кожному контракту є своє місце, і це місце не конфліктує із сусідами.
Якщо хочеться трохи наочнішої картинки, то тестовий набір за контролерами можна уявити так:
flowchart LR P["PublicArticleControllerWebMvcTest"] --> PC["PublicArticleController"] E["EditorArticleControllerWebMvcTest"] --> EC["EditorArticleController"] A["AdminArticleControllerWebMvcTest"] --> AC["AdminArticleController"]
Ця схема здається очевидною… доки ви не побачите реальний проєкт, де є ArticleApiTest на 1500 рядків, який «нібито перевіряє все», але під час падіння ви пів години зʼясовуєте, який ендпоінт узагалі впав і чому це повʼязано з тестом, який ви навіть не чіпали.
4. Імена та пакети WebMvcTest
Коли тестів стає більше десятка, «де вони лежать» починає впливати на швидкість розробки не менше, ніж вибір бібліотек. Причому це не жарт: якщо тестова структура незручна, ви просто рідше запускаєте тести й частіше «перевіряєте очима». А очі — інструмент хороший, але до вечора вони люблять галюцинувати. Тому у web-слайс-тестах ми так само дисципліновано ставимося до імені та місця файлу, як і в production-коді.
У межах ContentHub ми тримаємо просту й передбачувану модель: тестовий пакет дзеркалить production-пакет, а імʼя тесту чітко показує що тестуємо і яким типом тесту. Це дуже допомагає не сплутати @WebMvcTest з майбутніми ширшими тестами й водночас полегшує пошук. Ви не повинні грати в квест «знайди тест за назвою», коли у вас горить строк задачі.
Приклад структури директорій (без фанатизму, просто «щоб знаходилося»):
src
├─ main/java/com/example/contenthub/api/controller
│ ├─ PublicArticleController.java
│ ├─ EditorArticleController.java
│ └─ AdminArticleController.java
└─ test/java/com/example/contenthub/api/controller
├─ PublicArticleControllerWebMvcTest.java
├─ EditorArticleControllerWebMvcTest.java
└─ AdminArticleControllerWebMvcTest.java
Назва PublicArticleControllerWebMvcTest тут робить дві речі. Вона каже «я тестую PublicArticleController» і одразу ж уточнює «я роблю це як WebMvc slice». Це заощаджує дивовижно багато часу на читанні. Особливо коли ви повертаєтеся до проєкту через місяць і намагаєтеся зрозуміти, чому тест падає: ви не вгадуєте, який контекст піднімається і якого масштабу цей тест.
Щоб закріпити ідею, порівняйте дві назви:
PublicArticleControllerWebMvcTest // одразу ясно: controller + slice
ArticleControllerTest // неясно: який controller? який рівень?
Якщо в проєкті зʼявляться дорожчі тести (повний контекст, live server тощо), то «розмиті» імена починають реально заважати. Але навіть без майбутніх рівнів уже зараз важлива одна річ: тести мають читатися як документація, а документації дуже шкідливо бути загадковою.
До речі, це чудове місце, щоб застосувати те, що ви вже знаєте з JUnit: @Nested і @DisplayName. Усередині одного controller-test класу ви можете групувати тести за ендпоінтами. Важливо лише, щоб це не перетворювалося на «другий контролер у тому самому класі». Групування за ендпоінтами одного контролера — чудово. Групування «і public, і editor, і admin» — уже тривожний дзвіночок.
5. Спільний код без каші
Щойно ви розділили тести за контролерами, одразу виникає наступна спокуса: «А давайте все спільне винесемо в базовий клас, щоб не повторювати @Autowired MockMvc, ObjectMapper, константи URL тощо». Ця спокуса особливо сильна в початківців, тому що повторення здається «поганим», а абстракція — «красивою». У тестах усе трохи складніше: повторення іноді справді погане, але передчасна абстракція майже завжди гірша. Бо вона ховає сенс сценарію й розмиває межі.
Практичне правило тут таке: спільний код допустимий, якщо він не змінює сенс тесту й не змушує вас думати про «магію». Наприклад, спільна константа базового URL або маленький метод, який перетворює обʼєкт DTO на JSON-рядок, зазвичай цілком нормальні. А ось загальний базовий клас, який у @BeforeEach робить купу підмін для трьох різних контролерів, майже гарантовано перетворюється на пастку: тести починають залежати від неочевидної «успадкованої» поведінки, і ви ловите поломки там, де їх не очікували.
У @WebMvcTest особливо важливо, щоб кожен тестовий клас чітко показував свою межу залежностей. Тому корисно, щоб моки оголошувалися локально — поруч із тестами, які їх використовують. Це не про любов до копіпасту, а про чесність контексту. Коли ви відкриваєте PublicArticleControllerWebMvcTest, ви хочете одразу бачити, який сервіс підміняється і чому. Якщо це сховано в AbstractWebMvcTestBase, ви перетворюєте тестовий код на детектив.
Хороший компроміс часто виглядає так: маленький helper без Spring-анотацій, який живе в src/test/java/.../testsupport і містить 1–2 прості утиліти. Наприклад, генерація тестового DTO або мінімальний JSON-loader, якщо ви використовуєте fixtures. Але навіть тут корисно тримати дисципліну: helper має допомагати читати тест, а не перетворюватися на «мініфреймворк».
Покажу приклад допустимого міні-helperʼа, який не ламає межу й не тягне контекст:
import com.fasterxml.jackson.databind.ObjectMapper;
final class JsonTestHelper {
private JsonTestHelper() {
// Допоміжний клас: екземпляри створювати не потрібно
}
static String toJson(ObjectMapper mapper, Object value) throws Exception {
// Важливо: серіалізація має бути такою самою, як у застосунку (той самий ObjectMapper з контексту)
return mapper.writeValueAsString(value);
}
}
І приклад того, що зазвичай починає шкодити (не тому, що «так не можна», а тому, що швидко стає некерованим):
abstract class AbstractArticleApiWebMvcTestBase {
// Небезпечно: у такому базовому класі швидко зʼявляться моки для public/editor/admin,
// спільна підготовка, і через тиждень ніхто не згадає, що і навіщо.
}
Ще одна тонкість: іноді у контролерів повторюються частини URL. Наприклад, у public-ендпоінтів є /api/public/articles, у editor — /api/editor/articles. Так, можна винести рядки в константи. Але якщо від цього тест починає виглядати як математична формула, яку треба обчислити, щоб зрозуміти URL, — краще залишити рядок просто в тесті. Controller-тест — це тест контракту. Контракт корисно бачити очима, а не «складати з частин».
6. Кейс: розпилюємо «товстий» WebMvcTest
Найкраще правило «один контролер — один тестовий клас» видно на контрасті: коли ви берете один «товстий» тест і перетворюєте його на три тонкі. Уявімо, що ми почали з ідеї «перевірю всі статті одним тестом». Вийшов клас, у який потрапили і public list, і editor create, і admin approve. І в якийсь момент він почав вимагати три різні сервіси та три різні набори підмін — просто щоб контекст піднявся.
Ось як може виглядати типовий «товстий» скелет (навіть без тестових методів він уже пахне перегрівом):
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
// Навмисно «товстий» слайс: три контролери в одному контексті
@WebMvcTest({
PublicArticleController.class,
EditorArticleController.class,
AdminArticleController.class
})
class AllArticleControllersWebMvcTest {
// Чим більше контролерів тут, тим більше моків знадобиться нижче
}
Щоб він стартував, вам доведеться підміняти залежності всіх трьох контролерів. Спрощено це швидко перетворюється на таке:
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
// Тут у контекст потрапили мінімум public + editor, і їхні залежності потрібно замокати
@WebMvcTest({PublicArticleController.class, EditorArticleController.class})
class AllArticleControllersWebMvcTest {
@MockitoBean
PublicArticleService publicArticleService; // Мок для залежностей public-контролера
@MockitoBean
EditorArticleService editorArticleService; // Мок для залежностей editor-контролера
}
І далі логіка розростання майже неминуча: додали admin — додали AdminArticleService. Додали нову залежність в editor-контролер — додали ще мок. Додали ще один контролер — ще мок. І так далі.
Тепер той самий сенс, але в трьох окремих класах. Public:
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
// Вузький контекст: лише публічний шар
@WebMvcTest(PublicArticleController.class)
class PublicArticleControllerWebMvcTest {
}
Editor:
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
// Вузький контекст: лише editor-шар
@WebMvcTest(EditorArticleController.class)
class EditorArticleControllerWebMvcTest {
}
Admin:
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
// Вузький контекст: лише admin-шар
@WebMvcTest(AdminArticleController.class)
class AdminArticleControllerWebMvcTest {
}
У результаті кожен тестовий клас піднімає лише те, що йому потрібно, і вимагає лише ті моки, які справді стоять на межі цього контролера.
Щоб відчути, наскільки це полегшує життя, уявіть звичайну робочу ситуацію. Ви змінюєте editor-ендпоінт, наприклад змінюється request DTO. Якщо у вас один «товстий» тест, є ризик, що він упаде ще до запуску публічних тестів, тому що контекст не збереться. Якщо ж editor-тести окремі, то публічні тести продовжать нормально працювати й покажуть: публічний контракт не постраждав. Це саме та локальність, заради якої ми взагалі обираємо слайс-тести.
Нарешті, маленька «інженерна чесність» про швидкість. Так, три різні @WebMvcTest класи створять три різні контексти. Це може бути трохи дорожче, ніж один «універсальний» контекст. Але зазвичай виграє інше: контексти менші, тести простіші, діагностика швидша, і ви менше часу витрачаєте на боротьбу із «зайвими» залежностями. А якщо колись зʼявиться бажання оптимізувати швидкість — це варто робити вимірюваннями, а не вгадуванням. Найдорожчий тест — не той, що виконується 700 мс замість 400 мс, а той, що змушує розробника дві години шукати проблему в неправильному місці.
Для зручності можна тримати в голові невелику табличку «симптомів розповзання». Не як догму, а як сигнал «варто зупинитися і переглянути межу»:
| Симптом у controller-тесті | Що часто означає | Як зазвичай лікується |
|---|---|---|
| У тестовому класі 4–6 @MockitoBean | У контекст потрапили зайві контролери або контролер бере на себе забагато | Звузити @WebMvcTest до одного контролера, перевірити архітектуру контролера |
| @WebMvcTest без параметрів | Увімкнулися всі контролери проєкту | Вказати конкретний контролер в анотації |
| Тести падають через залежності ендпоінта, який ви не тестуєте | Тестовий контекст зібраний «на всі випадки життя» | Розділити тести за контролерами |
| У проєкті зʼявився «спільний базовий тестовий клас» із магією | Ви починаєте будувати власний тестовий фреймворк | Спрощувати, повернути моки й підготовку ближче до тестів |
7. Типові помилки під час @WebMvcTest
Помилка №1: використовувати @WebMvcTest без вказання контролера.
Технічно це виглядає красиво: одна анотація, мінімум параметрів. Практично — це увімкнення всіх контролерів у слайсі. Далі ви не тестуєте «один контракт», ви збираєте «мінікартину всього API», і будь-яка зміна в будь-якому контролері може ламати ваш тестовий клас, навіть якщо тестів під цей ендпоінт ще немає.
Помилка №2: намагатися зробити «один тест на всі статті», тому що «так менше файлів».
У web-layer тестах розмір файла майже ніколи не є головним параметром якості. Головний параметр — локальність відповідальності. Коли ви змішуєте public, editor і admin в одному класі, ви платите складністю: більше моків, більше випадкових залежностей, більше причин падіння. У підсумку файлів менше, а болю більше.
Помилка №3: робити величезну спільну підготовку й ховати її в успадкуванні.
Успадкування в тестах здається зручним, доки ви не намагаєтеся зрозуміти, чому в цьому тесті сервіс повертає якісь «дефолтні» дані. Коли підміни й підготовка ховаються в базовому класі, тест перестає бути документом сценарію і перетворюється на історію з пропущеними сторінками. Краще нехай у тесті буде на 3 рядки більше, зате буде видно, що відбувається.
Помилка №4: виносити URL і очікування в занадто розумні константи та «міні-DSL».
Controller-тест — це про HTTP-контракт. Контракт корисно бачити очима: /api/public/articles/{slug} — це не «поганий рядок», а важлива частина інтерфейсу. Якщо ви перетворюєте тест на набір викликів publicApi().articles().details(slug) без необхідності, ви втрачаєте відчуття реального HTTP. А потім дивуєтеся, чому баг був «в URL», а тест цього не показав.
Помилка №5: не помічати архітектурний запах «God-controller».
Іноді тестів багато, але все одно хочеться «обʼєднати» їх в один клас — тому що контролер величезний і там 15 ендпоінтів. Це часто не проблема тестів, а сигнал у production-коді: контролер взяв на себе забагато обовʼязків. У межах курсу ми не влаштовуємо переписування архітектури, але принаймні варто розпізнавати сигнал: якщо один контролер важко тестувати вузько, можливо, він і за змістом занадто широкий.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ