1. Архітектура MVC‑тестів
Коли MVC-тестів стає більше за пару штук, проблема зазвичай не в тому, що «бракує анотацій», а в тому, що тести починають жити хаотично: шматки public API перемішуються з editor, поруч раптово з’являються сценарії attachment, а через тиждень ніхто не пам’ятає, чому в базовому класі лежить мок якогось сервісу, який половині тестів узагалі не потрібен. Архітектура suite — це спосіб зробити тести передбачуваними й швидкими в супроводі, щоб будь-яка людина, включно з вами через місяць, могла знайти потрібний сценарій за хвилину, а не за пів дня археологічних розкопок.
У реальному проєкті MVC-тести часто зростають «за потреби»: додали ендпоінт — додали тест. Це нормально. Ненормально, коли кожен новий тест потрапляє у випадковий файл, а спільні речі копіюються в десяти місцях. У якийсь момент ви отримуєте не suite, а «тестове звалище», де кожен тест наче й зелений, але щось змінювати страшно: будь-яка правка ламає пів світу, бо тести занадто пов’язані.
Саме тому на цьому етапі курсу ми фіксуємо просту ідею: MVC-suite має відображати реальні поверхні API. У ContentHub ці поверхні вже явно розділені URI-префіксами (/api/public/**, /api/editor/**, /api/admin/**) і змістом сценаріїв. Отже, і тестова структура має повторити цю картину, а не сперечатися з нею.
2. Зони API: public, editor, admin
Якщо дивитися на API «очима клієнта», то public, editor і admin — це не просто різні контролери. Це різні очікування від контракту: public API майже завжди про читання та параметри пошуку, editor API — про створення, зміну та прикладні помилки користувача, admin API — про адміністративні дії та бізнес-конфлікти статусів. Коли ми змішуємо це в один тестовий казан, ми втрачаємо смислові межі й починаємо писати перевірки «про всяк випадок», а не за ризиками.
Щоб ця розмова була конкретною, зручно тримати в голові невелику таблицю. Вона допомагає не плутати, де саме ви знаходитесь під час читання тесту, і які перевірки в цій зоні зазвичай важливіші.
| Зона | Префікс URI | Типові операції | Що зазвичай важливо в MVC-тестах |
|---|---|---|---|
| public | /api/public/... | читання опублікованих статей | пагінація/сортування/фільтри, , JSON-формат відповіді |
| editor | /api/editor/... | створення/редагування, надсилання, вкладення | 201/200/400/409, валідація, multipart, заголовки під час завантаження, контракт помилок |
| admin | /api/admin/... | затвердження/відхилення/архівування, адмінський список | 200/409/404, правильні статуси й payload помилки |
Зверніть увагу: ми зараз свідомо не обговорюємо «права доступу» як предмет перевірки. У реальному застосунку public/editor/admin майже завжди пов’язані з безпекою, але на рівні архітектури MVC-suite нам важливіше спочатку розділити поверхні: інакше перевірки доступу змішаються зі звичайними HTTP-сценаріями, а suite швидко втратить форму.
Ще один практичний нюанс: кожна зона зазвичай має різний набір «улюблених» хелперів. Public найчастіше потребує зручних утиліт для query params (page, size, sort, category), editor — утиліт для multipart і файлових фікстур, admin — зручних заготовок для команд на кшталт approve/reject. Якщо все змішати, helpers починають розростатися «під усе», перетворюючись на мініфреймворк, який зрештою ховає HTTP-семантику й ускладнює читання тесту.
3. Правило: один контролер — один тест
Коли в нас з’являється спокуса написати «один великий тест на весь API», це зазвичай виглядає раціонально: «менше файлів, простіше шукати». На практиці виходить навпаки. Один великий тестовий клас дуже швидко перетворюється на звалище контекстів: вам потрібно замокати занадто багато залежностей, бо контролерів багато; з’являються @BeforeEach з підготовкою, яка половині тестів не потрібна; а ще зростає шанс випадково зламати чужий тест, бо ви змінили спільне налаштування заради одного кейсу.
Правило «один контролер — один тестовий клас» працює як санітарна норма. Воно змушує вас тримати @WebMvcTest вузьким, а отже, швидким і зрозумілим. Ви відкриваєте файл PublicArticleControllerWebMvcTest — і ваш мозок миттєво розуміє, що тут живуть лише сценарії GET /api/public/articles і GET /api/public/articles/{slug}. Жодних завантажень, жодних адмінських дій. Просто приємно.
При цьому «один контролер — один клас» не забороняє всередині класу мати кілька сценаріїв. Навпаки: усередині одного контролера ви можете організувати тести так, як вам зручно. Часто добре працюють @Nested-класи: наприклад, окрема група тестів для пагінації, окрема — для сортування, окрема — для негативних параметрів. Але ключове — межа «контролер → тестовий клас» лишається чіткою.
Нижче — мінімальний каркас, який відображає цей принцип і водночас не перетворюється на заготовку на 200 рядків.
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
// MVC-slice: піднімаємо вузький контекст навколо одного контролера.
@WebMvcTest(PublicArticleController.class)
class PublicArticleControllerWebMvcTest {
@Test
void shouldReturnPublishedArticles() {
// Тут пізніше зʼявляться перевірки контракту відповіді (JSON-формат, пагінація тощо).
}
}
Поки це виглядає надто порожньо, але в цьому й сенс: ми будуємо архітектуру як «скелет», який потім обростає м’язами. Якщо скелет кривий, м’язи теж виглядатимуть… своєрідно.
4. Структура тестів у src/test/java
Структура тестів — це не «краса заради краси». Це спосіб зробити так, щоб IDE і мозок працювали разом. Коли ви бачите пакет ...api.controller.editor, ви відразу очікуєте editor-ендпоінти та їхню специфіку. Коли ви бачите ...api.controller.publicapi, ви не очікуєте там MockMultipartFile. І це зменшує когнітивне навантаження сильніше, ніж здається: мозок перестає тримати «все й одразу», бо структура підказує контекст.
Найпростіший і робочий підхід у ContentHub — дзеркалити структуру production-пакетів на рівні тестів, але з додаванням «зон». Якщо у вас контролери лежать у ...api.controller, то в тестах логічно зробити підпакети publicapi, editor, admin. URI-префікс при цьому лишається /api/public/**, але Java package з назвою public буквально робити не варто: це ключове слово, тому потрібен безпечний для компіляції псевдонім.
src/test/java
└── com.example.contenthub
└── api
└── controller
├── publicapi
│ ├── PublicArticleControllerWebMvcTest.java
│ └── PublicArticleUrls.java
├── editor
│ ├── EditorArticleControllerWebMvcTest.java
│ ├── EditorArticleUrls.java
│ └── AttachmentTestFiles.java
└── admin
├── AdminArticleControllerWebMvcTest.java
└── AdminArticleUrls.java
Зверніть увагу: helpers лежать поруч із тестами своєї зони. Це не догма, але часто дуже практично. Коли ви тримаєте EditorArticleUrls поруч із EditorArticleControllerWebMvcTest, ви не створюєте глобальний «utility-пакет», у який з часом починають складати все підряд, зокрема «магічні» константи та шматки логіки.
Якщо вам усе ж потрібен спільний helper, наприклад завантажувач JSON-фікстур або невелика утиліта для перевірок ApiProblem, краще тримати його в окремому тестовому пакеті, але з дуже суворою відповідальністю. Наприклад, com.example.contenthub.testutil.json або ...testutil.web. Важливо, щоб це не стало «загальною помийницею», а залишалося набором маленьких, чесних інструментів.
Ще одна практична деталь: src/test/resources теж варто структурувати. Якщо у вас є тестові файли для multipart (картинка, невеликий бінарний файл), не кладіть їх у корінь ресурсів. Зробіть щось на кшталт src/test/resources/files/attachments/cover.png. Тоді в репозиторії не буде відчуття, що хтось підірвав папку з ресурсами, і тепер ми живемо серед уламків.
5. Хелпери та фікстури
Коли люди чують «архітектура тестів», вони часто уявляють собі «давайте зробимо спільний базовий клас на всі тести». Це звучить спокусливо: один раз налаштували — і готово. Але в MVC-slice контексті це майже завжди приводить до протилежного: спільна база починає тягнути зайві моки, зайві @Import, зайві налаштування серіалізації, і в підсумку ви отримуєте більше контексту, менше прозорості та більше загадкових падінь.
Набагато здоровіше мислити так: shared helper — це те, що зменшує шум, але не змінює сенс. Наприклад, винести рядки URL в окремий клас — нормально: ви прибираєте дублювання, але не ховаєте HTTP. А от винести «зроби запит до editor-ендпоінта з правильними заголовками, тілом, типовими параметрами й перевірками» — уже небезпечно, бо ви починаєте ховати контракт усередині DSL.
Невеликий приклад «безпечного» helper-а для URL. Він не мудрує, він просто збирає рядок, а тест і далі явно вказує параметри.
final class PublicArticleUrls {
private PublicArticleUrls() {
// Клас-утиліта: екземпляри не потрібні.
}
static String list() {
// Явно фіксуємо публічний ендпоінт для списку, без «магії» й DSL.
return "/api/public/articles";
}
static String details(String slug) {
// slug — частина шляху, а не параметр запиту: так простіше читати тести.
return "/api/public/articles/" + slug;
}
}
Схожий підхід можна зробити і для editor/admin зон, але важливо не змішувати. Ідея проста: PublicArticleUrls не має знати нічого про attachments, а EditorArticleUrls не має знати нічого про сортування public-видачі. Якщо helper починає «знати занадто багато», він перестає бути helper-ом і перетворюється на альтернативний API, який потрібно підтримувати.
З фікстурами та сама логіка. Для editor upload/download зручно мати невеликий клас, який створює MockMultipartFile з передбачуваними параметрами. Але нехай він буде локальним для editor-пакета, щоб public тести не «випадково» почали тягнути в себе файлові сценарії.
import org.springframework.mock.web.MockMultipartFile;
final class AttachmentTestFiles {
private AttachmentTestFiles() {
// Клас-утиліта: фабрика тестових файлів.
}
static MockMultipartFile smallPng() {
// Мінімальний «псевдофайл»: важливі назва поля, filename і content-type.
return new MockMultipartFile(
"file",
"cover.png",
"image/png",
new byte[] { 1, 2, 3 } // Вміст неважливий, якщо ми не тестуємо реальну обробку зображення.
);
}
}
Це коротко, зрозуміло й не перетворює тести на «магічний театр».
6. Налаштування @WebMvcTest за зонами
Найчастіша причина, чому MVC-slice раптом стає повільним і дивним, — це безконтрольне зростання конфігурації. У якийсь момент хтось додав @Import на пів проєкту, потім додав ще один @Import, потім з’явився спільний базовий клас, який «про всяк випадок» підключає все. Підсумок: @WebMvcTest перетворюється на майже повний контекст, тільки без репозиторіїв, але з тим самим рівнем болю.
Правильна інтуїція тут така: у кожної зони є свій мінімальний набір інфраструктури. У public тестах вам зазвичай потрібні контролер, його сервіс (замоканий), @ControllerAdvice для помилок і, можливо, конфігурація JSON/об’єктного мапера (яка й так приходить із Boot auto-config). У editor тестах додається multipart-частина, але все одно контролер і його залежності залишаються основою. В admin тестах найчастіше нічого особливого не потрібно — окрім тих самих базових речей.
Приклад акуратного public MVC slice з явно доданим exception handler і замоканим сервісом. Тут видно, що тест тримається навколо контролера, а не навколо напівзастосунку.
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@WebMvcTest(PublicArticleController.class)
// Підключаємо спільний обробник помилок, щоб контракт помилок теж був стабільним у тестах.
@Import(ApiExceptionHandler.class)
class PublicArticleControllerWebMvcTest {
@MockitoBean
private PublicArticleService publicArticleService; // Мокаємо залежність контролера, не піднімаючи сервісний шар.
}
Editor suite виглядає схоже, тільки моки інші. Суть у тому, що залежності мають відображати реальний контролер: якщо editor контролер працює з attachment-операціями через окремий сервіс — мокуйте його. Якщо він делегує все в один фасад — мокуйте фасад. Але не підтягуйте чужі сервіси «про всяк випадок», інакше ви дуже швидко почнете тестувати не контракт, а власні моки.
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@WebMvcTest(EditorArticleController.class)
@Import(ApiExceptionHandler.class) // Для editor-зони особливо важливо тестувати контракт помилок (валідація, 409 тощо).
class EditorArticleControllerWebMvcTest {
@MockitoBean
private EditorArticleService editorArticleService; // Рівно ті залежності, які потрібні цьому контролеру.
}
Admin suite — окремий клас. Навіть якщо там «усього два тести», окремий клас усе одно кращий. Він стане природним місцем, куди ляжуть майбутні перевірки адміністративних дій, і вам не доведеться потім переселяти тести, мов мешканців під час ремонту.
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@WebMvcTest(AdminArticleController.class)
@Import(ApiExceptionHandler.class) // Конфлікти статусів і бізнес-помилки теж мають мати єдиний формат.
class AdminArticleControllerWebMvcTest {
@MockitoBean
private AdminArticleService adminArticleService; // Адмін-дії часто прив’язані до сервісних команд.
}
І ще один нюанс щодо властивостей. Іноді зони відрізняються лімітами та конфігурацією (наприклад, attachment size limit). Якщо вам потрібно перевизначити property для конкретного класу тесту, робіть це локально, щоб не заразити інші suites. Тут важливо втримати принцип: конфігурація — теж частина контракту зони, але не треба перетворювати перевизначення властивостей на глобальну кашу.
Міні-шаблон для трьох suites
Коли структура пакетів і конфігурація зафіксовані, стає простіше писати тести: ви вже знаєте, куди додати новий кейс, і який style там очікується. В ідеалі будь-який новий тест «природно» лягає в правильний пакет, і вам не потрібно думати про те, де він житиме. Це як добре розставлені шухляди на кухні: ножі не зберігають у холодильнику, навіть якщо «так ближче».
Нижче — короткий приклад того, як можуть виглядати каркаси тестів за зонами. Я навмисно показую по одному невеликому тесту, щоб була видна ідея «різні поверхні → різні сценарії», а не щоб ми переписали половину проєкту.
Public suite: акцент на параметри запиту та метадані сторінки.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
class PublicArticleControllerWebMvcTest {
@Autowired
private MockMvcTester mvc; // Тестуємо HTTP-границю, тому працюємо через MockMvc.
@Test
void shouldReturnDefaultPageWhenNoParams() {
// Public: типовий сценарій — запит без параметрів і базова «сторінка за замовчуванням».
mvc.perform(get(PublicArticleUrls.list()))
.assertThat()
.hasStatusOk(); // Мінімальна перевірка: статус. JSON-перевірки додамо в профільних тестах.
}
}
Editor suite: акцент на multipart і контракт поля file. Тут чудово видно, що це інший світ, ніж public-видача.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
class EditorArticleControllerWebMvcTest {
@Autowired
private MockMvcTester mvc; // Тут важливо бачити multipart як «справжній» HTTP-запит.
@Test
void shouldUploadAttachment() {
// Editor: multipart — частина контракту, тому в тесті це має бути явно.
mvc.perform(multipart("/api/editor/articles/{id}/attachments", 10L)
.file(AttachmentTestFiles.smallPng())) // Поле "file" і content-type мають збігатися з контрактом.
.assertThat()
.hasStatusOk();
}
}
Admin suite: акцент на адміністративну дію. Так, тест виглядає простим — і це нормально. Архітектура suite не зобов’язана бути складною, щоб бути корисною.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
class AdminArticleControllerWebMvcTest {
@Autowired
private MockMvcTester mvc; // Admin-сценарії часто «командні»: POST на дію.
@Test
void shouldApproveArticle() {
// Admin: важливо тестувати саме ендпоінт дії та очікуваний статус (часто 200 або 409).
mvc.perform(post("/api/admin/articles/{id}/approve", 10L))
.assertThat()
.hasStatusOk();
}
}
Тут хтось може запитати: «А де перевірки JSON, заголовків і всього такого?» Вони будуть — і у вас уже є лекції та інструменти, щоб їх писати. Але сенс цієї лекції в іншому: показати, що ці перевірки мають жити в правильних місцях. І тоді suite зростає не як снігова куля хаосу, а як акуратна бібліотека сценаріїв за зонами.
7. Типові помилки в архітектурі MVC-test suite
Помилка № 1: один величезний тестовий клас на все API.
Спочатку це здається зручним: один файл, один контекст, «усе під рукою». Потім у цьому файлі з’являється 40 тестів, 12 моків, спільна підготовка в @BeforeEach, яку ніхто не розуміє, і будь-який новий сценарій ламає щось у неочікуваному місці. MVC-suite краще переживає ріст, коли його розділено за поверхнями.
Помилка № 2: спільний базовий клас, який «допомагає всім», але насправді заважає.
Base class майже завжди починає тягнути зайву конфігурацію, зайві @Import і зайві моки. У результаті @WebMvcTest перестає бути вузьким slice-тестом. Якщо вам дуже хочеться повторного використання, починайте з маленьких final helper-класів і локальних утиліт поруч із тестами, а не з наслідування.
Помилка № 3: спільний пакет util, у який звалюють усе підряд.
Спочатку туди кладуть Urls, потім — фабрики DTO, потім — «зручний» метод, який робить запит і відразу перевіряє половину відповіді, потім — константи статусів, і врешті util перетворюється на альтернативний застосунок. Корисний helper має або бути маленьким і чесним, або бути локальним для своєї зони API.
Помилка № 4: helpers ховають HTTP-контракт замість того, щоб зменшувати шум.
Є тонка межа між «прибрали повторюваний рядок URL» і «сховали сенс тесту». Якщо тест читався як HTTP-сценарій, а після рефакторингу став читатися як виклик невідомого DSL, ви перемогли копіпасту, але програли у зрозумілості. У MVC-тестах це особливо болісно: ви тестуєте саме HTTP-границю, і вона має бути видимою.
Помилка № 5: змішування зон через повторне використання «зручних» речей.
Наприклад, ви зробили helper для editor-запитів (з multipart, заголовками, якимись типовими значеннями) і почали використовувати його в public тестах «бо зручно». Через деякий час public suite несподівано починає залежати від деталей editor API. Правильніше тримати helpers і фікстури в межах своєї зони, інакше ви отримаєте тестову зв’язаність там, де в продукті її немає.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ