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 лучше явнее задавать то, что для контракта важно — даже если кажется, что “и так понятно”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ