1. Важливість меж і хибної впевненості
Коли тести починають «зеленіти», зʼявляється небезпечне відчуття: «Ну все, тепер я володар якості». Це відчуття особливо підступне у вебтестах, тому що MockMvc виглядає як справжній HTTP, і мозок автоматично домальовує те, чого там немає. Ця лекція — як ремінь безпеки: не для того, щоб їхати повільніше, а для того, щоб не вилетіти в кювет хибної впевненості.
У тестуванні є неофіційний закон збереження самовпевненості: що менше ви розумієте межі інструмента, то універсальнішим він здається. MockMvc у повноконтекстному режимі — потужний, зручний і професійний інструмент, але він не зобовʼязаний доводити все, що повʼязане з реальним сервером. І якщо ви почнете перевіряти через нього речі, які він не гарантує, у вас вийде тест, що виглядає солідно, але в реальності захищає вас приблизно як парасоля від акули.
Корисна звичка: перед кожним інтеграційним тестом подумки формулювати одну фразу: «Цей тест доводить X, але не доводить Y». Сьогодні ми навчимося робити це не на рівні філософії, а на рівні практичних прикладів із ContentHub.
2. Як працює повноконтекстний MockMvc
Якщо ви колись думали, що MockMvc — це «мінібраузер», то ні. Це радше дресований листоноша: він приносить запит просто у ваш Spring MVC, акуратно проходить через фільтри, DispatcherServlet, контролери, JSON-конвертери й @ControllerAdvice, а потім повертається з відповіддю. Але все це відбувається всередині одного процесу JVM, без справжнього TCP-порту і без окремого серверного процесу.
Щоб «зловити» межу, корисно побачити ланцюжок обробки у вигляді схеми. У режимі @SpringBootTest + @AutoConfigureMockMvc це виглядає так:
flowchart TD
T["Тест JUnit"] --> M["MockMvc.perform(...)"]
M --> F["Фільтри (зокрема ланцюжок Security-фільтрів)"]
F --> D["DispatcherServlet (Spring MVC)"]
D --> C["Контролер"]
C --> S["Сервіс"]
S --> R["Repository (JPA)"]
R --> DB["Тестова БД (DataSource)"]
C --> J["HttpMessageConverters (Jackson)"]
C --> A["@ControllerAdvice / ExceptionHandler"]
A --> J
J --> RESP["MockHttpServletResponse"]
Тут важливо помітити дві речі. По-перше, усе ліворуч від DispatcherServlet — «внутрішнє»: MockMvc створює «моковий» servlet-запит (MockHttpServletRequest), і Spring обробляє його як servlet-запит, але це не запит, що прийшов мережею до контейнера. По-друге, усе праворуч — максимально реальне для вашого застосунку: справжні Spring-beans, справжня конфігурація, справжні репозиторії та база даних у вибраному тестовому оточенні.
Тому правильно говорити так: повноконтекстний MockMvc доводить коректність MVC-ланцюжка всередині застосунку, але не доводить коректність мережевого і контейнерного шару навколо застосунку.
Мінікаркас такого тесту виглядає звично:
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
class PublicArticleFullContextMockMvcTest {
// Важливо: це режим MOCK — справжнього сервера на порту немає.
// Тут ми перевіряємо MVC-ланцюжок усередині застосунку, а не мережевий шар.
}
Зверніть увагу на webEnvironment = SpringBootTest.WebEnvironment.MOCK: це пряма підказка в коді, яка каже майбутньому вам або колезі: «Друзі, сервера на порту немає, не шукайте його».
3. Немає реального мережевого шляху
Коли ви робите mockMvc.perform(get("/api/public/articles/spring-basics")), ви не відкриваєте сокет, не запускаєте DNS, не проходите TCP і не перевіряєте, як усе летить мережею. Це важливо не тому, що TCP — ваше нове хобі, а тому, що деякі класи багів живуть саме там — між клієнтом і сервером.
Наприклад, через повноконтекстний MockMvc ви не доведете коректність «транспортних» речей: компресії (gzip), chunked transfer encoding, обмежень розміру заголовків на рівні контейнера, тонкощів keep-alive, таймаутів зʼєднання, особливостей роботи проксі та балансувальників. Ви можете поставити очікування на якийсь Transfer-Encoding і отримати «зелений» тест, але це буде зелений тест про MockHttpServletResponse, а не про реальну відповідь Tomcat чи Jetty у продакшені.
Ось приклад хорошого очікування в цьому режимі: воно про контракт застосунку (status + JSON), а не про транспорт:
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void returnsProblemWhenSlugMissing() throws Exception {
// Доводимо: контракт помилки (status + JSON-поля), а не транспортні деталі відповіді.
mockMvc.perform(get("/api/public/articles/missing-slug"))
.andExpect(status().isNotFound())
// Важливо: перевіряємо бізнес-код помилки, який стабільний для клієнтів.
.andExpect(jsonPath("$.errorCode").value("ARTICLE_NOT_FOUND"));
}
А ось приклад очікування, яке виглядає «дорослим», але в цьому режимі майже беззмістовне і часто нестабільне:
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
@Test
void doesNotProveChunkedTransfer() throws Exception {
// Важливо: це перевіряє заголовки на MockHttpServletResponse, а не поведінку реального контейнера.
mockMvc.perform(get("/api/public/articles"))
.andExpect(header().exists("Transfer-Encoding")); // це не про реальний сервер
}
Проблема не в тому, що «не можна перевіряти заголовки». Заголовки на кшталт Content-Type, Location, Cache-Control — чудова ціль, тому що їх формує ваша MVC-логіка і ваші компоненти. Проблема в тому, що частина заголовків і поведінки — це робота реального контейнера та реального мережевого стеку, а MockMvc ним не є. У цьому режимі ви перевіряєте те, що застосунок зібрав, але не те, як сервер віддав це по мережі.
Є ще тонший момент: навіть якщо ви перевіряєте заголовок, який справді ваш, ви все одно не перевіряєте, що клієнт отримав ті самі байти в тій самій формі. Тут немає справжнього HTTP-клієнта, який би реально прочитав відповідь по мережі, інтерпретував кодування, декодував компресію і так далі. Ми поки не переходимо до того, як перевірити це правильно, — просто фіксуємо межу.
4. Контейнерні особливості
Наступна велика зона, яку легко переоцінити, — servlet-контейнер. У продакшені ваш MVC-застосунок живе всередині Tomcat, Jetty, Undertow або їхнього еквівалента, і в кожного контейнера є власні особливості: як він нормалізує заголовки, які обмеження накладає на URL, як обробляє дивні символи, як виставляє дефолтні заголовки, як поводиться зі Trailing Slash, як працює multipart parsing і так далі. У MockMvc ви перебуваєте в «лабораторній колбі»: servlet-обʼєкти — мокові, без реальної контейнерної реалізації.
Щоб це не звучало надто абстрактно, ось проста таблиця «що зазвичай чесно перевіряється у повноконтекстному MockMvc, а що вже ближче до серверної реальності»:
| Тема | У повноконтекстному MockMvc це зазвичай чесно перевіряється | У повноконтекстному MockMvc це не гарантується |
|---|---|---|
| @ControllerAdvice, ApiProblem | Так: виняток → JSON-дані помилки | Так, але не «серверна HTML-сторінка помилки» |
| Jackson серіалізація DTO | Так: HttpMessageConverters реальні | Але не факт щодо «байти в мережі + компресія» |
| Security filter chain | Часто так: фільтри справді в ланцюжку | Але не всі ефекти контейнера і браузера навколо |
| Multipart upload логіка на рівні Spring MVC | Так, у межах mock request | Але потокова природа, ліміти контейнера, нюанси парсингу можуть відрізнятися |
| «Дефолтні» заголовки контейнера | Не ціль режиму | Не ціль режиму |
Звідси практичний висновок: якщо ваше очікування звучить як «Tomcat зобовʼязаний…», то MockMvc — це майже напевно не той рівень. Якщо ж очікування звучить як «Наш контролер, фільтр або адвайс зобовʼязаний…», то MockMvc якраз ваш інструмент.
Ще один приклад, який часто вводить новачків в оману, — HTTPS. У MockMvc ви можете зробити запит «ніби secure», але це буде імітація прапорця, а не реальний TLS-обмін.
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void secureFlagIsNotRealTls() throws Exception {
// Доводимо: реакцію застосунку на request.isSecure().
// НЕ доводимо: реальний TLS-хендшейк, сертифікати та поведінку HTTPS у продакшені.
mockMvc.perform(get("/api/public/articles").secure(true))
.andExpect(status().isOk());
}
Це може бути корисно, якщо ваша логіка в застосунку, наприклад генерація посилань, залежить від request.isSecure(). Але це не тест «у нас справді все працює по HTTPS». Це тест «ми правильно реагуємо на прапорець secure всередині Spring MVC». У реальному світі TLS — це окремий рівень відповідальності, і в нашому курсі ми не перетворюємося на курс із його налаштування. Нам достатньо розуміти межу доведення.
5. Клієнтська реальність
Навіть якщо забути про мережу й контейнер, залишається ще одна зона ілюзій: поведінка реального клієнта. MockMvc — це не браузер. Він не зберігає cookies як браузер, не робить CORS preflight як браузер за замовчуванням, не поводиться як мобільний застосунок, не використовує ваш реальний frontend-клієнт і не відтворює всі особливості HTTP-бібліотек.
Це означає, що через MockMvc ви не перевірите деякі речі, які в бою виникають саме через клієнта. Наприклад, реальний клієнт може надсилати Accept: */*, а ваш тест завжди вказує Accept: application/json, і ви не помічаєте, що десь увімкнулося неочікуване content negotiation. Або навпаки: у тестах ви не задаєте Accept, і Spring обирає дефолтний converter, але реальний клієнт завжди просить JSON, тож ви живете в іншій реальності.
Хороша новина: частину цих речей можна наблизити в MockMvc, якщо дисципліновано задавати заголовки та тіло запиту. Погана новина: це все одно буде симуляція вручну, а не перевірка реального клієнта. Тому очікування потрібно формулювати чесно: «ми перевірили, що за таких-то заголовків і такого-то запиту застосунок відповідає так-то».
До речі, у повноконтекстному MockMvc є дуже здоровий, навчально-показовий прийом: у тесті явно фіксувати, який саме аспект ви доводите. Навіть просто коментарем.
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void returnsJsonWhenClientRequestsJson() throws Exception {
// Доводимо: JSON-контракт застосунку при Accept: application/json.
// НЕ доводимо: поведінку браузера, CORS preflight і нюанси конкретної HTTP-бібліотеки клієнта.
mockMvc.perform(get("/api/public/articles").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
// Перевіряємо медіатип відповіді на рівні MVC (не транспортні деталі).
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON));
}
Ця маленька ремарка різко знижує ризик того, що ви самі або колега через місяць почнете сприймати тест як універсальну гарантію поведінки для всіх клієнтів. У тестуванні дуже часто баг — це не баг коду, а баг очікувань у голові.
6. Формулювання меж у тестах
Зараз буде трохи письменницька частина, але вона вкрай практична: інтеграційні тести дорогі. Вони мають бути не лише правильними, а й зрозумілими. А зрозумілість починається з імені та структури.
У повноконтекстному MockMvc особливо важливо не писати тести з назвами на кшталт testEndpoint() або shouldWork(). Вони читаються як «ну, наче все працює», і спокуса домалювати зайве лише зростає. Краще, щоб назва прямо фіксувала: який саме endpoint, який сценарій, яка межа.
Порівняйте два варіанти:
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void shouldWork() throws Exception {
// Погано: з назви неясно, що саме доводимо і що НЕ доводимо.
mockMvc.perform(get("/api/public/articles/spring-basics"))
.andExpect(status().isOk());
}
і більш чесний варіант: так, довший, але це нормально для інтеграційного тесту:
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void getPublicArticle_returns200AndPublishedArticleJson_whenSlugExists() throws Exception {
// Доводимо: endpoint віддає published-статтю і ключові поля JSON-контракту.
// НЕ доводимо: транспортні заголовки, роботу реального порту, поведінку проксі тощо.
mockMvc.perform(get("/api/public/articles/spring-basics"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.slug").value("spring-basics"))
.andExpect(jsonPath("$.status").value("PUBLISHED"));
}
Другий варіант не просто красивіший. Він змушує вас мислити межами: ви перевіряєте 200, JSON і ключові поля. Ви не обіцяєте «реальний сервер», не обіцяєте «все на світі». Ви обіцяєте дуже конкретне: якщо slug існує, то public endpoint віддає опубліковану статтю з таким-то контрактом.
Те саме стосується й контракту помилки. Хороший інтеграційний тест на помилку в ContentHub часто цінніший, ніж тест на happy path, тому що помилка — це місце, де ламається половина клієнтських інтеграцій.
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void getPublicArticle_returnsApiProblem404_whenArticleNotFound() throws Exception {
// Доводимо: за відсутності статті повертається наш стабільний API-контракт помилки.
mockMvc.perform(get("/api/public/articles/no-such-slug"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.errorCode").value("ARTICLE_NOT_FOUND"));
}
Зверніть увагу: ми не пишемо returnsWhitelabelErrorPage. Тому що whitelabel — це вже ближче до того, як сервер рендерить HTML у деяких режимах, а ми в цьому курсі робимо API й хочемо стабільний JSON ApiProblem. Це й є чесна межа.
Мініантипатерни очікувань
Зараз розберемо кілька ситуацій, які особливо часто трапляються у новачків. Вони не «дурні» — вони просто природно випливають із того, що MockMvc дуже схожий на HTTP. Саме тому їх корисно проговорювати заздалегідь, поки ви не побудували навколо них половину набору тестів.
Перша класика — спроба перевірити серверну HTML-сторінку помилки. У API-проєкті це зазвичай узагалі не ціль, але навіть якби ціль була, повноконтекстний MockMvc — не той режим, де це чесно доводити.
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void unknownPage_onlyStatusIsMeaningfulHere() throws Exception {
// Доводимо лише статус (404). HTML, рендеринг і сторінка помилки — це інший рівень.
mockMvc.perform(get("/unknown-page"))
.andExpect(status().isNotFound());
}
Це нормальний тест, якщо ви хочете просто довести 404. Але якщо ви почнете перевіряти точний HTML, точний текст сторінки, точні server-generated headers, ви дуже швидко опинитеся у світі, де тести перевіряють особливості мокової відповіді, а не реальні умови.
Друга класика — очікування на транспортні речі тому, що «так написано в підручнику з HTTP». Проблема не в HTTP. Проблема в рівні тесту. Якщо ви хочете довести, що реальний сервер віддає відповідь із Content-Length у конкретному вигляді, то MockMvc не зобовʼязаний бути вашим Tomcat. У цьому режимі краще триматися тих частин контракту, за які відповідає ваш застосунок: status, content type, JSON payload, Location, ваші бізнес-заголовки, ваш ApiProblem.
7. Типові помилки повноконтекстного MockMvc
Помилка №1: розширювати висновки тесту за межі його режиму.
Найчастіший сценарій звучить так: «Тест через MockMvc зелений, отже реальний сервер точно віддасть те саме». У реальності зелений тест доводить коректність роботи MVC-ланцюжка всередині Spring, але не доводить роботу реального мережевого шляху, поведінку контейнера та реакцію конкретного HTTP-клієнта. Це не робить тест поганим — поганою робить інтерпретація результату.
Помилка №2: намагатися перевіряти «серверні» речі, які в цьому режимі не зобовʼязані існувати.
Коли в тесті зʼявляються очікування на кшталт Transfer-Encoding, «точний Content-Length», «як саме сервер закриває зʼєднання», «який саме HTML відрендерився на 404», ви часто тестуєте не свій API, а деталі реалізації мокового оточення. Такі очікування дають гарний зелений колір, але захищають від регресій гірше, ніж проста перевірка ApiProblem з errorCode.
Помилка №3: формулювати тест як «перевіряємо весь застосунок», а не як одну конкретну історію поломки.
Інтеграційний повноконтекстний тест за визначенням дорогий: він підіймає багато інфраструктури. Тому в нього особливо важливо мати зрозумілу мету. Якщо ви робите один тест, який за раз перевіряє і статус, і половину JSON, і купу заголовків, і пʼять різних гілок, він стає крихким і погано діагностується: упав — і незрозуміло, через що. У цьому режимі краще, щоб кожен тест доводив одну історію: «не знайдено», «віддав published», «помилка валідації перетворилася на ApiProblem».
Помилка №4: мовчки сподіватися на налаштування за замовчуванням і не фіксувати умови запиту.
Якщо ви в тесті не вказуєте Accept, не вказуєте Content-Type, не задаєте важливі заголовки та параметри, ви перевіряєте «поведінку за замовчуванням», яку легко випадково змінити конфігурацією. Потім реальний клієнт приходить з іншими заголовками, і все раптом «працює не так». У повноконтекстному MockMvc краще явно задавати те, що важливо для контракту, — навіть якщо здається, що й так зрозуміло.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ