JavaRush /Курси /Spring REST & MVC /Перевірки negative-path у @...

Перевірки negative-path у @WebMvcTest

Spring REST & MVC
Рівень 30 , Лекція 3
Відкрита

1. Negative-path у REST‑контракті

Якщо happy-path тести схожі на перевірку «Двері відчиняються, ручка працює», то negative-path тести — це перевірка «А що, якщо ключ не той: двері не мають відчинятися і водночас повинні чемно пояснити, чому». У REST API помилки — це публічна частина контракту: клієнту важливі не лише дані, а й передбачувана форма відмови.

Щоб не тестувати помилки хаотично, корисно тримати в голові спрощену карту того, де саме в Spring MVC може статися відмова. Це допомагає не плутати validation із malformed JSON і не змішувати все в один тест.

Нижче — схема конвеєра запиту на рівні «достатньо для Junior», де ми позначаємо точки відмови:

flowchart TD
  A[Запит MockMvc] --> B[Зіставлення обробника<br/>вибір методу контролера]
  B --> C[Зв’язування path/query<br/>+ перетворення типів]
  C -->|помилка перетворення| E1[400 INVALID_INPUT<br/>ProblemDetail]
  C --> D[HttpMessageConverter<br/>Jackson читає тіло]
  D -->|некоректний JSON| E2[400 MALFORMED_JSON<br/>ProblemDetail]
  D --> F["Bean Validation<br/>@Valid / @Validated"]
  F -->|помилка валідації| E3[400 INVALID_INPUT<br/>ProblemDetail + fieldErrors]
  F --> G[Метод контролера]
  G --> H[Виклик сервісу]
  H -->|доменний виняток| E4[404 / 409 ...<br/>ProblemDetail + code]
  H --> I[Успішна відповідь]

Зверніть увагу на важливу деталь: дуже багато помилок трапляються до того, як запит доходить до сервісу. Це прямий подарунок для тестів: ми можемо не лише перевірити «яка відповідь повернулася», а й перевірити «що сервіс узагалі не чіпали». Такий тест одразу ловить клас помилок на кшталт «невалідний запит усе одно пішов у бізнес-логіку й там змінив стан».

Успішні відповіді ми вже зафіксували: статус, заголовки, content type і JSON shape. Але контракт наполовину сліпий, якщо так само суворо не описані відмови. Тепер дивимося на ті самі ендпоінти з іншого боку — де запит може зламатися і як це має виглядати зовні.

2. Що перевіряти в ProblemDetail

ProblemDetail — це зручний стандартний каркас помилки: у нього є поля, які Spring уміє серіалізувати в application/problem+json, і він добре дружить із @ControllerAdvice. Але у студентів тут часто виникає спокуса: «О, якщо помилка в JSON, давайте перевіримо весь JSON цілком, включно з detail слово в слово». Це зазвичай породжує тести, які ламаються від будь-якої косметичної зміни.

Тому домовимося: у негативних тестах ми фіксуємо стабільні обіцянки контракту, а не літературні якості тексту помилки. Зазвичай стабільними є статус, тип вмісту, службовий code, а для validation — ще й структура fieldErrors. А от detail, title, timestamp і локалізовані повідомлення можуть змінюватися — і це нормально.

Зручно тримати в голові таку міні-таблицю (це не закон природи, а практичний компроміс):

Поле у відповіді ProblemDetail Рівень стабільності Що зазвичай перевіряти в тесті Чому так
status високо status().isBadRequest() / isNotFound() / isConflict() це основна HTTP-семантика
Content-Type високо contentTypeCompatibleWith(APPLICATION_PROBLEM_JSON) контракт помилки має бути явним
code високо jsonPath("$.code").value("...") це машиночитний якір для клієнта
instance середньо іноді перевіряти на конкретний path корисно, але залежить від вашої реалізації
fieldErrors високо (для validation) наявність масиву, імʼя поля клієнту потрібні адресні помилки
detail / title низько-середньо краще не перевіряти дослівно змінюється через wording та i18n
timestamp низько зазвичай не перевіряти занадто «живе» поле

Із цього випливають два практичні правила. По-перше, у тесті ми майже завжди перевіряємо status і code. По-друге, якщо ми тестуємо validation, ми перевіряємо, що fieldErrors містить правильний field (наприклад, title), але не намагаємося прибити цвяхами точний текст повідомлення.

3. Помилка валідації: 400 і INVALID_INPUT

Помилки валідації — найчастіший негативний сценарій у звичайному CRUD API. Клієнту досить один раз надіслати порожній title, щоб ви зрозуміли: «Ага, почалося справжнє життя». Наша мета в тесті — довести, що невалідний запит перетворюється на коректний ProblemDetail, помилка вказує на конкретне поле, а сервісний шар при цьому не викликається.

Почнімо з мінімального каркаса тесту. Тут важливо, що ми тестуємо MVC-зріз навколо контролера, а сервіс підміняємо через @MockitoBean.

import com.example.tasktracker.domain.service.TaskService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.test.context.bean.override.mockito.MockitoBean;

@WebMvcTest(TaskController.class)
class TaskControllerNegativeWebMvcTest {

    @Autowired MockMvc mockMvc; // через MockMvc виконуємо HTTP-запити до MVC-шару
    @Autowired ObjectMapper objectMapper; // знадобиться, якщо потрібно серіалізувати/десеріалізувати JSON у тестах

    @MockitoBean TaskService taskService; // сервіс підміняємо моком: важливо довести, що його не викликали
}

Тепер сам validation-тест. Ми надсилаємо JSON, який порушує constraints (наприклад, title порожній або складається з пробілів), і перевіряємо 400, application/problem+json, code=INVALID_INPUT і одну field-помилку по title.

import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;

import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Test
void createTask_withBlankTitle_returnsInvalidInput() throws Exception {
    String body = """
            {"title":"  ","description":"text"}
            """; // title складається з пробілів → очікуємо помилку валідації

    mockMvc.perform(post("/api/v1/tasks")
            .contentType(MediaType.APPLICATION_JSON)
            .content(body))
            .andExpect(status().isBadRequest())
            .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON))
            .andExpect(jsonPath("$.code").value("INVALID_INPUT"))
            .andExpect(jsonPath("$.fieldErrors[0].field").value("title"));

    verifyNoInteractions(taskService); // важливо: за невалідних вхідних даних запит не має доходити до сервісу
}

Зверніть увагу на verifyNoInteractions(taskService). Це не «прискіпування до Mockito», а перевірка сенсу: якщо вхідні дані невалідні, сервіс не повинен навіть дізнатися, що хтось намагався створити задачу. Інакше ми ризикуємо отримати побічні ефекти (логування, метрики, спробу запису, випадкові NPE) від сміттєвих даних.

Частий нюанс: порядок fieldErrors[0] може не гарантуватися. Якщо ви не хочете залежати від порядку, можна перевіряти, що у списку є елемент із field=title. У jsonPath це робиться трохи складніше, тож на навчальному рівні допустимо перевіряти перший елемент — але важливо розуміти, що в реальному проді краще писати стійкіші асерти.

4. Помилки query/path-параметрів

Коли студенти думають про помилки входу, вони майже завжди уявляють лише @RequestBody. Але реальний API частіше ламають не body, а query-параметри: page=-1, size=100000, status=INPROGRESS замість IN_PROGRESS, sort=hackMe,desc. Такі помилки з’являються на стадії binding/type conversion, і якщо їх не тестувати, клієнт отримує неочікувані відповіді, а ви — неочікувані баг-репорти.

Покажемо два короткі приклади. Перший — некоректний enum у query. Припустімо, GET /api/v1/tasks приймає status, а клієнт надіслав status=WRONG. В ідеальному світі це 400 і наш INVALID_INPUT.

import org.junit.jupiter.api.Test;

import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Test
void listTasks_withInvalidStatusEnum_returnsInvalidInput() throws Exception {
    // Рядок "WRONG" не мапиться на enum → очікуємо 400 з єдиним code
    mockMvc.perform(get("/api/v1/tasks?status=WRONG"))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.code").value("INVALID_INPUT"));

    verifyNoInteractions(taskService); // до сервісу запит дійти не має
}

Другий приклад — невалідна пагінація. Тут сценарій найчастіше такий: «коректний тип, але невалідне значення», наприклад size=101 за максимуму 100. Залежно від того, як ви валідуєте TaskSearchCriteria, це може бути помилка Bean Validation (із fieldErrors) або окрема гілка. Але сенс тесту той самий: 400, INVALID_INPUT і сервіс не викликати.

import org.junit.jupiter.api.Test;

import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Test
void listTasks_withTooLargeSize_returnsInvalidInput() throws Exception {
    // Тип параметра коректний (число), але значення виходить за бізнес-обмеження
    mockMvc.perform(get("/api/v1/tasks?size=999"))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.code").value("INVALID_INPUT"));

    verifyNoInteractions(taskService); // обмеження має спрацювати раніше за бізнес-логіку
}

Тут ми навмисно не перевіряємо fieldErrors, тому що у вашому конкретному проєкті це може бути і fieldErrors, і просто загальний detail. Важливо інше: форма помилки і код мають залишатися єдиними, інакше клієнту доведеться писати «зоопарк парсерів» під різні типи помилок.

5. Некоректний JSON: MALFORMED_JSON

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

Тест виглядає майже як validation-тест, але JSON навмисно зламаний. Ми очікуємо 400 і код MALFORMED_JSON. І знову: сервіс не має бути викликаний.

import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;

import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Test
void createTask_withMalformedJson_returnsMalformedJsonProblem() throws Exception {
    String body = """
            {"title":"Broken JSON"
            """; // немає закривної дужки }

    mockMvc.perform(post("/api/v1/tasks")
            .contentType(MediaType.APPLICATION_JSON)
            .content(body))
            .andExpect(status().isBadRequest())
            .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON))
            .andExpect(jsonPath("$.code").value("MALFORMED_JSON"));

    verifyNoInteractions(taskService); // парсинг упав → сервіс чіпати не можна
}

Тут важливо тримати окремий code MALFORMED_JSON. Parsing зламався раніше, ніж з’явився об’єкт для Bean Validation, тому змішувати цей сценарій із INVALID_INPUT не варто: для клієнта це інший клас помилки й інший привід виправляти запит.

Схожий сценарій — «missing body», коли клієнт надіслав Content-Type: application/json, але тіло порожнє. Залежно від налаштувань MVC і сигнатури контролера це також часто перетворюється на HttpMessageNotReadableException. Його можна тестувати так само, просто надіславши порожній рядок замість JSON.

6. Not Found: 404 і TASK_NOT_FOUND

Після validation і malformed JSON настає інша категорія помилок: вхід коректний, але ресурс не знайдено. Це не 400, тому що клієнт не порушив формат запиту; він просто попросив ресурс, якого немає. Тут ми вже тестуємо не пайплайн до сервісу, а зв’язок «сервіс кинув доменний виняток → @ControllerAdvice перевів його в єдиний ProblemDetail».

Спочатку готуємо заглушку: сервіс під час запиту за конкретним id кидає TaskNotFoundException. Це важливо: ми не тестуємо логіку пошуку в памʼяті, ми тестуємо, що контролерний шар правильно переводить ситуацію в публічний контракт.

import static org.mockito.Mockito.when;

String taskId = "missing-task";
when(taskService.getTaskById(taskId))
        .thenThrow(new TaskNotFoundException(taskId)); // імітуємо доменну ситуацію "ресурс не знайдено"

Тепер сам тест: робимо GET /api/v1/tasks/{taskId} і перевіряємо 404 і TASK_NOT_FOUND. Якщо ваш ProblemDetail встановлює instance як URI запиту, це дуже корисно перевіряти: клієнту й логам легше зіставляти помилки з конкретними викликами.

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 getTaskDetails_whenMissing_returnsTaskNotFound() throws Exception {
    mockMvc.perform(get("/api/v1/tasks/{taskId}", "missing-task"))
            .andExpect(status().isNotFound())
            .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON))
            .andExpect(jsonPath("$.code").value("TASK_NOT_FOUND"))
            .andExpect(jsonPath("$.instance").value("/api/v1/tasks/missing-task")); // корисно, якщо instance = path запиту
}

Зверніть увагу: ми не перевіряємо detail дослівно. Він може змінюватися («Task not found», «Задачу не знайдено», «Task with id ... not found») — і це не повинно ламати тест. Код і статус — це ваш «бетон».

7. Conflict: 409 і INVALID_STATUS_TRANSITION

Конфліктні помилки — найнеприємніший тип для новачка, тому що вони схожі на validation, але за змістом зовсім інші. Validation — це «вхід зламаний», а conflict — це «вхід нормальний, але операція заборонена правилами предметної області». У нашому Task Tracker це може бути недопустимий перехід статусу або спроба змінити архівну задачу. І це ідеальний кандидат на 409 Conflict із зрозумілим code.

Сценарій тесту схожий на not found: сервіс кидає доменний виняток, @ControllerAdvice переводить його в ProblemDetail. Припустімо, PATCH намагається перевести задачу в ARCHIVED напряму, сервіс кидає InvalidStatusTransitionException, а ми очікуємо 409 і INVALID_STATUS_TRANSITION.

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;

String taskId = "task-1";
when(taskService.patchTask(eq(taskId), any()))
        .thenThrow(new InvalidStatusTransitionException("TODO", "ARCHIVED")); // конфлікт: перехід заборонено доменними правилами

Щоб не потонути в деталях patch DTO (ми зараз не тестуємо сам PATCH-меппінг як окрему тему), можна надіслати мінімальний JSON і зосередитися на статусі та коді.

import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Test
void patchTask_withInvalidTransition_returnsConflict() throws Exception {
    String body = "{\"status\":\"ARCHIVED\"}";

    mockMvc.perform(patch("/api/v1/tasks/{taskId}", "task-1")
            .contentType(MediaType.APPLICATION_JSON)
            .content(body))
            .andExpect(status().isConflict())
            .andExpect(jsonPath("$.code").value("INVALID_STATUS_TRANSITION"));
}

Якщо вам хочеться перевірити ще й status=409 всередині JSON, це допустимо, але зазвичай зайве: HTTP-статус ви вже перевірили на рівні протоколу. Тести стають коротшими, коли ви не дублюєте одне й те саме кілька разів.

8. Короткі negative-path тести

Negative-path тести легко перетворюються на ланцюжок із двадцяти .andExpect(...), особливо коли ви перевіряєте ProblemDetail і fieldErrors. З одного боку, хочеться зменшити повторення. З іншого боку, якщо ви ховаєте URI, метод і тіло запиту в хелпери, тест перестає читатися як HTTP-сценарій. Тут корисна проста дисципліна: хелпери мають прибирати шум, але залишати на видноті контракт.

Наприклад, можна зробити невеликий допоміжний метод, який надсилає JSON-запит і повертає ResultActions. Він не ховає ні URL, ні метод, ні тіло — він лише прибирає рутину з contentType.

import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.ResultActions;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;

private ResultActions postJson(String url, String body) throws Exception {
    // Допоміжний метод прибирає лише "шум" (contentType), але залишає видимими URL і body
    return mockMvc.perform(post(url)
            .contentType(MediaType.APPLICATION_JSON) // єдиний Content-Type для JSON-запитів
            .content(body));
}

Тоді validation-тест стає компактнішим, але все ще читається:

import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Test
void createTask_withBlankTitle_returnsInvalidInput_short() throws Exception {
    postJson("/api/v1/tasks", "{\"title\":\"  \"}")
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.code").value("INVALID_INPUT"));
}

Коли ви пишете тести, дуже допомагає тимчасово додати .andDo(print()), щоб побачити реальну відповідь. Але не варто робити це постійною частиною кожного тесту: друк корисний під час розробки, а потім лише захаращує логи CI.

9. Типові помилки під час negative-path тестування в @WebMvcTest

Помилка №1: змішувати validation і malformed JSON в одному сценарії.
Обидва випадки дають 400, але причини різні: в одному дані не пройшли валідацію, в іншому — запит узагалі не було розібрано. Якщо тести не розрізняють ці кейси, ви втрачаєте точність діагностики й не ловите регресії в обробці винятків.

Помилка №2: перевіряти detail як фіксований рядок.
detail — це людсько-читабельне повідомлення, а не стабільний контракт. Воно може змінюватися під час рефакторингу або локалізації. Для стійких тестів краще спиратися на code і fieldErrors, а detail не використовувати як суворий критерій.

Помилка №3: не враховувати, що @WebMvcTest піднімає лише частину контексту.
Якщо @ControllerAdvice не потрапив у тестовий зріз, ви отримаєте інший формат помилок і будете шукати проблему “в логіці”, хоча справа в конфігурації тесту. У таких випадках потрібно явно підключити обробник або перевірити зону сканування.

Помилка №4: підміняти сервіс там, де він не має викликатися.
У validation або malformed JSON сценаріях запит взагалі не повинен доходити до сервісу. Якщо ви пишете when(service...) у таких тестах, ви тестуєте не те, що потрібно. Правильніша перевірка — переконатися, що сервіс не було викликано.

Помилка №5: намагатися перевірити кілька типів помилок в одному тесті.
Коли один тест покриває одразу validation, parsing і бізнес-помилки, він втрачає ясність і стає крихким. Negative-path тести мають бути односценарними: один тест — один тип відмови. Тоді кожен тест стає точним «датчиком» конкретної проблеми.

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