JavaRush /Курсы /Spring Test /Validation в controller tests

Validation в controller tests

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

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() — маленькая строчка, которая часто экономит часы отладки.

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