JavaRush /Курсы /Spring Test /Границы full-context MockM...

Границы full-context MockMvc

Spring Test
20 уровень , 3 лекция
Открыта

1. Важность границ и ложной уверенности

Когда тесты начинают “зеленеть”, появляется опасное чувство: «ну всё, я теперь повелитель качества». Это чувство особенно коварно в web-тестах, потому что MockMvc выглядит как настоящий HTTP, и мозг автоматически дорисовывает то, чего там нет. Эта лекция — как ремень безопасности: не для того, чтобы ехать медленнее, а чтобы не улететь в кювет ложной уверенности.

В тестировании есть неофициальный закон сохранения самоуверенности: чем меньше вы понимаете границы инструмента, тем больше инструмент кажется “универсальным”. MockMvc в full-context режиме — сильный, удобный и профессиональный инструмент, но он не обязан доказывать всё, что связано с “реальным сервером”. И если вы начнёте проверять через него вещи, которые он не гарантирует, у вас получится тест, который выглядит солидно… и при этом в реальности защищает вас примерно как зонтик от акулы.

Полезная привычка: перед каждым интеграционным тестом мысленно формулировать одну фразу: «Этот тест доказывает X, но не доказывает Y». Сегодня мы научимся делать это не на уровне философии, а на уровне практических примеров из ContentHub.

2. Как работает full-context MockMvc

Если вы когда-нибудь думали, что MockMvc — это “мини-браузер”, то нет. Это скорее “дрессированный почтальон”: он приносит запрос прямо в ваш Spring MVC, аккуратно проходит через фильтры, DispatcherServlet, контроллеры, конвертеры JSON и @ControllerAdvice, а потом возвращается с ответом. Но всё это — внутри одного процесса JVM, без настоящего TCP-порта и без настоящего runtime сервера.

Чтобы “поймать” границу, полезно увидеть цепочку обработки в виде схемы. В режиме @SpringBootTest + @AutoConfigureMockMvc это выглядит примерно так:

flowchart TD
    T["JUnit тест"] --> M["MockMvc.perform(...)"]
    M --> F["Filters (в т.ч. Security filter chain)"]
    F --> D["DispatcherServlet (Spring MVC)"]
    D --> C["Controller"]
    C --> S["Service"]
    S --> R["Repository (JPA)"]
    R --> DB["Test DB (DataSource)"]
    C --> J["HttpMessageConverters (Jackson)"]
    C --> A["@ControllerAdvice / ExceptionHandler"]
    A --> J
    J --> RESP["MockHttpServletResponse"]

Здесь важно заметить две вещи. Во‑первых, всё слева от DispatcherServlet — “внутреннее”: MockMvc создаёт “моковый” servlet-запрос (MockHttpServletRequest), и Spring обрабатывает его как servlet-запрос, но это не запрос, пришедший “по сети” в контейнер. Во‑вторых, всё справа — максимально реальное для вашего приложения: настоящие Spring-бины, настоящая конфигурация, настоящие репозитории и база (в вашем выбранном test-path).

Поэтому правильно говорить так: full-context 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 — ваше новое хобби, а потому что некоторые классы багов живут именно там, “между клиентом и сервером”.

Например, через full-context 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")); // это не про реальный сервер
}

Проблема не в том, что “нельзя проверять headers”. Заголовки вроде Content-Type, Location, Cache-Control — отличная цель, потому что их формирует ваша MVC-логика и ваши компоненты. Проблема в том, что часть заголовков и поведения — это работа реального контейнера и реального сетевого стека, а MockMvc им не является. В этом режиме вы проверяете “что приложение собрало”, но не “как сервер отдал по сети”.

Есть ещё более тонкий момент: даже если вы проверяете заголовок, который действительно “ваш”, у вас всё равно нет проверки того, что клиент получил те же байты в той же форме. Потому что здесь нет настоящего HTTP‑клиента, который бы реально прочитал ответ по сети, интерпретировал кодировку, декодировал компрессию и так далее. Мы пока не уходим в тему “как это проверить правильно” — просто фиксируем границу.

4. Контейнерные особенности

Следующая крупная зона, которую легко переоценить: servlet-контейнер. В проде ваше MVC-приложение живёт внутри Tomcat/Jetty/Undertow (или их эквивалента), и у каждого контейнера есть собственные особенности: как он нормализует заголовки, какие ограничения накладывает на URL, как обрабатывает странные символы, как выставляет дефолтные заголовки, как ведёт себя со Trailing Slash, как работает multipart parsing и так далее. В MockMvc вы находитесь в “лабораторной колбе”: servlet-объекты — моковые, без реальной контейнерной реализации.

Чтобы это не звучало слишком абстрактно, вот простая таблица “что обычно честно в full-context MockMvc, а что уже ближе к серверной реальности”:

Тема В full-context MockMvc это обычно честно проверяется В full-context MockMvc это не гарантируется
@ControllerAdvice, ApiProblem Да: исключение → JSON error payload Да, но не “серверная HTML error page”
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 — это отдельный уровень ответственности (и в нашем курсе мы не превращаемся в курс по настройке TLS — просто понимаем границу доказательства).

5. Клиентская реальность

Даже если забыть про сеть и контейнер, остаётся ещё одна зона иллюзий: поведение реального клиента. MockMvc — это не браузер. Он не хранит cookies “как браузер”, не делает CORS preflight “как браузер по умолчанию”, не ведёт себя как мобильное приложение, не использует ваш реальный frontend-клиент, не повторяет все особенности HTTP библиотек.

Это означает, что через MockMvc вы не проверите некоторые вещи, которые “в бою” возникают именно из-за клиента. Например, бывают проблемы с тем, что реальный клиент отправляет Accept: */*, а ваш тест всегда пишет Accept: application/json, и вы не замечаете, что где-то включился неожиданный content negotiation. Или наоборот: в тестах вы не задаёте Accept, и Spring выбирает дефолтный converter, но реальный клиент всегда просит JSON, и вы живёте в другой реальности.

Хорошая новость: часть этих вещей можно приблизить в MockMvc, если вы дисциплинированно задаёте headers и тело запроса. Плохая новость: это всё равно будет “симуляция руками”, а не проверка реального клиента. Поэтому ожидания нужно формулировать честно: «мы проверили, что при таких-то заголовках и таком-то запросе приложение отвечает так-то».

Кстати, в full-context 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. Формулировка границ в тестах

Сейчас будет немного “писательской” части, но она крайне практичная: интеграционные тесты — дорогие. Они должны быть не только правильными, но и объяснимыми. А объяснимость начинается с имени и структуры.

В full-context 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 отдаёт опубликованную статью таким-то контрактом».

То же относится и к error contract. Хороший интеграционный тест на ошибку в 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. Именно поэтому их полезно проговаривать заранее, пока вы не построили вокруг них половину suite.

Первая классика — попытка “проверить серверную HTML-страницу ошибки”. В API-проекте это обычно вообще не цель, но даже если цель была бы, full-context 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, ваши бизнес‑headers, ваш ApiProblem.

7. Типичные ошибки full-context MockMvc

Ошибка №1: расширять выводы теста дальше его режима.
Самый частый сценарий звучит так: «Тест через MockMvc зелёный, значит реальный сервер точно отдаст то же самое». В реальности зелёный тест доказывает корректность работы MVC-цепочки внутри Spring, но не доказывает работу реального сетевого пути, поведения контейнера и реакции конкретного HTTP-клиента. Это не делает тест плохим — это делает плохой интерпретацию результата.

Ошибка №2: пытаться проверять “серверные” вещи, которые в этом режиме не обязаны существовать.
Когда в тесте появляются ожидания вроде Transfer-Encoding, “точный Content-Length”, “как именно сервер закрывает соединение”, “какой exact HTML отрендерился на 404”, вы часто тестируете не ваш API, а детали реализации мокового окружения. Такие ожидания дают красивый зелёный цвет, но защищают от регрессий хуже, чем простая проверка ApiProblem с errorCode.

Ошибка №3: формулировать тест как “проверяем всё приложение”, а не как одну конкретную историю поломки.
Интеграционный full-context тест по определению дорогой: он поднимает много инфраструктуры. Поэтому у него особенно важно иметь понятную цель. Если вы делаете один тест, который за раз проверяет “и статус, и половину JSON, и кучу заголовков, и пять разных веток”, то он становится хрупким и плохо диагностируемым: упал — и непонятно, из-за чего. В этом режиме лучше, чтобы каждый тест доказывал одну историю: “не найдено”, “отдал published”, “ошибка валидации превратилась в ApiProblem”.

Ошибка №4: молча надеяться на default-и и не фиксировать условия запроса.
Если вы в тесте не указываете Accept, не указываете Content-Type, не задаёте важные заголовки и параметры, вы проверяете “поведение по умолчанию”, которое легко случайно поменять конфигурацией. Потом реальный клиент приходит с другими заголовками, и всё внезапно “работает не так”. В full-context MockMvc лучше явнее задавать то, что для контракта важно — даже если кажется, что “и так понятно”.

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