JavaRush /Курси /Spring Test /Межі повноконтекстного Moc...

Межі повноконтекстного MockMvc

Spring Test
Рівень 20 , Лекція 3
Відкрита

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 краще явно задавати те, що важливо для контракту, — навіть якщо здається, що й так зрозуміло.

1
Задача
Spring Test, 20 рівень, 3 лекція
Недоступна
MOCK-оточення без реального порту
MOCK-оточення без реального порту
1
Задача
Spring Test, 20 рівень, 3 лекція
Недоступна
Різна зона перевірки для API та невідомого шляху
Різна зона перевірки для API та невідомого шляху
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ