1. DTO vs сломанный запрос
Если JSON прочитался и DTO собрался, мы уже знаем следующий вопрос: пройдёт ли объект Bean Validation. Теперь разбираем другой класс проблем — ситуации, где запрос даже не удалось нормально собрать. Это похоже на разницу между «вы пришли в банк без нужных документов» и «вы пришли, но заявление заполнено криво»: в обоих случаях вас развернут, но процесс “сломался” на разных шагах — и тест должен это различать.
Валидация (@Valid) начинается только тогда, когда Spring уже смог прочитать тело, распарсить JSON и создать DTO. А malformed JSON и type mismatch ломают запрос раньше: на стадии чтения тела или конвертации параметров. Результат для клиента часто выглядит одинаково (400 Bad Request), и именно поэтому очень легко написать тест “не про то” и получить зелёный цвет, который ничего не доказывает.
Давайте зафиксируем это в одной маленькой таблице. Она не про «все статусы мира», а именно про то, чтобы не путать этапы обработки:
| Ситуация | JSON синтаксически ок? | DTO создан? | Bean Validation запускается? | Controller method реально вызывается? | Типичный итог |
|---|---|---|---|---|---|
| Validation failure (@NotBlank, @Size) | да | да | да | нет | 400 |
| Malformed JSON (сломанный синтаксис) | нет | нет | нет | нет | 400 |
| Message conversion failure (JSON ок, но не маппится в DTO) | да | нет | нет | нет | 400 |
| Type mismatch (например, id=abc для Long) | неважно | неважно | нет | нет | 400 |
Для сценария @RequestBody + @Valid, на котором держится этот уровень, handler method тоже не вызывается: MethodArgumentNotValidException возникает ещё на этапе подготовки аргумента. Другие варианты method validation сейчас не трогаем, чтобы не смешивать разные механики.
Последняя колонка («типичный итог») специально сформулирована осторожно. Мы не строим “религию статусов” прямо сейчас; нам важнее понять, почему запрос не проходит и на каком этапе он отваливается. Это и будет предметом наших MVC-тестов.
2. Где ломается запрос в Spring MVC
Чтобы писать точные негативные тесты, полезно представлять себе обработку запроса как конвейер. Spring MVC не «телепортирует» JSON прямо в ваш метод контроллера — он проходит последовательность шагов: выбирается handler, конвертируются параметры, читается тело, создаётся DTO, запускается валидация. Если тест не понимает, на каком шаге упало — он легко начинает проверять случайные симптомы вместо причины.
Упростим пайплайн до уровня, достаточного автору тестов (без погружения в сотни внутренних классов). В реальности шагов больше, но нам важны точки, где возникают сегодняшние ошибки:
flowchart TD
A[HTTP Request] --> B[HandlerMapping выбрал controller method]
B --> C[Конвертация аргументов метода]
C --> C1["@PathVariable / @RequestParam String->Long/Enum/…"]
C --> C2["@RequestBody HttpMessageConverter + Jackson"]
C2 --> D[Binding: собрать DTO]
D --> E["Bean Validation: @Valid"]
E --> F[Вызов controller method]
F --> G[Controller вызывает service]
G --> H[HTTP Response]
C1 -. type mismatch .-> X[400]
C2 -. malformed JSON / conversion failure .-> X[400]
E -. constraint violations .-> X[400]
Обратите внимание на коварный момент: разные ошибки сходятся в один и тот же статус 400, но падают на разных стрелках. Валидация — это уже почти финишная прямая перед вызовом метода. Malformed JSON — это “не прошли даже на вход в DTO”. Type mismatch — это ещё раньше: даже аргументы метода (например, Long id) собрать не смогли.
Практическое следствие для тестов очень простое, но полезное: если ошибка произошла на ранней стадии, то ваш service не должен быть вызван вообще. И это хороший, понятный и довольно устойчивый assertion, который защищает нас от случайного теста “не туда”.
3. Malformed JSON и нечитаемое тело
Тело запроса — это место, где студенты чаще всего ловят чувство “я вроде отправил JSON, почему Spring не рад?”. Причина обычно в том, что JSON либо синтаксически сломан, либо синтаксически корректен, но не может быть десериализован в ваш DTO (например, поле ожидает строку, а пришло число). В обоих случаях @Valid не запускается, потому что DTO не появилось — Spring не из чего валидировать.
Malformed JSON: сломанный синтаксис
Malformed JSON — это когда тело запроса нельзя распарсить как JSON вообще. Типичные примеры: не закрыли фигурную скобку, забыли кавычку, поставили запятую не там, написали JSON “почти как в JavaScript” (а JSON на это обижается).
В ContentHub это часто проявляется на editor endpoint:
- POST /api/editor/articles (создание черновика),
- PUT /api/editor/articles/{id} (обновление).
Для теста нам не нужна сложная подготовка: достаточно отправить заведомо сломанный JSON и проверить, что получаем 400, а сервис не вызывается.
@Test
void shouldReturn400_whenJsonIsMalformed() throws Exception {
// Синтаксически битое тело: не закрыли фигурную скобку
String body = """
{"title":"A"
""";
mvc.perform(post("/api/editor/articles")
// Важно: тестируем именно JSON-чтение, поэтому Content-Type честный
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
// Маркер, что запрос отвалился ДО controller/service (на чтении тела)
then(service).shouldHaveNoInteractions();
}
Здесь важны две мысли. Первая: мы явно ставим Content-Type: application/json, иначе ошибка может стать другой (например, 415), и тест начнёт проверять не то, что мы хотели. Вторая: shouldHaveNoInteractions — наш маркер, что конвейер даже не дошёл до service-layer.
Иногда полезно убедиться, что мы действительно поймали “ошибку чтения тела”, а не, например, Bean Validation. Для этого можно проверить тип исключения, которое Spring “разрулил” внутри MVC.
import static org.assertj.core.api.Assertions.assertThat;
@Test
void shouldExposeHttpMessageNotReadableException_forMalformedJson() throws Exception {
mvc.perform(post("/api/editor/articles")
.contentType(MediaType.APPLICATION_JSON)
// Минимальный “битый” JSON: открыли объект, но не закрыли
.content("{"))
.andExpect(result -> assertThat(result.getResolvedException())
// Проверяем именно ошибку чтения тела (а не ошибки @Valid)
.isInstanceOf(HttpMessageNotReadableException.class));
}
Это не обязательно для каждого теста, но как “страховка от путаницы” в учебном проекте — очень полезно. Особенно когда вы ещё нарабатываете привычку не смешивать разные классы негативных сценариев.
JSON валидный, но DTO не собрать
Есть более хитрая ситуация: JSON синтаксически корректен, но Jackson не может создать DTO. Пример из жизни: поле title ожидает строку, а вы прислали число. Или поле category в DTO — enum, а вы прислали значение, которого нет.
Для Spring это всё ещё “ошибка чтения тела”: он пытается прочитать тело и превратить его в объект, но не может. Часто итогом снова становится HttpMessageNotReadableException (с причиной внутри, например MismatchedInputException).
@Test
void shouldReturn400_whenJsonCannotBeMappedToDto() throws Exception {
// JSON корректный, но тип поля "title" неверный для DTO (ожидалась строка)
String body = """
{"title":123,"summary":"S","body":"B","category":"JAVA"}
""";
mvc.perform(post("/api/editor/articles")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
// DTO не собрался -> до бизнес-логики дело не дошло
then(service).shouldHaveNoInteractions();
}
Почему это важно выделять отдельно от Bean Validation? Потому что при validation failure вы ожидаете, что DTO собрался, и ошибки будут “про поля”: пустая строка, слишком длинно, не тот формат. А здесь DTO не родился — и вы не должны “требовать” от ответа, например, список violations в формате field-level ошибок. В хорошем API можно сделать единый error contract, но это решение осознанное и отдельное; по умолчанию механика другая.
Пустое тело запроса
Ещё один реалистичный случай: клиент отправил Content-Type: application/json, но тело пустое. По человечески это выглядит как “ну я же отправил запрос”, по Spring-овски — “мне нечего читать, а @RequestBody обязателен”.
@Test
void shouldReturn400_whenRequestBodyIsEmpty() throws Exception {
mvc.perform(post("/api/editor/articles")
// Content-Type есть, но payload пустой
.contentType(MediaType.APPLICATION_JSON)
.content(""))
.andExpect(status().isBadRequest());
// Ошибка на web-boundary: сервис не должен запускаться
then(service).shouldHaveNoInteractions();
}
В реальных проектах пустое тело часто появляется не потому, что клиент “вредный”, а потому что интеграция сломалась: где-то забыли сериализовать объект, где-то отправили не тот stream, где-то потеряли payload в прокси. Поэтому тест на такой сценарий — не роскошь и не издевательство, а способ сделать поведение API предсказуемым.
4. Type mismatch в параметрах
Когда мы видим endpoint вроде GET /api/editor/articles/{id}, рука сама тянется думать про “статья найдена / не найдена”. Но до этого есть более базовый вопрос: а id вообще число? Если вы ожидаете Long id, а в URL прилетело abc, то это не “статья не найдена”. Это “клиент прислал параметр неправильного типа”. И это ломается ещё до поиска статьи и до вызова сервиса.
В ContentHub такие сценарии особенно важны на editor/admin API, потому что там много id-шек: article id, attachment id и т.д.
С точки зрения контроллера это выглядит обычно так (сильно упрощённо):
@GetMapping("/api/editor/articles/{id}")
ArticleDetailsResponse getById(@PathVariable Long id) {
// На этом этапе id уже должен быть Long.
// Если пришло "abc", до метода контроллера запрос вообще не дойдёт.
return service.getById(id);
}
Если вместо числа приходит строка, Spring пытается преобразовать её в Long (через конвертеры/биндинг), не может и выдаёт 400. Никакого “поиска по id” не случилось — искать было нечего.
Неверный тип path variable
Тест получается очень коротким, и это хорошо. Наша цель — зафиксировать семантику: неправильный тип входа → 400, сервис не трогаем.
@Test
void shouldReturn400_whenIdIsNotLong() throws Exception {
// В path variable приходит нечисловое значение
mvc.perform(get("/api/editor/articles/abc"))
.andExpect(status().isBadRequest());
// Конвертация аргументов упала раньше, чем вызов controller/service
then(service).shouldHaveNoInteractions();
}
Если хочется “доказать”, что это именно type mismatch, можно проверить resolved exception. Для path variables часто всплывает MethodArgumentTypeMismatchException.
@Test
void shouldFailWithTypeMismatchException_whenIdIsNotLong() throws Exception {
mvc.perform(get("/api/editor/articles/abc"))
.andExpect(result -> assertThat(result.getResolvedException())
// assertThat — из AssertJ; обычно подключают static import в тестах
.isInstanceOf(MethodArgumentTypeMismatchException.class));
}
Это снова не обязательное требование для каждого теста. Но как инструмент обучения и отладки — отлично помогает не перепутать типы негативных кейсов.
Семантика: это не 404
Тут часто срабатывает “интуиция пользователя”: раз статья по abc не найдена, значит 404. Но сервер не может честно сказать “ресурс не найден”, потому что ресурс в такой форме не существует даже теоретически. id по контракту — число; abc — это не “id, которого нет”, это “не id”.
В тестах это важно, потому что иначе вы начнёте строить отрицательные сценарии вокруг сервиса (например, given(service.getById(...)).willThrow(...)) там, где сервис вообще не должен быть вызван. А это уже тест “про вашу фантазию”, а не про поведение API.
5. Заголовки и 415 Unsupported Media Type
Очень легко написать тест, который “вроде отправляет JSON”, но забывает указать Content-Type. Или указывает неправильный Content-Type, например text/plain. Для человека это всё одно и то же: “я же положил JSON в body”. Для Spring MVC это разные случаи, потому что выбор HttpMessageConverter зависит от заголовка Content-Type. И тогда вместо ожидаемого 400 вы внезапно получаете 415 Unsupported Media Type, а потом полчаса ругаете Jackson за то, что он “не парсит”.
Если ваш controller-method принимает @RequestBody, то Spring ищет конвертер, который умеет читать именно этот media type и превращать его в нужный Java-тип. Если конвертера не нашлось — это обычно 415.
Неподдерживаемый Content-Type
Вот тест, который часто ловит студентов: JSON-строка есть, но заголовок говорит, что это text/plain.
@Test
void shouldReturn415_whenContentTypeIsTextPlain() throws Exception {
// Тело похоже на JSON, но Content-Type явно неправильный для @RequestBody JSON
String body = """
{"title":"T"}
""";
mvc.perform(post("/api/editor/articles")
.contentType(MediaType.TEXT_PLAIN)
.content(body))
.andExpect(status().isUnsupportedMediaType());
// До сервиса дело не доходит: не нашёлся подходящий HttpMessageConverter
then(service).shouldHaveNoInteractions();
}
И тут появляется практическое правило для MVC-тестов: если тест проверяет ошибки чтения запроса, он обязан быть честным по HTTP. То есть выставлять те заголовки, которые реальный клиент выставит, и явно показывать, какой именно случай мы тестируем.
Отсутствующий Content-Type
Если Content-Type вообще не задан, результат зависит от конфигурации и деталей запроса, но довольно часто это тоже приводит к 415. Потому что Spring не знает, каким конвертером читать тело.
В учебных тестах лучше вообще не “играть в угадайку”: если мы хотим тестировать JSON parsing, мы ставим application/json явно. Если хотим тестировать поведение на неправильный Content-Type — мы ставим неправильный Content-Type и ожидаем соответствующий статус.
6. Шаблон тестов в @WebMvcTest
В негативных тестах есть соблазн быстро накидать кучу почти одинаковых методов, где меняется один символ в JSON и один assertion. Через пару дней это превращается в кашу: непонятно, что именно ломает запрос, и почему этот тест вообще существует. Поэтому полезно иметь очень простой, повторяемый шаблон: один тест — одна причина отказа; и в тесте явно видно, что мы подали на вход.
Самый простой учебный шаблон для наших сегодняшних сценариев выглядит так: берём @WebMvcTest на конкретный контроллер, мокируем сервис через @MockitoBean, а в каждом тесте вызываем mvc.perform(...), проверяем статус и дополнительно фиксируем, что сервис не трогали.
Если вы хотите уменьшить шум от “валидного JSON”, допустимо завести крошечный helper, который возвращает канонический корректный body. Главное — не спрятать HTTP-семантику за трёхэтажным DSL.
private static String validCreateArticleJson() {
// Канонический “хороший” JSON, чтобы в тестах менять только одну причину падения
return """
{"title":"T","summary":"S","body":"B","category":"JAVA"}
""";
}
Дальше тест “на сломанный JSON” не должен превращаться в тест “на 10 разных вещей сразу”. Он просто отправляет сломанное тело.
@Test
void shouldReturn400_whenJsonIsBroken() throws Exception {
mvc.perform(post("/api/editor/articles")
.contentType(MediaType.APPLICATION_JSON)
// Имитируем битый JSON (обрезали строку) — DTO не соберётся
.content(validCreateArticleJson().substring(0, 10)))
.andExpect(status().isBadRequest());
// Ошибка на чтении/десериализации -> сервис не вызывается
then(service).shouldHaveNoInteractions();
}
А тест на type mismatch не должен тащить в себя body вообще, иначе вы создадите второй источник ошибок и не будете понимать, почему именно 400.
@Test
void shouldReturn400_whenPathVariableHasWrongType() throws Exception {
// Ошибка на конвертации аргументов метода контроллера
mvc.perform(get("/api/editor/articles/abc"))
.andExpect(status().isBadRequest());
then(service).shouldHaveNoInteractions();
}
Если тест падает “странно” и вы не понимаете, почему — andDo(print()) часто спасает нервы. Это не assertion, но отличная диагностическая подсказка.
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
@Test
void debugExample() throws Exception {
mvc.perform(post("/api/editor/articles")
.contentType(MediaType.APPLICATION_JSON)
// Намеренно отправляем битый JSON, чтобы увидеть, как Spring формирует ответ
.content("{"))
.andDo(print()) // печатает request/response в консоль
.andExpect(status().isBadRequest());
}
7. Что проверять в негативных тестах
Негативные тесты легко “пересолить”: начать проверять внутренние тексты ошибок Jackson, точные формулировки исключений, порядок полей в JSON-ответе и прочие вещи, которые меняются от версии библиотеки или конфигурации. В итоге тесты зелёные только на вашем ноутбуке и только пока никто не обновил зависимости. Поэтому важно осознанно выбрать уровень строгости: мы должны фиксировать контракт, но не превращать suite в заложника случайных деталей.
Для ошибок чтения запроса и type mismatch почти всегда есть три “слоя полезности”.
Самый базовый слой — статус и отсутствие вызова сервиса. Он универсален и редко ломается от эволюции error message. Это минимальное доказательство, что ошибка действительно на web-boundary и действительно ранняя.
Второй слой — проверка типа ошибки через getResolvedException(). Это полезно, когда вы учитесь или когда внутри проекта реально есть риск перепутать причины 400. Но в зрелом suite это иногда становится излишней привязкой к внутренностям MVC. Поэтому держите это как инструмент: применять, когда есть смысл, а не по привычке.
Третий слой — проверка error payload. Если проект использует стабильный error contract (в ContentHub это ApiProblem), вы можете проверять базовые поля, которые считаются “обещанием API”: например, status и какой-то общий title. Но для malformed JSON и conversion failures важно не ожидать, что ответ будет выглядеть как “валидация полей DTO”, потому что DTO не был создан. Например, поле violations может быть пустым или отсутствовать — и это нормально, если так задумано.
Чтобы не путаться, можно держать в голове такую “рамку”:
| Класс ошибки | Что полезно проверить | Что лучше не фиксировать жёстко |
|---|---|---|
| Malformed JSON | 400, сервис не вызван, (опционально) HttpMessageNotReadableException | точный текст detail от Jackson, “violations как у validation” |
| JSON не маппится в DTO | 400, сервис не вызван | конкретную причину MismatchedInputException в тексте |
| Type mismatch | 400, сервис не вызван, (опционально) MethodArgumentTypeMismatchException | трактовку как “не найден ресурс” |
| Wrong Content-Type | 415, сервис не вызван | детали сообщения об unsupported media type |
И ещё одна полезная привычка: один тест — одна причина. Если вы одновременно отправили malformed JSON и неправильный path variable, вы не тестируете “две вещи”, вы тестируете “какую-то из них, но мы не знаем какую”. Такой тест может стать зелёным по любому из двух поводов — и именно поэтому он опасен.
8. Типичные ошибки при чтении запроса
В негативных MVC-тестах чаще всего ломает не сложность Spring, а простая человеческая лень: “и так сойдёт”. В результате тесты начинают проверять не тот слой, смешивать причины 400 и давать ложную уверенность. Ниже — несколько типовых граблей именно для malformed JSON, message conversion failures и type mismatch, которые встречаются чаще всего и обычно съедают больше времени, чем сама реализация.
Ошибка №1: путать validation failure и ошибку чтения тела (и ждать violations там, где DTO не создан).
Если запрос не распарсился или не сматчился с DTO, Bean Validation не запускается. Поэтому ожидать в ответе тот же набор ошибок, что и при @NotBlank, — логическая ошибка. В тесте сначала определитесь: DTO появился или нет. Если нет, проверяйте статус, отсутствие вызова сервиса и, при необходимости, тип исключения.
Ошибка №2: писать тест “на 400” и думать, что он уже доказал смысл ошибки.
400 — это всего лишь класс ответа “плохой запрос”, и у него много причин. Один andExpect(status().isBadRequest()) часто слишком слабое доказательство. Добавьте хотя бы один маркер: сервис не вызван, или проверка типа исключения, или проверка базового поля error payload (если контракт стабилен). Иначе вы легко получите зелёный тест, который проходит по другой причине.
Ошибка №3: не задавать Content-Type и потом удивляться 415 или “странному” поведению.
@RequestBody почти всегда требует честного Content-Type. В тестах это особенно важно, потому что мы моделируем реальный HTTP-клиент. Если вы тестируете JSON parsing, ставьте application/json явно. А если вы тестируете неправильный media type — ставьте неправильный и ожидайте 415. Не надо надеяться, что Spring “догадается”.
Ошибка №4: ожидать 404 для /api/editor/articles/abc.
abc — не корректный id. Это не “ресурс не найден”, это “некорректный формат входных данных”. Поэтому семантически это 400. Если вы начнёте проверять 404, вы заставите контроллер и обработчик ошибок врать клиенту: он будет говорить “не нашли”, хотя на самом деле “не смогли прочитать”.
Ошибка №5: мокать сервис и настраивать given(service...) в тесте, где сервис не должен быть вызван.
Когда ошибка происходит на уровне message conversion или type mismatch, сервисная логика не должна включаться. Если в таком тесте вы готовите given(service.create(...)).willThrow(...), вы пишете сценарий, который никогда не случится. Гораздо полезнее проверить shouldHaveNoInteractions — это и короче, и честнее.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ