1. Вступ
Коли один і той самий HTTP-сценарій уже встиг пройти через міграцію, інфраструктурні ефекти й binding, залишається останнє цілком земне питання: що вважати нормою для MVC-suite. Єдиний стиль потрібен не заради естетики, а щоб тести читалися як документація і щоб під час падіння ви витрачали час на причину помилки, а не на перемикання між трьома діалектами перевірок.
Якщо сусідні класи описують один і той самий HTTP-світ різними мовами, мозок постійно перемикає передачі. Це не трагедія, але це накопичена вартість підтримки. Тому тут потрібно не ще раз порівнювати API, а зафіксувати просте правило: який стиль вважається за замовчуванням, де припустимі винятки і які хелпери не перетворюють suite на магічний ліс.
raw MockMvc і MockMvcTester
Тут достатньо тримати в голові два факти. По-перше, raw MockMvc і MockMvcTester працюють поверх одного й того самого MVC slice. По-друге, вибір між ними — це вибір синтаксису і читабельності, а не “сили” тесту.
Цього вже достатньо, щоб перейти до робочого правила. Докладно порівнювати механіку тут більше не потрібно: далі важливіше саме правило вибору.
2. Правило вибору: інструмент за замовчуванням і винятки
Коли в проєкті є два зручні інструменти, мозок природно хоче використовувати обидва. Це нормально. Ненормально — використовувати обидва так, що тести виглядають як випадкова суміш. Тому правило, яке добре працює для MVC-suite в ContentHub, звучить просто: обираємо один стиль «за замовчуванням» у межах пакета/класу, а другий залишаємо як усвідомлений запасний інструмент.
Для такого MVC-suite логічний вибір за замовчуванням — MockMvcTester. Він добре лягає на вже обраний нами базовий стиль перевірок (AssertJ) і зазвичай робить успішні сценарії та типові перевірки коротшими. Але raw MockMvc не потрібно відкидати. Він залишається сильним тоді, коли вам потрібно:
- виразити перевірку через дуже специфічний matcher (наприклад, ви вже звикли до header().string(...) і він вам зараз зрозуміліший, ніж «намагатися зробити красиво»);
- зробити перевірку настільки низькорівневою, що fluent-стиль починає заважати (інколи тест простіше читати як «і очікувати ось це, і очікувати ось це», ніж як довгий ланцюжок).
Щоб не перетворювати це на список «з 17 пунктів», корисніше тримати в голові просту матрицю вибору:
| Ситуація в тесті | Переважно | Чому |
|---|---|---|
| Звичайний HTTP-сценарій «запит → статус → кілька базових перевірок» | MockMvcTester | Швидше читається як сценарій, менше службового коду |
| Потрібен дуже специфічний matcher зі звичного набору MockMvc | raw MockMvc | Зрозуміліший намір, менше «обгорток заради обгорток» |
| В одному тесті хочеться перевірити багато різних аспектів відповіді | MockMvcTester | Зручно писати ланцюжкові перевірки, але не варто захоплюватися |
Ключовий момент: ми обираємо інструмент не за принципом “красивіше”, а за принципом “зрозуміліше наступному читачеві”. Наступний читач — це або ваш колега, або ви самі через кілька тижнів. І, на жаль, «ви через кілька тижнів» зазвичай не памʼятаєте, що ви мали на увазі вчора о 23:47.
4. Змішування MockMvcTester і raw MockMvc
Змішування двох інструментів у проєкті допустиме, але його потрібно дисциплінувати, інакше вийде класична ситуація: «в одному тесті MockMvcTester, у наступному raw, потім знову tester, потім хтось додав допоміжний DSL, і тепер ніхто не знає, що вважається нормою». Дисципліна тут не про бюрократію, а про те, щоб тести були передбачуваними.
Найзрозуміліша межа — один стиль на один тестовий клас. Тобто якщо PublicArticleControllerWebMvcTest написаний через MockMvcTester, то «за замовчуванням» усі тести в цьому класі використовують mvc (tester). Якщо раптом потрібен raw для однієї суперточної перевірки, його можна застосувати точково, але краще зробити це явно і зрозуміло (і не перетворювати «точково» на «половина тестів класу»).
Технічно ви можете інʼєктувати обидва інструменти — це нормально. Головне — не перетворювати це на «подвійне керування» без правила.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
class PublicArticleControllerWebMvcTest {
@Autowired
MockMvcTester mvc; // Основний інструмент за замовчуванням: fluent/AssertJ-стиль поверх MockMvc
@Autowired
MockMvc rawMvc; // Запасний інструмент: точкові перевірки через perform + andExpect
}
Тут ми заздалегідь чесно говоримо: у нас є default (mvc) і запасний ключ від «старого замка» (rawMvc). І далі дотримуємося дисципліни: в межах одного тестового методу не змішуємо два API. Це схоже на ситуацію з двома мовами програмування в одному мікросервісі: можна, але якщо кожну функцію писати своєю мовою, супроводжуваність закінчиться швидко.
Ще один важливий принцип — не намагатися сховати HTTP-деталі під час змішування. Іноді люди починають робити так: «Раз MockMvcTester гарний, давайте загорнемо URI, headers і ще половину в хелпери». У підсумку тест стає коротким, але абсолютно нечитабельним, бо з нього незрозуміло, яку кінцеву точку взагалі перевіряємо. Краса перемогла зміст, а зміст у тестах — це, взагалі-то, головне.
5. Хелпери без магії
Хелпери в MVC-тестах — річ підступна. Вони можуть реально допомогти, а можуть непомітно перетворити тести на «мініфреймворк поверх тестів», де щоб зрозуміти один кейс, потрібно відкрити ще пʼять файлів. У ContentHub ми хочемо рівно протилежного: щоб тест читався як HTTP-сценарій і не ховав ключові деталі.
Хороший хелпер у controller-тестах зазвичай робить одну з двох речей: або допомагає збирати повторювані частини URI, або допомагає тримати однакові технічні налаштування (наприклад, JSON Accept) без дублювання — але так, щоб це не приховувало зміст.
Приклад безпечного хелпера — зібрати URL, не ховаючи кінцеву точку:
private String publicArticlesUri(int page, int size) {
// Хелпер не ховає кінцеву точку: у рядку явно видно шлях і параметри запиту
return "/api/public/articles?page=" + page + "&size=" + size;
}
Тест із таким хелпером і далі читається нормально: ви бачите, що це /api/public/articles, і бачите, які параметри туди потрапили.
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
@Test
void shouldUseTesterAsDefault() {
// Запит залишається «HTTP-явним»: видно і URI, і Accept
assertThat(mvc.get()
.uri(publicArticlesUri(0, 10))
.accept(MediaType.APPLICATION_JSON))
.hasStatusOk(); // Перевіряємо найважливіше: коректний HTTP-статус
}
А ось приклад хелпера, який виглядає «круто», але зазвичай шкідливий. Він ховає занадто багато і перетворює тест на вгадування:
private Object getPublicArticlesResult(int page, int size) {
// Погана ознака: хелпер повертає «щось» і ховає деталі запиту всередині себе
return mvc.get()
.uri(publicArticlesUri(page, size))
.accept(MediaType.APPLICATION_JSON);
}
І потім тест стає таким:
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
@Test
void shouldReturnOk_butWhatEndpointIsIt() {
// З тесту не видно ні кінцевої точки, ні методу, ні важливих заголовків — доводиться йти читати хелпер
assertThat(getPublicArticlesResult(0, 10))
.hasStatusOk();
}
Формально все працює. Але зміст зникає. Через місяць ви дивитиметеся на це і думатимете: «Ок, а це точно public? а не editor? а параметри точно ті?». У підсумку ви все одно відкриєте хелпер, а отже, ви втратили основну цінність тесту — бути читабельним одразу.
Маленький практичний орієнтир: якщо хелпер ховає HTTP method, кінцеву точку або робить тест «занадто універсальним», він майже напевно погіршує ситуацію. В ідеалі, навіть із хелперами в коді тесту мають залишатися: URI (або його явна частина), метод запиту і ключові headers. Усе інше — другорядне.
6. Шаблон тест-класу для ContentHub
Коли ми говоримо «єдиний стиль», корисно мати в голові один шаблон, за яким виглядатиме більшість тестів. Не як суворий корпоративний стандарт, а як звичний ритм: спочатку підготували стаб (якщо потрібен), потім відправили запит, потім зробили кілька важливих перевірок.
Покажемо це на публічному контролері, де ми читаємо статтю за slug. Ми не будемо заглиблюватися в тіло відповіді — нам зараз важливіша структура тесту і вибір інструмента.
Мінімальний каркас MVC slice-тесту з default на MockMvcTester:
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
@WebMvcTest(PublicArticleController.class) // Піднімаємо лише MVC-шар навколо вказаного контролера
class PublicArticleControllerWebMvcTest {
@Autowired
MockMvcTester mvc; // Інструмент за замовчуванням для читабельних HTTP-перевірок
}
Якщо у контролера є залежність від сервісу, ми, як завжди, створюємо для неї мок. І тут приємно, що міграція на MockMvcTester взагалі не змінює частину з Mockito: створення заглушок лишається створенням заглушок.
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
@WebMvcTest(PublicArticleController.class) // Це все ще slice-тест: реальний MVC, але залежності контролера — моки
class PublicArticleControllerWebMvcTest {
@Autowired
MockMvcTester mvc;
@Autowired
MockMvc rawMvc; // Точковий запасний інструмент для рідкісних low-level перевірок
@MockitoBean
ArticleQueryService articleQueryService; // Мокаємо сервіс, щоб керувати сценаріями та відповідями
}
Тепер тест, де ми зберігаємо HTTP-деталі явними: URI і Accept.
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
@Test
void shouldReturnPublishedArticle() {
// Готуємо стаб: контролюємо, що поверне сервіс для конкретного slug
given(articleQueryService.findBySlug("spring-basics"))
.willReturn(articleResponse); // заздалегідь підготовлений DTO
// Явно показуємо HTTP-контракт: кінцеву точку і заголовок Accept
assertThat(mvc.get()
.uri("/api/public/articles/spring-basics")
.accept(MediaType.APPLICATION_JSON))
.hasStatusOk(); // Мінімальна базова перевірка — HTTP 200
}
А тепер — приклад «усвідомленого винятку». Припустімо, у нас є фільтр/інфраструктурний компонент, який додає заголовок зони доступу (наприклад, X-ContentHub-Zone: public). Raw MockMvc іноді в таких точкових перевірках заголовків читається просто і звично. Це не привід перемикати весь клас назад на raw; просто для цієї однієї низькорівневої перевірки синтаксис виходить чеснішим.
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.beans.factory.annotation.Autowired;
@Test
void shouldExposePublicZoneHeader() throws Exception {
// Тут навмисно використовуємо raw MockMvc: matcher-и для заголовків читаються дуже прямо
rawMvc.perform(get("/api/public/articles"))
.andExpect(status().isOk()) // Перевіряємо статус
.andExpect(header().string("X-ContentHub-Zone", "public")); // Перевіряємо інфраструктурний заголовок
}
Тут важливо не те, що "raw кращий". Важливо, що в нас є правило: tester — за замовчуванням, raw — точковий інструмент під конкретну форму перевірки. У підсумку пакет тестів виглядає однорідно: більшість сценаріїв читаються як стиль AssertJ, а рідкі технічні перевірки не ламають загальну картину.
7. Типові помилки під час вибору стилю MVC-suite
Помилка № 1: «У нас немає дефолту, у нас є свобода».
Свобода без дефолту зазвичай закінчується тим, що кожен пише так, як звик. У підсумку suite схожий на клаптикову ковдру: десь andExpect, десь assertThat, десь «супер-хелпери», десь узагалі незрозуміло що. Виправляти це потім важко, бо спір уже не про код, а про звички.
Помилка № 2: змішувати raw MockMvc і MockMvcTester всередині одного тестового методу.
Іноді хочеться почати через tester («красиво»), а потім додати пару andExpect («ну тут же один matcher»). У підсумку тест перетворюється на гібрид, який важко читати і важко підтримувати: у нього немає однієї мови. Краще вибрати один API на тест і дотримуватися його.
Помилка № 3: ховати HTTP-семантику в хелпери заради скорочення рядків.
Зробити тест у три рядки приємно. Але якщо ці три рядки не показують, який endpoint викликано, які заголовки важливі і які параметри передано, тест перестає бути документацією. Це особливо небезпечно в controller-тестах, бо їхня цінність якраз у явності HTTP-контракту.
Помилка № 4: перетворювати MockMvcTester на «один величезний ланцюжок на 15 перевірок».
Fluent-стиль провокує писати довгі chained assertions. Це добре доти, доки ланцюжок залишається читабельним. Але якщо ви в одному ланцюжку перевіряєте статус, заголовки, десять полів JSON і ще щось, тест починає виглядати як романи Толстого: ніби твір великий, але читати щодня важко. Краще залишити лише ключові перевірки й дробити їх за змістом.
Помилка № 5: обирати стиль за принципом «як менше рядків», а не за принципом «як ясніше намір».
Іноді raw MockMvc у конкретній перевірці виглядає зрозумілішим. Іноді MockMvcTester робить сценарій ближчим до людського читання. Це нормально. Проблема починається, коли критерій — тільки кількість рядків. Тести — не змагання з мініфікації. Вони мають пояснювати поведінку, а не демонструвати акробатику API.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ