1. Як негативні тести перетворюються на хаос
Негативні тести майже завжди зʼявляються в реальному житті: учора у вас був happy path, сьогодні прилетів звіт про помилку, завтра бойове середовище впало через некоректне тіло запиту, а післязавтра хтось змінив error payload, і клієнтський застосунок почав сумувати. У цей момент рука сама тягнеться дописати ще один тест — і це правильно. Проблема в іншому: якщо писати їх як доведеться, через 20–30 кейсів ви отримаєте набір методів на кшталт test1, test2, shouldReturn400WhenX, shouldReturn400WhenY, які ніхто не може читати як цілісну систему.
Найтиповіша картина хаосу виглядає так: один тест перевіряє одночасно і @NotBlank, і @Size, і що service не викликався, і що ApiProblem.title саме такий-то (який завтра перейменують). Другий тест робить майже те саме, але з іншим endpoint. Третій — з іншим статусом і ще однією умовою. Тести зелені, але впевненості немає, бо набір тестів став схожим на коробку з дротами від старих зарядок: начебто «щось є», але підключати страшно.
Щоб вибратися з цього стану, зручно перейти від «списку кейсів» до матриці: у нас є скінченне число endpoint-ів editor API та скінченне число класів помилок. І якщо ми чесно назвемо ці осі, то кожен тест автоматично отримає зрозуміле місце в структурі.
2. Матриця: endpoint × клас помилки
Коли ми говоримо «матриця», не треба лякатися: це не лінійна алгебра й не «вектори в просторі Spring». Це буквально шкільна ідея «таблиці множення», тільки замість чисел — наші endpoint-и та типи негативних сценаріїв. Матриця допомагає двом речам: по-перше, вона показує, що ми вже закрили, по-друге — допомагає не плодити дублікати й не забувати важливі класи помилок. І так, вона ще й знімає тривожність: замість відчуття «я нічого не покриваю» зʼявляється відчуття «ось система, я закриваю ризик за ризиком».
В editor API ContentHub нам зручно взяти мінімальний «наскрізний» набір endpoint-ів, який часто ламається на вході та під час переходу між статусами:
- POST /api/editor/articles (створення чернетки),
- PUT /api/editor/articles/{id} (оновлення чернетки),
- POST /api/editor/articles/{id}/submit (надсилання на ревʼю).
А за класами помилок візьмемо ті, які ми вже розібрали сьогодні:
- 400 через помилку валідації,
- 400 через некоректний JSON,
- 400 через невідповідність типів,
- 404 — ресурс не знайдено,
- 409 — конфлікт (бізнес-конфлікт стану),
- 500 — непередбачена помилка (fallback).
415 Unsupported Media Type ми теж розбирали, але тримаємо його трохи осторонь від основної матриці. Це корисний guardrail на рівні транспортного шару для заголовків і Content-Type: зазвичай достатньо 1–2 окремих тестів, і не потрібно робити з нього центральну вісь editor-flow поруч із validation, not found і conflict.
Схематично це можна представити так (це не «контракт», а зручна карта):
| Endpoint | 400 (валідація) | 400 (некоректний JSON) | 400 (невідповідність типів) | 404 | 409 | 500 |
|---|---|---|---|---|---|---|
| POST /api/editor/articles | так | так | рідко | рідко | іноді | так |
| PUT /api/editor/articles/{id} | так | так | так | так | іноді | так |
| POST /api/editor/articles/{id}/submit | іноді | ні | так | так | так | так |
Важливо розуміти, що «так/ні» тут не математична істина. Це підказка: де очікувати відповідний клас помилок, щоб набір тестів виглядав не як список випадковостей, а як покриття конкретних ризиків. Наприклад, на submit у нас зазвичай немає @RequestBody, тому malformed JSON там не настільки актуальний, зате дуже актуальні 404 (статтю не знайдено) і 409 (недопустимий перехід статусу).
3. Правило “один тест — одне джерело проблеми”
У негативних тестах є дуже проста, але рятівна дисципліна: змінюємо рівно один фактор порівняно з «канонічно валідним запитом». Це звучить банально, але це той самий момент, де «навчальні поради» раптом перетворюються на «професійну гігієну». Коли ви змінюєте один фактор, ви точно знаєте, чому тест упав. Коли ви змінюєте три — тест стає загадкою, і ви починаєте налагоджувати тест замість застосунку.
Найкращий помічник тут — «канонічний валідний JSON». Він має бути максимально нудним. Не потрібно вставляти туди особливі символи, емодзі чи поезію (ми ж тестуємо бекенд, а не творче письмо). Нудний JSON — це суперсила, тому що в кожному негативному тесті ви підміняєте тільки одну деталь.
Мініприклад такого хелпера:
private String validCreateArticleJson() {
// Канонічний валідний JSON: максимально нудний, без сюрпризів.
// У негативних тестах ми будемо змінювати рівно одну деталь у цьому тілі.
return """
{"title":"Title","summary":"Summary","body":"Body","category":"JAVA"}
""";
}
Далі будь-який негативний тест стає «точковою мутацією»:
- потрібно перевірити @NotBlank для title — змінюємо "Title" на "" або " ";
- потрібно перевірити @Size(max=120) — робимо рядок на 121 символ;
- потрібно перевірити відсутність поля — прибираємо "category".
І тут важливий психологічний бонус: коли ви читаєте тест, вам не потрібно заново розуміти весь запит. Ви дивитеся на diff: «ага, тут тільки title порожній — отже, тест про обовʼязковість title». Це дуже знижує когнітивне навантаження, особливо якщо ви ще не впевнені в Spring-магії і взагалі іноді забуваєте, де @RequestBody, а де @PathVariable.
4. Структура тесту: один контролер, але багато @Nested
У прикладах нижче використовуємо JUnit 6. (Анотації @Nested, @DisplayName, @ParameterizedTest залишаються такими ж за змістом, бо це все ще JUnit Jupiter.)
Коли негативних сценаріїв стає більше пʼяти, «плоский» тестовий клас починає перетворюватися на простирадло. І тут @Nested — це не «краса заради краси», а спосіб зробити набір тестів читабельним як документ: спочатку ми бачимо endpoint, потім усередині — класи помилок. Якщо вам знайоме відчуття «я відкрив тестовий клас і прокрутив на 200 рядків униз, а там усе однакове» — @Nested якраз лікує цей біль.
Скелет структури для editor API може виглядати так:
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
@WebMvcTest(EditorArticleController.class)
class EditorArticleControllerWebMvcTest {
// Групуємо за endpoint-ами: так клас читається як зміст API.
@Nested
@DisplayName("POST /api/editor/articles")
class CreateDraftErrors { }
@Nested
@DisplayName("PUT /api/editor/articles/{id}")
class UpdateDraftErrors { }
@Nested
@DisplayName("POST /api/editor/articles/{id}/submit")
class SubmitForReviewErrors { }
}
Зверніть увагу на важливу деталь: ми групуємо тести за формою API, а не за внутрішніми методами сервісу. Це робить тести ближчими до споживача API та до документації. Плюс, якщо завтра хтось перенесе endpoint або змінить URI, вам одразу буде зрозуміло, які тести зачеплено.
Далі всередині кожного класу @Nested ви можете вже «розставити полиці»: окремі тести на 400, на 404, на 409. Але без фанатизму. Ми не будуємо бібліотеку каталогізації тестів — ми будуємо зрозумілий набір тестів.
5. Helper-методи: менше шуму, але HTTP має залишатися видимим
Бажання написати «супер-хелпер» для всього одразу абсолютно природне. Особливо коли ви вже третій раз пишете .contentType(APPLICATION_JSON) .accept(APPLICATION_JSON) і відчуваєте, як усередині вас народжується маленький framework (а потім ви самі ж будете його підтримувати, вітаю). Тому правило таке: хелпери мають прибирати повторюваний шум, але не приховувати зміст запиту.
Один із найбезпечніших хелперів — це маленька обгортка «POST JSON» або «PUT JSON», яка все одно залишає видимими URL і саме тіло:
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
private ResultActions postJson(MockMvc mvc, String url, String json) throws Exception {
// Невеликий helper прибирає рутину (Content-Type/Accept),
// але залишає видимими ключові речі: URL і тіло запиту.
return mvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON) // кажемо: "я надсилаю JSON"
.accept(MediaType.APPLICATION_JSON) // кажемо: "хочу отримати JSON"
.content(json)); // саме тіло запиту (не ховаємо його!)
}
Чому це хороший компроміс? Бо, читаючи тест, ви все одно бачите «куди» (url) і «що» (json). А от зайві деталі «як саме налаштовано Content-Type» ви ховаєте, бо це справді повторюваний ритуал. У результаті тест читається як сценарій, а не як церемонія виклику API.
Якщо ж ви зробите хелпер рівня createArticleWithTitleBlankAndExpect400, ви сховаєте зміст, і тест стане «магічним»: щоб зрозуміти, що він робить, доведеться йти в хелпер. Це як дивитися фільм, де кожну другу сцену треба читати у Вікіпедії — формально можна, але задоволення сумнівне.
6. Параметризація негативних тестів
Параметризовані тести — класна річ, але вони підступні. Вони легко перетворюють зрозумілий набір сценаріїв на «цифрову кашу», де ви бачите набір входів, але втрачаєте зміст. Ключовий принцип: параметризувати можна тільки тоді, коли статус, зміст помилки та контракт відповіді залишаються однаковими. Інакше ви будете ганяти один тест, який іноді очікує 400, іноді 404, іноді 409 — і це вже не тест, а мініквест.
Хороший приклад параметризації — «порожній title» у двох формах: порожній рядок і рядок, що складається лише з пробілів:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ParameterizedTest
@ValueSource(strings = {"", " "}) // дві форми blank: порожній рядок і лише пробіли
void shouldReturn400WhenTitleIsBlank(String title) throws Exception {
// Змінюємо рівно один фактор щодо валідного JSON: поле title.
String body = """
{"title":"%s","summary":"Summary","body":"Body","category":"JAVA"}
""".formatted(title);
// Перевіряємо межу web-рівня: запит має впасти з 400, без переходу до бізнес-логіки.
postJson(mvc, "/api/editor/articles", body)
.andExpect(status().isBadRequest());
}
Це один зміст, один клас помилки, один статус. Параметри не «ховають» ідею, а лише прибирають дублювання. Читач тесту все одно розуміє: ми перевіряємо правило @NotBlank для title.
А от поганий приклад (словами) виглядав би так: «проганяємо параметри id="abc", id="999", id="42" і очікуємо різні статуси». Там насправді три різні історії: type mismatch (400), not found (404), conflict (409) — і для кожної має бути окремий тест (або окремий блок @Nested). Інакше ви самі себе обманюєте: тест стане «універсальним», але перестане бути читабельним.
7. Моки в негативній матриці: два базові патерни
У негативних MVC-тестах зазвичай є лише два типи взаємодії із сервісом. Перший тип — сервіс не має викликатися взагалі, бо запит відсікся на web-рівні (валідація, malformed JSON, type mismatch). Другий тип — сервіс викликається, але він кидає контрольований виняток, і ми перевіряємо, що @ControllerAdvice переводить його в коректний HTTP + ApiProblem.
Патерн A: запит поганий → сервіс не чіпаємо
import static org.mockito.BDDMockito.then;
@Test
void shouldStopBeforeServiceWhenRequestIsInvalid() throws Exception {
// Порушуємо одне правило DTO: title порожній.
String body = """
{"title":"","summary":"Summary","body":"Body","category":"JAVA"}
""";
// Перевіряємо, що web-рівень відсікає запит на вході.
postJson(mvc, "/api/editor/articles", body)
.andExpect(status().isBadRequest());
// Ключова перевірка межі: до бізнес-шару ми не маємо дійти.
then(service).shouldHaveNoInteractions();
}
Тут тест доводить межу: web-рівень сам відсікає сміття, а бізнес-шар не бере участі. Це важливо, бо інакше ви будете «лагодити» валідацію в сервісі, а в результаті отримаєте різний формат помилок і складніший у підтримці API.
Патерн B: запит → сервіс кидає бізнес-помилку → advice мапить її в ApiProblem
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@Test
void shouldReturn409WhenInvalidStatusTransition() throws Exception {
// Запит валідний, отже до сервісу доходимо.
// Але сервіс імітує бізнес-конфлікт: перехід між статусами недопустимий.
given(service.submitForReview(42L))
.willThrow(new InvalidStatusTransitionException("Вже надіслано"));
// Тут тестуємо контракт для клієнта: HTTP-статус і ключове поле помилки.
mvc.perform(post("/api/editor/articles/42/submit"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.errorCode").value("INVALID_STATUS_TRANSITION"));
}
Сенс у тому, що ми тестуємо не причину, чому стаття вже надіслана, а як виглядає помилка для клієнта. Внутрішню бізнес-логіку (чому перехід недопустимий) ми тестуємо або unit-тестами policy/сервісу, або іншими шарами. Тут важливий контракт: статус + error payload.
8. Мінішаблони негативних сценаріїв
Коли ви починаєте мислити матрицею, раптом виявляється, що багато негативних тестів — це один і той самий «скелет», просто з різними входами. Це нормально: Spring MVC теж обробляє помилки за типовими маршрутами. Нижче — кілька «мінішаблонів» (саме як ідеї), які допомагають писати тести швидше та рівніше.
Для помилки валідації ви майже завжди робите коректний JSON, порушуєте одне правило DTO, очікуєте 400 і (якщо контракт це передбачає) перевіряєте violations. Плюс корисно довести, що сервіс не викликався.
Для malformed JSON ви даєте тіло, яке не парситься, очікуєте 400 і теж корисно переконатися, що сервіс не викликався. Тут часто не варто вимагати violations — це не field-level validation, а помилка розбору.
Для type mismatch ви ламаєте шлях (наприклад, abc замість Long) або query param, очікуєте 400 і не чіпаєте сервіс. Це окремий клас помилок: ресурс ще навіть не шукали, ми не дійшли до бізнес-логіки.
Для 404 ви формуєте коректний запит, а сервіс налаштовуємо так, щоб він кинув ArticleNotFoundException. Далі перевіряємо 404 і errorCode = "ARTICLE_NOT_FOUND". Це якраз той випадок, коли тест має бути «про перетворення винятку», а не про пошук статті.
Для 409 ми робимо те саме, але виняток — InvalidStatusTransitionException, а очікування — 409 і відповідний error code.
Для 500 (unexpected) у навчальному проєкті зазвичай достатньо одного тесту «на весь клас проблем»: сервіс кидає RuntimeException, advice перетворює його в 500 із безпечним вмістом відповіді. Цей тест потрібен не для того, щоб любити 500, а для того, щоб випадковий NPE не перетворився на «віддаємо stack trace клієнту».
Щоб це закріпити в голові, корисна ось така коротка логічна схема (не як «правило REST», а як практична підказка для тесту):
flowchart TD
A["Запит надійшов"] --> B{"Чи вдалося прочитати вхідні дані?"}
B -- ні --> S400["400: malformed / type mismatch"]
B -- так --> C{"DTO валідний?"}
C -- ні --> V400["400: validation + violations"]
C -- так --> D{"Ресурс знайдено?"}
D -- ні --> N404["404: не знайдено"]
D -- так --> E{"Стан дозволяє дію?"}
E -- ні --> K409["409: конфлікт"]
E -- так --> OK["2xx: успішний сценарій"]
Якщо ви починаєте з цього дерева, то вам стає легше не плутати, де 400, де 404, а де 409. І тести в підсумку виходять більш «рівними» за стилем.
9. Коли додавати новий негативний тест
У певний момент зʼявляється спокуса: «Давайте перевіримо всі поля, всі довжини, всі комбінації». І ось тут починається тестова інфляція. Негативний набір тестів може розпухнути вдесятеро, але впевненості додасться мало, бо ви будете дублювати один і той самий ризик під різними кутами.
Практичний підхід такий: новий тест варто додавати тоді, коли він закриває нову категорію ризику, а не нову варіацію вже закритої. Якщо ви вже перевірили, що title не може бути blank, то додаткові 15 тестів на " " vs "\t" vs "\n" найчастіше не дають нової цінності. А от тест на відсутність category — це інша категорія (missing required field), і він корисний. Аналогічно, якщо ви вже довели, що submit повертає 409 при недопустимому переході, то другий тест на «інший текст повідомлення» зазвичай слабкий, бо повідомлення може змінитися без зміни змісту.
Матриця допомагає тут тим, що ви бачите «порожні клітинки». Якщо у вас є валідація на create, але немає перевірки type mismatch на update, це може бути реальним пробілом (наприклад, /api/editor/articles/abc має стабільно давати 400). Якщо у вас є 404 на update, але немає 404 на submit, це теж пробіл: «submit неіснуючої статті» — реалістичний сценарій.
І ще одна «земна» евристика: якщо ви не можете за 10 секунд пояснити, який ризик покриває новий тест, найімовірніше, він зайвий або його потрібно переформулювати. Негативні тести мають бути не «про все», а про конкретні класи поломок.
10. Типові помилки під час побудови негативної матриці
Помилка №1: звести всі негативні кейси в один плоский клас.
Спочатку це здається швидким: «давайте просто напишемо 30 методів підряд». Але за тиждень ви отримаєте файл, який неможливо читати. Групування через @Nested за endpoint-ами працює як зміст: ви одразу бачите «create», «update», «submit» і не плутаєте кейси різних частин API.
Помилка №2: параметризувати те, що параметризувати не можна.
Параметризований тест хороший, коли у вас один зміст і один статус. Якщо ви намагаєтеся одним параметризованим методом покрити 400, 404 і 409, ви ховаєте семантику статусів і робите тест менш читабельним. Краще три прості тести, ніж один «розумний», який ніхто не розуміє.
Помилка №3: перевіряти тільки статус і ігнорувати контракт помилки.
Статус — це важливо, але для клієнта API часто критично, що в ApiProblem є errorCode і (у випадку validation) violations. Якщо ви перевіряєте тільки 400, то зміна структури помилки може зламати клієнтів, а ваші тести будуть зеленими. Потрібен баланс: не потрібно перевіряти кожну літеру, але ключові поля контракту варто фіксувати.
Помилка №4: забути перевірити, що сервіс не викликався під час помилки входу.
Без shouldHaveNoInteractions() ви не доводите межу web-рівня. Може виявитися, що контролер усе одно викликає сервіс, а сервіс кидає виняток, і ви випадково отримуєте 400. Тест буде зеленим, але архітектурно ви вже «протягнули сміття» вглиб застосунку.
Помилка №5: сховати HTTP у занадто розумні хелпери.
Якщо хелпер повністю ховає URL, тіло запиту та заголовки, тест перестає бути перевіркою HTTP-контракту й перетворюється на виклик «якоїсь магії». Прибирайте лише рутину (Content-Type, Accept), але залишайте видимими ключові речі: endpoint і payload.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ