JavaRush /Курси /Spring Test /Помилки читання запиту у Spring MVC

Помилки читання запиту у Spring MVC

Spring Test
Рівень 12 , Лекція 1
Відкрита

1. DTO проти зламаного запиту

Якщо JSON уже прочитано і DTO зібрано, виникає наступне запитання: чи пройде об’єкт Bean Validation. Тепер розбираємо інший клас проблем — ситуації, коли запит навіть не вдалося нормально зібрати. Це схоже на різницю між «ви прийшли в банк без потрібних документів» і «ви прийшли, але заяву заповнено криво»: в обох випадках вас розвернуть, але збій стався на різних етапах — і тест має це розрізняти.

Валідація (@Valid) починається лише тоді, коли Spring уже зміг прочитати тіло, розпарсити JSON і створити DTO. А malformed JSON і type mismatch ламають запит раніше: на етапі читання тіла або конвертації параметрів. Для клієнта результат часто виглядає однаково — 400 Bad Request. Саме тому дуже легко написати тест «не про те» й отримати зелений результат, який нічого не доводить.

Зафіксуймо це в одній невеликій таблиці. Вона не про «всі статуси світу», а саме про те, щоб не плутати етапи обробки:

Ситуація JSON синтаксично ок? DTO створено? Bean Validation запускається? Метод контролера реально викликається? Типовий результат
Збій валідації (@NotBlank, @Size) так так так ні 400
Malformed JSON (пошкоджений синтаксис) ні ні ні ні 400
Збій перетворення повідомлення (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 обрав метод контролера]
    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[Виклик методу контролера]
    F --> G[Controller викликає service]
    G --> H[HTTP Response]

    C1 -. невідповідність типів .-> X[400]
    C2 -. пошкоджений JSON / збій перетворення .-> X[400]
    E -. порушення обмежень .-> 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 у proxy. Тому тест на такий сценарій — не розкіш і не знущання, а спосіб зробити поведінку API передбачуваною.

4. Type mismatch у параметрах

Коли ми бачимо endpoint на кшталт GET /api/editor/articles/{id}, рука сама тягнеться думати про «статтю знайдено / не знайдено». Але до цього є більш базове питання: а id взагалі число? Якщо ви очікуєте Long id, а в URL прилетіло abc, це не «статтю не знайдено». Це означає, що клієнт прислав параметр неправильного типу. І це ламається ще до пошуку статті та до виклику сервісу.

У ContentHub такі сценарії особливо важливі на editor/admin API, бо там багато ідентифікаторів: 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 трактування як «ресурс не знайдено»
Неправильний 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 — це і коротше, і чесніше.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ