1. Валидация на границе web-слоя
Когда вы впервые пишете API, очень хочется думать так: «Ну если клиент прислал что-то странное — мы разберёмся в сервисе». Это естественно, но в реальном backend это быстро превращается в хаос: сервис начинает проверять пустые строки, null и длины полей, а контроллер становится просто «трубой». Валидация на границе — это как турникет в метро: он не решает, куда вы едете, но он обязан отсечь человека без билета.
В ContentHub web-слой — это место, где мы должны отфильтровать запросы вида «создай статью, но без заголовка» или «заголовок из 5000 символов, потому что я так чувствую». Такие запросы не должны доходить до service-layer вообще. Не потому что сервис слабый и обидится, а потому что у сервиса другая работа: бизнес-правила (статусы, workflow, ограничения переходов), а не проверка, что строка не пустая.
Есть ещё одна прагматичная причина: валидация на DTO делает поведение API предсказуемым. Вы получаете единый механизм, единые ошибки и единый стиль «как клиенту понять, что он сделал не так». И это идеально ложится в наш курс: мы фиксируем это поведение MVC-тестами, потому что именно оно — часть контракта API.
Представьте, что вы — клиент (мобильное приложение, фронтенд, другой сервис). Вам не важно, на каком уровне вы проверили поле title. Вам важно, чтобы API стабильно отвечал: «400, вот список нарушений, вот какие поля надо исправить». Эту стабильность и должен защищать тест.
2. Bean Validation в Spring MVC: где ломается запрос
Чтобы писать хорошие тесты, важно понимать, на какой стадии Spring вообще успевает валидировать данные. Мы не залезаем сегодня в «внутренности Spring MVC до последнего фильтра», но минимальная модель обработки запроса вам нужна — иначе негативные кейсы смешиваются в одну кашу из «ну просто 400 же».
С Bean Validation логика такая: сначала Spring пытается прочитать тело запроса и собрать DTO (десериализация JSON → Java-объект). Если это получилось, только тогда включается Bean Validation и проверяет аннотации вроде @NotBlank и @Size. Если DTO нарушает ограничения, возникает validation failure: запрос прочитан, DTO создан, но DTO “плохой”.
Эту мысль удобно держать как маленькую схему. Вот упрощённая «линия жизни» запроса для нашего POST /api/editor/articles:
flowchart TD
A["HTTP request
Content-Type: application/json"] --> B["JSON -> DTO binding
(Jackson)"]
B -->|успех| C["@Valid запускает Bean Validation"]
B -->|ошибка чтения| X["не наш случай сегодня
(тело не прочитано)"]
C -->|DTO валиден| D["Controller method вызван"]
C -->|DTO невалиден| E["MethodArgumentNotValidException"]
D --> F["Service вызывается"]
E --> G["Error response (ApiProblem + violations)"]
Сегодня мы работаем строго с веткой, где JSON корректный, DTO собрать можно, но DTO нарушает правила. Поэтому мы тестируем три вещи одновременно (и это важно проговорить прямо): во-первых, статус (обычно 400), во-вторых, формат ошибки (наш ApiProblem), и в-третьих, границу — что сервис не был вызван.
Если вы поймаете себя на мысли «а почему это 400, а не 422?», вы не одиноки. В разных системах встречается и 422 Unprocessable Entity, но в рамках нашего проекта и выбранной методики мы держим простую дисциплину: нарушения входных данных — это 400, потому что клиент прислал запрос, который не проходит правила входной формы. Главное — не число само по себе, а стабильность контракта и понятность причины.
3. Ограничения DTO: @NotBlank и @Size
Bean Validation — это не магическая «галочка качества», а довольно честный механизм: вы пишете ограничения на полях, а Spring проверяет их, когда вы попросили (@Valid). Если вы ограничения не написали, Spring ничего «не догадается» и не будет валидировать по телепатии. Поэтому первое, что мы делаем в ContentHub — объявляем правила прямо на request DTO.
Для сценария создания черновика (editor API) нам логично зафиксировать базовые требования: заголовок, summary, body и category обязательны; заголовок не должен быть бесконечным. В учебном проекте это выглядит примерно так:
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
class CreateArticleRequest {
// Заголовок обязателен: null/""/" " — не принимаем
@NotBlank
// Ограничиваем длину заголовка на входе, чтобы не тащить «простыню» дальше
@Size(max = 120)
private String title;
// Summary тоже обязателен: пустые строки и пробелы не считаются данными
@NotBlank
private String summary;
}
Да, DTO здесь «обрезан» ради короткого примера. В реальном CreateArticleRequest у вас будут и body, и category, и, возможно, другие поля. Суть одна: ограничения — рядом с полем, а не «где-то в тесте» и не «где-то в сервисе».
Очень частый вопрос у начинающих: «Почему @NotBlank, а не @NotNull?» Потому что @NotNull запрещает null, но разрешает пустую строку "" и строку из пробелов " ". Для API это обычно не то, что вы хотите. @NotBlank как раз говорит: «строка обязана быть не null, и после trim() там должен быть хоть один символ». Это ближе к человеческому смыслу “заполни поле”.
А @Size(max=120) — это не “красота” и не “на всякий случай”. Это защита от двух реальных проблем: первая — пользователь случайно вставил огромный текст в заголовок, и UI это не поймал; вторая — кто-то намеренно шлёт огромные поля (иногда не из злобы, а потому что «а вдруг прокатит»). Ограничение длины на входе — это часть стабильности API.
Чтобы не держать все аннотации в голове, полезно иметь короткую табличку — не как шпаргалку для экзамена, а как ориентир “что обычно ставят на request DTO”:
| Аннотация | Тип поля | Что означает по-человечески | Типичный кейс в ContentHub |
|---|---|---|---|
| @NotNull | любой | значение обязательно, но пустые строки не запрещает | технические поля, где null недопустим |
| @NotBlank | String | строка обязательна и не может быть пустой/из пробелов | title, summary, body, category |
| @Size(min, max) | String, коллекции | ограничение длины/размера | длина заголовка, лимиты списков |
| @Pattern(regexp=...) | String | формат строки по регулярке | например, если бы мы валидировали slug на входе |
Главное правило здесь очень «инженерное»: в DTO живут формальные правила входного формата, а не бизнес-правила уровня “черновик нельзя публиковать напрямую”. Бизнес-правила — это уже сервис и доменная логика. DTO — это «форма, которую клиент заполняет».
4. @Valid в контроллере: “стоп-кран” до service-layer
Теперь важный момент: ограничения на DTO сами по себе не запускаются. Их запускает валидация аргумента в controller method. Для request body это делается просто: вы добавляете @Valid на параметр @RequestBody. Это и есть тот самый «стоп-кран», который не пропускает некорректный запрос дальше.
Минимальный пример для editor endpoint в ContentHub:
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
class EditorArticleController {
// Сервис здесь условный: в реальном проекте он будет внедрён через конструктор
private final EditorArticleService service;
EditorArticleController(EditorArticleService service) {
this.service = service;
}
@PostMapping("/api/editor/articles")
void create(@Valid @RequestBody CreateArticleRequest request) {
// Важно: сюда мы попадаем только если DTO успешно собрано и прошло Bean Validation
service.create(request);
}
}
Если @Valid убрать, то запрос с пустым title вполне может попасть в метод контроллера, а дальше вы будете вынуждены либо писать проверки руками, либо ловить ошибку “где-то глубже”. И вот это как раз тот случай, когда у новичков возникают “странные тесты”: тест ожидает 400, а получает 200 или 500, потому что DTO вообще не валидировался, сервис попытался обработать мусор, и всё пошло по непредсказуемому сценарию.
В контексте @WebMvcTest нам важно зафиксировать ещё одну вещь: если валидация не прошла, то контроллер не должен вызывать сервис. И это тестируется не «на глаз», а буквально: сервис в MVC slice — это мок, и мы проверяем, что вызовов не было.
Здесь очень полезна простая ментальная модель: controller — это “переводчик” HTTP → вызов сервиса. Если вход плохой, переводчик должен остановиться и сказать: “извините, так не принимаем”. Он не должен «передавать плохую бумажку дальше по цепочке».
5. Validation в @WebMvcTest: 400 и violations
Теперь самое вкусное: как это проверять тестом так, чтобы он был и строгим, и не хрупким. В нашем курсе мы уже договорились, что ошибки в API имеют стабильный контракт ApiProblem (ProblemDetail-style с дополнительными полями), и для validation failures особенно важен блок violations. Поэтому в тесте нам мало просто увидеть 400: мы хотим увидеть, что клиент получит понятный “список проблем”.
Представим, что наш API возвращает ошибку примерно такого вида:
{
"title": "Validation failed",
"status": 400,
"detail": "Request validation failed",
"errorCode": "VALIDATION_FAILED",
"violations": [
{ "field": "title", "message": "must not be blank" }
]
}
Вы не обязаны сегодня знать, как именно это собирается внутри @ControllerAdvice — мы ещё поговорим об этом отдельно. Но тест на уровне web-границы вправе ожидать, что contract будет именно таким: статус + список нарушений.
Базовый тест: пустой заголовок → 400 + violations
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
class EditorArticleControllerWebMvcTest {
private MockMvc mvc;
@Test
void shouldReturn400WhenTitleIsBlank() throws Exception {
// Важно: JSON корректный (парсится), но DTO нарушает ограничения (title blank)
String body = """
{"title":" ","summary":"S","body":"B","category":"JAVA"}
""";
mvc.perform(
post("/api/editor/articles")
// Явно указываем JSON, чтобы тест проверял именно validation, а не «угадайку» конвертеров
.contentType(MediaType.APPLICATION_JSON)
.content(body)
)
// Контракт: для validation failures у нас ожидается 400
.andExpect(status().isBadRequest())
// И минимум: в ответе должен присутствовать блок violations
.andExpect(jsonPath("$.violations[0]").exists());
}
}
Обратите внимание на тонкость: мы отправляем корректный JSON, просто title — пробелы. Это чистая validation failure. Мы явно ставим Content-Type: application/json, чтобы тест не зависел от “что там Spring решит по умолчанию”. И мы проверяем не только статус, но и факт наличия violations.
Если ваш контракт более строгий (например, violations — всегда массив объектов {field, message}), вы можете усилить проверку:
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void shouldIncludeTitleViolation() throws Exception {
// Здесь title уже пустая строка — тоже должен сработать @NotBlank
String body = """
{"title":"","summary":"S","body":"B","category":"JAVA"}
""";
mvc.perform(
post("/api/editor/articles")
.contentType(MediaType.APPLICATION_JSON)
.content(body)
)
.andExpect(status().isBadRequest())
// Проверяем смысл: нарушение относится именно к полю title
.andExpect(jsonPath("$.violations[0].field").value("title"));
}
Здесь мы уже фиксируем смысл: нарушено именно поле title. Это полезно, потому что клиентский код часто подсвечивает конкретные поля формы.
Тест “стоп-кран”: сервис не вызывается
В MVC slice сервис обычно заменён на мок. Тогда тест может и должен доказать, что запрос не ушёл дальше.
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.mockito.BDDMockito.then;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void shouldStopBeforeServiceWhenRequestIsInvalid() throws Exception {
// DTO будет невалидным из-за title=""
String body = """
{"title":"","summary":"S","body":"B","category":"JAVA"}
""";
mvc.perform(
post("/api/editor/articles")
.contentType(MediaType.APPLICATION_JSON)
.content(body)
)
.andExpect(status().isBadRequest());
// Ключевая проверка границы: сервис не должен вызываться вообще
then(service).shouldHaveNoInteractions();
}
Этот тест — один из самых “дорогих по пользе” во всей теме validation. Он защищает вас от ситуации, когда кто-то случайно убрал @Valid, поменял сигнатуру контроллера, или переподключил какую-то конфигурацию — и внезапно плохие запросы начали попадать в сервис. Это не просто «красота», это реальная страховка от регрессии.
Параметризованный тест на одно правило (без копипаста)
Когда правило одно и то же, а входных вариантов несколько, удобно использовать parameterized tests. Например, @NotBlank нужно проверить на "" и " " — и хватит, не надо устраивать олимпиаду по количеству пробелов.
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.http.MediaType;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ParameterizedTest
@ValueSource(strings = {"", " "})
void shouldRejectBlankTitle(String title) throws Exception {
// Собираем тело запроса так, чтобы менялся только title (проверяем одну идею)
String body = """
{"title":"%s","summary":"S","body":"B","category":"JAVA"}
""".formatted(title);
mvc.perform(
post("/api/editor/articles")
.contentType(MediaType.APPLICATION_JSON)
.content(body)
)
.andExpect(status().isBadRequest());
}
Этот тест остаётся читаемым, потому что проверяет одну идею: “title не должен быть blank”. И он не превращается в комбайн, который одновременно проверяет и title, и summary, и category.
Про “сообщения ошибок”: почему их лучше не проверять дословно. Очень хочется в тесте проверить сообщение "must not be blank" или "Title is required". Иногда это уместно, но чаще делает тест хрупким. Сообщения могут меняться из-за локализации, из-за настроек провайдера, из-за того, что вы поменяли текст в DTO, не меняя смысла контракта.
Надёжнее фиксировать более структурные вещи: статус, наличие violations, имя поля, и то, что сообщение вообще есть (не обязательно точный текст). Если очень хочется — можно проверять ключ ошибки или свой errorCode, а не человеческую фразу.
6. Каскадная валидация: @Valid во вложенных DTO
Пока DTO плоский (все поля на одном уровне) — всё довольно просто. Но как только вы начинаете делать request “по-человечески”, у вас появляются вложенные объекты. Например, вы захотели сгруппировать текст статьи в payload, а метаданные — отдельно. И вот здесь спрятан классический баг, на который очень легко наступить: @Valid на параметре контроллера не запускает валидацию вложенного объекта автоматически. Для каскадной проверки нужен @Valid прямо на поле вложенного объекта.
Пример — упрощённый, но жизненный:
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
class WrapperRequest {
// Ключевой момент: без @Valid вложенный объект НЕ будет провалидирован каскадно
@Valid
private NestedRequest payload;
}
class NestedRequest {
// Это ограничение сработает только если payload помечен @Valid в WrapperRequest
@NotBlank
private String value;
}
Если убрать @Valid с поля payload, то value внутри NestedRequest может быть пустым — и Bean Validation не заметит. DTO будет считаться валидным, хотя внутри лежит мусор. Это и есть тот самый “тихий баг”, который потом выстреливает где-то в сервисе.
Как это тестировать на web-границе? Ровно так же, как и плоские случаи: отправить корректный JSON, но нарушить правило внутри вложенного объекта, и ожидать 400.
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void shouldReturn400WhenNestedValueIsBlank() throws Exception {
// JSON корректный, но payload.value нарушает @NotBlank
String body = """
{"payload":{"value":" "}}
""";
mvc.perform(
post("/api/editor/articles/wrapper")
.contentType(MediaType.APPLICATION_JSON)
.content(body)
)
.andExpect(status().isBadRequest());
}
Да, это “демо endpoint” для примера. В реальном ContentHub вы подобную вложенность встретите, когда DTO разрастётся: например, появится вложенный объект для настроек, подструктура для “контента”, список вложений и так далее. И вот тогда наличие @Valid на поле вложенного объекта перестаёт быть “деталью аннотаций” и становится вопросом: “а вообще работает ли наша защита входа”.
Тут полезно помнить простую аналогию. @Valid на параметре контроллера — это как просьба: “проверь этот объект”. А @Valid на поле — это как указание: “и внутри него тоже проверяй всё, что помечено ограничениями”. Без второго Spring честно скажет: “я проверил верхний объект, он не null, значит всё хорошо”. И формально он будет прав.
7. Типичные ошибки в validation MVC-тестах
Ошибка №1: ожидать валидацию без @Valid и потом обвинять тесты в “странном поведении”.
Это самый частый сценарий: ограничения на DTO стоят, но @Valid на @RequestBody забыли, и запрос спокойно проходит в контроллер. Тест либо получает 200, либо падает дальше по цепочке. Лечится просто: в контроллере @Valid — это не украшение, а включатель механизма.
Ошибка №2: проверять только статус 400 и не фиксировать контракт ошибки.
Статус сам по себе мало что доказывает: 400 можно получить и по другим причинам. Если вы не проверяете хотя бы наличие violations, вы не защищаете клиентский контракт. Правильный негативный тест смотрит и на статус, и на ключевой кусок тела ответа, который клиент реально будет использовать.
Ошибка №3: проверять текст сообщения нарушения дословно и потом удивляться “флакам” на ровном месте.
Сообщения ограничений могут отличаться между окружениями, могут локализоваться, могут меняться при небольшой правке. Если вам нужна стабильность, фиксируйте поле (title, summary) и сам факт наличия сообщения, а не конкретную фразу. Дословные сообщения — это почти всегда слишком хрупкая проверка.
Ошибка №4: забыть Content-Type: application/json и случайно протестировать не то, что вы думаете.
В MVC-тестах “мелочи HTTP” решают многое. Без Content-Type Spring может иначе выбрать конвертер, иначе интерпретировать тело, иначе упасть. Если вы тестируете validation request DTO, задавайте application/json явно, чтобы тест проверял именно validation, а не случайный “непонятный формат запроса”.
Ошибка №5: не проверять границу “сервис не вызывается”.
Даже если API возвращает 400, полезно доказать, что запрос не ушёл в сервис. Это защищает от регрессии, где кто-то убрал валидацию и начал вручную кидать исключение внутри сервиса (или ещё хуже — частично обрабатывать невалидный запрос). then(service).shouldHaveNoInteractions() — маленькая строчка, которая часто экономит часы отладки.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ