Негативні перевірки й обробка помилок
Зміст
Позитивний тест доводить, що фіча працює, коли їй дали правильні дані. Але користувач рідко буває чемним: він надішле порожнє поле, вставить емодзі в номер телефону, натисне «Зберегти» двічі, а фронтенд у розпал релізу зліпить кривий JSON. Зрілість API видно не з того, як він обробляє щасливий шлях, а з того, як він відмовляє: чи повертає осмислений код помилки, чи не падає в 500 від зайвого пробілу, чи каже клієнту що саме не так.
Саме тому негативні перевірки мають високу вагу на співбесідах: вони відрізняють того, хто вміє клікати happy path, від того, хто думає про надійність (robustness). Питання «а що поверне ваш API, якщо передати рядок замість числа?» відсіює половину кандидатів. Ця глава — про те, як улаштована помилка на рівні HTTP, які негативні вхідні дані треба ганяти обов'язково і як не переплутати 400, 404, 422 та 409, коли інтерв'юер спитає різницю.
Позитив, негатив і «щасливий шлях бреше»
Позитивна перевірка (positive check) підтверджує, що система робить те, що має, за валідних умов. Негативна (negative check) підтверджує, що система коректно відмовляється робити те, чого робити не можна: відхиляє невалідний ввід, не пускає до чужих даних, не ламається від межових значень. Це пряме продовження досвідних технік із тест-дизайну — передбачення помилок (error guessing) і атак на поле вводу, тільки застосованих до тіла HTTP-запиту, а не до форми в браузері.
Ключова думка: добре оброблена помилка — це очікувана поведінка, а не збій. Якщо клієнт передав нісенітницю, правильна реакція сервера — ввічливо сказати «так не можна» зрозумілим кодом і тілом, а не впасти або мовчки проковтнути дані. Тому в негативному тесті ми перевіряємо не «щоб не працювало», а «щоб не працювало правильним чином»: конкретний статус, конкретна структура помилки, жодних побічних ефектів (запис не створено, гроші не списано).
4xx проти 5xx: чия це вина
Уся система HTTP-кодів помилок ділиться навпіл за одним питанням — хто винен. Про самі статус-коди ми докладно говорили в розділі про веб; тут важливий саме контракт відповідальності.
| Клас | Хто винен | Що каже клієнту | Приклади |
|---|---|---|---|
| 4xx | Клієнт | «Ти надіслав щось не те — виправ запит» | 400, 401, 403, 404, 409, 422, 429 |
| 5xx | Сервер | «Я не впорався — з твоїм запитом усе гаразд» | 500, 502, 503, 504 |
З цього поділу випливає найважливіший інваріант негативного тестування: невалідний ввід ніколи не повинен давати 5xx. Якщо ти надіслав криву дату, а сервер відповів 500 Internal Server Error, — це баг, а не «ну помилка ж повернулась». 500 означає, що на бекенді полетів необроблений виняток (unhandled exception): валідація не спрацювала, дані дійшли до місця, яке їх не чекало, і код упав. Правильна реакція на будь-який кривий ввід — це 4xx, свідома і контрольована відмова.
Під час дебагу варто розрізняти 5xx-підвиди: 500 — впав сам застосунок; 502 Bad Gateway і 504 Gateway Timeout — проблема між проксі/балансувальником і сервісом; 503 Service Unavailable — сервіс тимчасово недоступний (перевантаження, деплой). Для тесту різниця практична: 500 — майже завжди баг застосунку, а 502/503/504 частіше вказують на інфраструктуру чи гонку деплою, ніж на код фічі.
Структура тіла помилки: code, message, details
Статус-код каже категорію проблеми, але 400 на створенні користувача може означати і «email невалідний», і «пароль закороткий», і «такий логін зайнятий». Щоб клієнт (і тестувальник) зрозумів конкретику, помилка має нести структуроване тіло. Усталений мінімум — три складники:
code— стабільний машиночитний ідентифікатор помилки (VALIDATION_ERROR,EMAIL_ALREADY_EXISTS). Саме на ньому клієнтський код будує розгалуження. Він не залежить від мови й не змінюється між релізами.message— людиночитний опис для логів і розробника. Може бути локалізований, може переформулюватись — тому на нього не можна вішати асерти рядком і не можна будувати на ньому програмну логіку.details— розбивка по полях: що саме і чому не пройшло. Тут живе найцінніше для негативних тестів — перелік конкретних порушень.
{
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{ "field": "email", "issue": "must be a valid email address" },
{ "field": "age", "issue": "must be an integer >= 18" }
]
}
Звідси практичне правило перевірок: асерти вішаємо на code і на структуру details, а не на текст message. Тест, який порівнює message з рядком «Request validation failed», зламається від першого ж переписування копірайту або вмикання локалізації — і це буде фальшивий фейл, не баг.
problem+json (RFC 9457)
Щоб кожен API не винаходив власний формат помилки, існує стандарт — Problem Details for HTTP APIs, описаний у RFC 9457 (який 2023 року замінив старіший RFC 7807). Він задає єдину структуру й окремий media type application/problem+json у заголовку Content-Type. Базові поля (кожне з них опціональне):
| Поле | Значення |
|---|---|
type | URI, що ідентифікує тип проблеми (посилання на її опис) |
title | Короткий людиночитний заголовок типу проблеми |
status | HTTP-статус-код, продубльований у тілі |
detail | Пояснення саме цього випадку |
instance | URI конкретного інциденту (напр., посилання на цей запит) |
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://example.com/errors/validation",
"title": "Your request is not valid.",
"status": 422,
"detail": "age must be an integer >= 18",
"instance": "/users/create/req-8a2f"
}
Стандарт дозволяє додавати власні поля (extension members) — те саме details з розбивкою по полях кладуть туди. Для тесту проти такого API з'являється ще одна перевірка: Content-Type має бути application/problem+json, а поле status у тілі — збігатися зі статус-кодом відповіді. Але не всі API дотримуються цього стандарту: багато хто має власний формат помилки, і контракт визначає саме специфікація (OpenAPI), а не RFC.
Категорії негативних вхідних даних
Це кістяк негативного набору для будь-якого ендпоінта, що приймає тіло. Кожна категорія — окремий тест, бо ламає систему в іншому місці.
Невалідний JSON. Обірваний або синтаксично зламаний payload ({"name": }). Тіло навіть не парситься — сервер має відповісти 400 Bad Request ще до валідації бізнес-правил. Класична пастка: недбалий парсер віддає тут 500.
Відсутні обов'язкові поля. Прибираємо required-поле зі схеми. Очікуємо відмову з указівкою, якого саме поля бракує. Окремо перевіряємо межу «поле є, але порожнє» ("") і «поле є, але null» — це три різні випадки, і API може обробляти їх по-різному (порожнє vs відсутнє vs явний null — важлива тема з перевірок відповіді).
Неправильні типи. Рядок замість числа, число замість булевого, об'єкт замість масиву. Улюблене age: "thirty". Перевіряємо, що сервер типізовано відхиляє, а не намагається «здогадатись» і не падає.
Зайві / невідомі поля. Додаємо в тіло поле, якого немає в контракті. Тут два легітимні варіанти поведінки — і що саме правильно, диктує специфікація: одні API мовчки ігнорують зайве, інші свідомо відхиляють запит (additionalProperties: false у JSON Schema). Важливий безпековий кут: якщо API сліпо мапить усі вхідні поля на модель, зайве поле на кшталт "role": "admin" або "isVerified": true може проскочити в базу — це вразливість mass assignment. Тому «зайве поле проігноровано» варто перевіряти не лише на статус, а й на те, що воно не потрапило в збережений об'єкт.
Довгі значення, спецсимволи, ін'єкції — як smoke. Рядок на 10 000 символів, unicode й емодзі, керівні символи, а також класичні payload-и ін'єкцій (' OR 1=1 --, <script>, ${jndi:...}) ганяємо тут поверхнево, як перевірку стійкості: система не повинна падати в 500, віддавати сирий стек-трейс чи виконувати вставлене. Це саме smoke — переконатись, що вхід санітизується і межі довжини є. Глибоке тестування безпеки (SQL-ін'єкції, XSS, повний розбір payload-ів) — окрема тема розділу про безпеку; тут ми лише ставимо прапорець «сервіс не розсипається від брудного вводу».
// Playwright: невалідний тип поля має дати 4xx, а не 500
test('рядок замість числа → 422, тіло з code', async ({ request }) => {
const res = await request.post('/api/users', {
data: { email: 'a@b.co', age: 'thirty' },
});
expect(res.status()).toBe(422); // 422 — бо так у контракті цього API; міг бути й 400
const body = await res.json();
expect(body.code).toBe('VALIDATION_ERROR'); // асерт на code
expect(body.details).toContainEqual( // а не на текст message
expect.objectContaining({ field: 'age' }),
);
});
404 vs 400 vs 422: класична плутанина
Три коди, які на співбесіді просять розрізнити, бо їх постійно плутають і в житті. Різниця — у тому, на якому етапі обробки запит спіткнувся.
400 Bad Request— запит зламаний на рівні синтаксису чи форми: сервер не може його навіть розібрати. Кривий JSON, битий параметр, поламаний заголовок. «Я не розумію, що ти взагалі надіслав.»404 Not Found— запит зрозумілий, але ресурс, до якого він адресований, не існує.GET /users/99999, якого немає. «Я тебе зрозумів, але такого немає.»422 Unprocessable Content— запит синтаксично коректний (валідний JSON, усі типи на місці), але семантично неприйнятний: порушено правило валідації чи бізнес-логіки. «Я тебе зрозумів, синтаксис ок, але за змістом так не можна» — вік менший за 18, дата в минулому, сума від'ємна.
Важлива чесна поправка: межа 400 vs 422 не залізобетонна. Багато API взагалі не використовують 422 і віддають 400 на будь-яку помилку клієнтського вводу — і це не баг, якщо так у контракті. 422 (формально визначений у RFC 9110, історично прийшов із WebDAV) корисний тим, що відокремлює «не розібрав» від «розібрав, але не влаштовує». Тому в тесті звіряємось не з власним уявленням про «правильний» код, а зі специфікацією цього API. Головне — щоб API був консистентний і не віддавав на ту саму помилку то одне, то інше.
Ще нюанс 404: іноді його повертають навмисне замість 403 Forbidden, щоб не розкривати існування ресурсу тому, у кого немає доступу. Це вже територія авторизації в API — там 401 vs 403 vs 404 розібрані як окрема тема.
409 і конкурентне редагування
409 Conflict — це відмова через конфлікт зі станом ресурсу: запит сам по собі валідний, але суперечить тому, що зараз є на сервері. Два типові джерела: спроба створити дублікат (унікальне обмеження — другий користувач із тим самим email) і конкурентне редагування.
Проблема конкурентного редагування називається втрачене оновлення (lost update). Двоє відкрили один документ у версії 1. Перший зберіг — стало 2. Другий, який досі бачить версію 1, теж зберігає — і затирає зміни першого, навіть не знаючи про них. Дані мовчки зникли.
Захист — оптимістичне блокування (optimistic concurrency): у ресурсу є маркер версії (числове поле version або HTTP-заголовок ETag, механіку якого ми розбирали в кешуванні). Клієнт, зберігаючи, надсилає ту версію, яку бачив. Якщо на сервері версія вже інша — сервер відхиляє запис. Тут два усталені варіанти, і на співбесіді цінують, що ти їх розрізняєш:
- прикладний рівень: клієнт кладе
versionу тіло, сервер порівнює і на розбіжності віддає409 Conflict; - HTTP-рівень: клієнт надсилає умовний запит із заголовком
If-Match: "etag", і якщо ETag не збігається, сервер віддає412 Precondition Failed.
Для тестувальника головне — вміти відтворити гонку, а не просто знати теорію. Сценарій детермінований: створюємо ресурс, читаємо його версію, оновлюємо його «від імені іншого» (версія зростає), а потім намагаємось записати зі старою версією й перевіряємо відмову.
test('редагування застарілої версії → 409', async ({ request }) => {
const doc = await (await request.post('/api/docs', { data: { title: 'v1' } })).json();
// хтось інший уже оновив ресурс — версія зросла
await request.put(`/api/docs/${doc.id}`, {
data: { title: 'v2', version: doc.version },
});
// ми пишемо зі старою, вже неактуальною версією
const stale = await request.put(`/api/docs/${doc.id}`, {
data: { title: 'v3', version: doc.version },
});
expect(stale.status()).toBe(409);
});
Тема тісно пов'язана з ідемпотентністю та гонками з тест-дизайну для API: повторний PUT тими самими даними має бути безпечним, а от PUT зі застарілим станом — ні.
Rate limiting: 429 і Retry-After
Щоб один клієнт не завалив сервіс запитами, API обмежують частоту звернень (rate limiting). Коли ліміт вичерпано, сервер відповідає 429 Too Many Requests (код з RFC 6585). Це не помилка застосунку — це свідомий захисний механізм, і тестувати його треба функціонально, а не навантаженням (саме навантажувальне тестування — окрема тема свого розділу).
Ключовий заголовок відповіді — Retry-After (стандартизований у RFC 9110). Він каже, коли можна повторити спробу, і має один із двох форматів: кількість секунд (Retry-After: 30) або абсолютна HTTP-дата. Часто поруч ідуть інформаційні заголовки на кшталт X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset — це поширена конвенція, але не єдиний стандарт, тож точні назви залежать від провайдера.
Що перевіряти функціонально: після перевищення ліміту приходить саме 429, а не 500 чи мовчазне ігнорування; у відповіді є Retry-After з осмисленим значенням; після вказаної паузи ендпоінт знову приймає запити, а не блокується назавжди.
Окремий практичний бік: rate limiting — часте джерело флаку в автотестах. Сюїта, що ганяє сотні запитів паралельно, легко впирається в ліміт і починає «мигати» червоним. Не звинувачуй застосунок передчасно (фейл автотесту ≠ баг застосунку): правильний клієнт має поважати Retry-After — почекати й повторити, а не падати відразу.
const res = await request.get('/api/data');
if (res.status() === 429) {
const retry = Number(res.headers()['retry-after']) * 1000;
await new Promise((r) => setTimeout(r, retry)); // почекати й повторити, а не впасти
}
Типові помилки
- Виглядає як «негативний тест зелений, отже, все добре» — а насправді API повернув
200 OKі мовчки проковтнув невалідні дані. Перевіряй не лише статус, а й що побічного ефекту не сталося: запис не створено, значення не змінилось. - Виглядає як «помилка ж повернулась,
500— це нормально» — а насправді500на кривому вводі це баг: валідація мала відсікти дані й дати4xx.5xx— вина сервера, не клієнта. - Виглядає як надійна перевірка тексту помилки — а насправді асерт на
messageламається від переписаного копірайту чи локалізації. Асерти вішай на стабільнийcodeі структуруdetails. - Виглядає як «
409не відтворюється, значить його немає» — а насправді конфлікт вимагає гонки: спершу підняти версію ресурсу, потім писати застарілою. Один послідовний запит його не покаже. - Виглядає як «
429в CI = баг лімітера» — а насправді це твоя сюїта вперлась у ліміт, а клієнт не поважаєRetry-After. Перш ніж звинувачувати API, зроби backoff. - Виглядає як «зайве поле проігноровано — ок» — а насправді воно могло потрапити в базу (mass assignment). Перевіряй, що поля
role/isAdminіз запиту не зберігаються.
Підсумок
- Невалідний ввід — це очікуваний сценарій, а не збій: правильна відповідь на нього — контрольований
4xxз осмисленим тілом, ніколи не5xx. - Тіло помилки перевіряй за стабільним
codeі структуроюdetails, а не за текстомmessage; якщо API дотримується problem+json — звіряй ще йContent-Type: application/problem+json. 400— не розібрав запит,404— ресурсу немає,422— розібрав, але семантика невалідна,409— конфлікт зі станом; але точний код диктує специфікація API, не твоє уявлення.- Конкурентне редагування ловиться через версію/ETag:
409(прикладна версія) або412(умовнийIf-Match); гонку треба вміти відтворити, а не лише назвати. 429зRetry-After— це захист, а не баг; тестуй його функціонально й змушуй свій клієнт поважати паузу, щоб не отримати фальшивий флак.
Що питають на співбесіді
- «Передали рядок замість числа й отримали
500. Це баг?» — Так. Валідація мала відсікти невалідний тип і повернути400/422.500означає необроблений виняток: дані дійшли до коду, який їх не чекав. Інтерв'юер дивиться, чи розумієш поділ 4xx/5xx за відповідальністю. - «Яка різниця між
400,404і422?» —400— зламаний синтаксис запиту;404— ресурс не існує;422— синтаксис валідний, але семантика/бізнес-правило порушені. Сильна відповідь додає, що межа400/422умовна й визначається контрактом, а головне — консистентність. - «Як протестувати конкурентне редагування?» — Через оптимістичне блокування: прочитати версію/ETag, оновити ресурс «від іншого клієнта», потім записати застарілою версією й перевірити
409(або412дляIf-Match). Дивляться, чи назвеш проблему lost update і чи вмієш відтворити гонку. - «Що перевіряєш у тілі помилки?» — Стабільний
code, розбивкуdetailsпо полях,Content-Type; і що наmessageасерти не вішаю. Плюс — що побічного ефекту не сталося. - «Що робить твій тест, коли API віддає
429?» — ПоважаєRetry-After: чекає й повторює, а не падає. Це маркер, що кандидат відрізняє захист сервера від бага і не плутає rate limiting із флаком продукту. - «Складіть негативний набір для ендпоінта створення користувача.» — Відсутні обов'язкові поля, неправильні типи, зайві поля, межові довжини й спецсимволи як smoke, дублікат (email зайнятий →
409), невалідний JSON. Дивляться на системність, а не на випадкові приклади.
Джерела
- RFC 9110 — HTTP Semantics — канонічне визначення статус-кодів (400, 404, 409, 412, 422) і заголовка
Retry-After. - RFC 9457 — Problem Details for HTTP APIs — стандарт формату помилки
application/problem+json(замінює RFC 7807). - RFC 6585 — Additional HTTP Status Codes — джерело коду
429 Too Many Requests. - MDN: HTTP response status codes — зручний довідник по кодах із прикладами.
- OWASP API Security Top 10 (2023) — безпековий контекст глави; mass assignment у редакції 2023 входить до категорії API3 Broken Object Property Level Authorization (глибше — у розділі про безпеку).
Що таке негативна перевірка і чим вона відрізняється від позитивної?
Позитивна перевірка (positive check) відповідає на питання «чи працює система, коли все зробили правильно», негативна (negative check) — «чи правильно система відмовляє, коли зробили неправильно». Об'єкт негативного тесту — реакція на невалідний ввід, спробу дістатись чужих даних, межові значення. По суті це досвідні техніки тест-дизайну — error guessing і «атаки» на поля вводу — перенесені з форми в браузері на тіло HTTP-запиту. Важливо: асертимо не сам факт відмови, а її якість — очікуваний статус-код, структуроване тіло помилки і відсутність побічних ефектів (нічого не створилось, нічого не списалось).
Чому кажуть, що «щасливий шлях бреше»?
Бо зелений happy path доводить лише один сценарій із багатьох: правильні дані, слухняний клієнт. Реальний трафік інакший: порожні поля, емодзі там, де чекали цифри, подвійний клік по «Зберегти», зламаний JSON від фронтенду в день релізу. Тому справжню зрілість API оцінюють за поведінкою у відмові: осмислений код замість 500, зрозуміле тіло помилки, вказівка, що саме не так. Звідси й вага теми на співбесідах — питання про негативні сценарії швидко показує, чи мислить кандидат ширше за happy path, чи думає про надійність (robustness).
У чому головна різниця між 4xx і 5xx?
Критерій поділу один — на чиєму боці причина. 4xx: сервер запит опрацював і свідомо відхилив, бо проблема в самому запиті — виправляти має клієнт. 5xx: запит міг бути цілком коректним, але сервер не впорався — проблема на його боці. Для негативного тестування з цього випливає центральний інваріант: жоден кривий ввід не має закінчуватись 5xx. Якщо закінчився — сервер не відмовив, а зламався, і це знахідка.
Передали рядок замість числа й отримали 500. Це баг?
Так, баг. Очікувана поведінка — контрольована відмова 400 або 422 після спрацювання валідації. Натомість 500 Internal Server Error сигналізує про необроблений виняток: шар валідації пропустив значення, воно дісталося коду, який на такий тип не розрахований, і той упав. Тригер справді створив клієнт, але реакція — окремий дефект бекенда: стійкий сервер і на сміття відповідає кодом класу 4xx. Інтерв'юер цим питанням перевіряє, чи розумієш поділ відповідальності 4xx/5xx.
Чим 500 відрізняється від 502, 503 і 504?
Усі чотири — серверний клас, але падіння відбувається в різних точках ланцюжка «клієнт → проксі/балансувальник → застосунок». 500 — виняток усередині самого застосунку. 502 Bad Gateway — проксі отримав від апстріму нечитабельну відповідь або не достукався до нього. 503 Service Unavailable — сервіс тимчасово не приймає запити (деплой, перевантаження), нерідко разом із Retry-After. 504 Gateway Timeout — апстрім не відповів проксі за відведений час. Практичний фільтр при дебагу: одиничний 500 — шукай баг у коді фічі; хвиля 502/503/504 після викочування — дивись на інфраструктуру й процес деплою.
З яких частин складається структуроване тіло помилки?
Мінімальний усталений набір — три поля з різними ролями. code — машиночитний ідентифікатор типу помилки (VALIDATION_ERROR, EMAIL_ALREADY_EXISTS): стабільний між релізами, незалежний від мови інтерфейсу; саме по ньому клієнт розгалужує логіку. message — текст для людини (розробника, логів): його дозволено переписувати й локалізувати. details — поелементна розбивка: яке поле не пройшло і чому. Для тестувальника найцінніші code і details — на них і тримаються асерти; message лишається для дебагу.
Чому на message не можна вішати асерти рядком?
Бо message за призначенням нестабільний: це людиночитний текст, який команда може переписати заради кращого формулювання чи перекласти при локалізації — без жодної зміни поведінки API. Рядковий асерт на нього почервоніє від такої зміни, і сюїта дасть фальшивий фейл там, де бага немає. Стабільна частина контракту помилки — code і структура details: вони машиночитні й не залежать від копірайту. Тож правило просте: логіка й асерти — на code/details, message — хіба що в лог для діагностики.
Що таке problem+json і навіщо він потрібен?
Це стандартизована відповідь на зоопарк самописних форматів помилок: Problem Details for HTTP APIs, RFC 9457 (у 2023 році замінив попередника — RFC 7807). Стандарт фіксує структуру тіла (type, title, status, detail, instance) і власний media type — application/problem+json у Content-Type. Специфічні дані (ту саму розбивку по полях) дозволено додавати як extension members. Тестувальнику це дає дві додаткові перевірки: чи стоїть правильний Content-Type і чи збігається status у тілі зі статус-кодом самої відповіді. При цьому дотримання RFC — не обов'язок: реальний формат помилки визначає специфікація конкретного API (OpenAPI).
Які категорії негативних вхідних даних треба ганяти обов'язково?
Базовий набір для будь-якого ендпоінта з тілом складається з категорій, кожна з яких б'є в інший шар системи. Синтаксично зламаний JSON — валиться ще на парсингу, очікуємо 400 до будь-якої бізнес-валідації. Пропущені обов'язкові поля — очікуємо відмову, яка називає конкретне поле. Хибні типи (рядок замість числа тощо) — очікуємо типізовану відмову, не спробу «здогадатись». Поля поза контрактом — залежно від специфікації їх або ігнорують, або відхиляють. Наостанок — надмірні довжини, спецсимволи та класичні ін'єкційні payload-и, але лише в режимі smoke: сервіс не падає, стек-трейс не тече, вставлене не виконується.
Чому «поле відсутнє», «поле порожнє» і «поле null» — три різні випадки?
Бо для сервера це три семантично різні входи, і валідація кожного може йти окремою гілкою. Ключа немає в тілі — клієнт узагалі не передав значення. Ключ є зі значенням "" — значення передане, але порожнє. Ключ є зі значенням null — клієнт явно передав «нічого». API має право (і часто мусить) реагувати на них по-різному, тому об'єднувати їх в один тест-кейс — типова помилка: дефект нерідко живе рівно в одному з трьох варіантів, наприклад null прослизає там, де порожній рядок коректно відбивається.
Що таке mass assignment і чому зайве поле треба перевіряти не лише на статус?
Mass assignment — вразливість, за якої бекенд без фільтра переносить усі поля запиту на модель даних. Тоді додане зловмисником поле поза контрактом — "role": "admin", "isVerified": true — тихо опиняється в базі, а відповідь при цьому виглядає цілком успішною. Саме тому асерта на статус тут замало: треба прочитати створений ресурс і переконатися, що підкинуте поле в нього не записалось. Щодо легітимної поведінки контракт може обирати одне з двох: мовчки ігнорувати невідомі поля або відхиляти такий запит (additionalProperties: false у JSON Schema) — правильний варіант диктує специфікація.
У чому різниця між 400, 404 і 422?
Розрізняй за етапом, на якому запит зійшов з дистанції. 400 Bad Request — сервер не зміг запит навіть розібрати: зламаний синтаксис, битий параметр, «не розумію, що ти надіслав». 404 Not Found — запит розібраний, але цільового ресурсу не існує (GET /users/99999). 422 Unprocessable Content — розібрати вдалося, синтаксично все гаразд, але зміст порушує валідаційне чи бізнес-правило: вік менший за дозволений, дата в минулому, від'ємна сума. Сильний кандидат додає: межа 400/422 — питання контракту конкретного API, а не абсолютної істини.
Чому межа між 400 і 422 не залізобетонна?
Бо 422 — опціональний інструмент, а не вимога: чимало API принципово обходяться самим 400 для всіх клієнтських помилок вводу, і поки це зафіксовано в контракті — багом воно не є. Цінність 422 (нині формально описаний у RFC 9110, а родом із WebDAV) — у розділенні «не зміг розібрати» й «розібрав, але зміст неприйнятний». Для тесту висновок один: очікуваний код береться зі специфікації API, а не з власних уявлень про красиве. Червоний прапорець — не вибір між 400 і 422, а непослідовність, коли та сама помилка повертає то один код, то інший.
Що означає 409 Conflict і коли він виникає?
409 каже: із запитом усе гаразд, але він несумісний із поточним станом ресурсу на сервері. Два класичні сценарії: порушення унікальності (реєстрація другого користувача на вже зайнятий email) і конкурентне редагування, коли клієнт намагається записати зміни поверх версії, якої вже не існує. Від 422 відрізняється саме цим: 422 — проблема в тілі запиту, 409 — у розбіжності зі станом сервера. Для тестувальника цінність не у визначенні, а в умінні конфлікт відтворити.
Що таке втрачене оновлення (lost update) і як від нього захищаються?
Це тиха втрата даних при конкурентному записі. Обидва клієнти прочитали ресурс у версії 1; перший записав свої зміни — на сервері версія 2; другий, нічого не знаючи, записав свої — і зміни першого зникли без сліду й без помилки. Захист — оптимістичне блокування (optimistic concurrency): ресурс носить маркер версії (поле version чи HTTP-заголовок ETag), клієнт при записі пред'являє версію, від якої відштовхувався, і якщо на сервері вона вже інша — запис відхиляється. Гонка з тихої втрати перетворюється на явну відмову, яку клієнт може обробити.
Яка різниця між 409 і 412 у конкурентному редагуванні?
Мета одна — не дати записати поверх зміненого стану, — але механізми лежать на різних рівнях. 409 Conflict повертає прикладна логіка: версію передають полем у тілі запиту, сервер сам порівнює її з актуальною. 412 Precondition Failed — механізм самого HTTP: клієнт робить умовний запит із заголовком If-Match і ETag-ом, і сервер відмовляє, якщо передумова не виконалась (ETag уже не той). Коротко: 409 — «конфлікт побачила бізнес-логіка», 412 — «не пройшла передумова умовного запиту». На співбесіді цінують саме розрізнення цих двох підходів.
Як відтворити конфлікт конкурентного редагування в тесті?
Детермінованим сценарієм — випадкова гонка тут не потрібна. Крок 1: створити ресурс і зафіксувати його версію (чи ETag). Крок 2: змінити ресурс іще раз, граючи роль «іншого клієнта», — актуальна версія на сервері пішла вперед. Крок 3: спробувати записати з версією з кроку 1 і заасертити відмову: 409, якщо версія прикладна, або 412 для If-Match. Типова хибна логіка — «ганяю запити, 409 не бачу, отже, його немає»: одиночний послідовний запит конфлікту принципово не створює, бо стан між читанням і записом ніхто не змінив.
Що таке 429 і як його правильно тестувати?
429 Too Many Requests (визначений у RFC 6585) — відповідь механізму rate limiting, коли клієнт перевищив дозволену частоту звернень. Це навмисний захист, а не збій, тому й перевіряється він функціонально, без навантажувальних інструментів. Три ключові перевірки: перевищення ліміту дає саме 429 (не 500 і не тихе ігнорування запитів); відповідь несе Retry-After з осмисленим значенням — секунди або абсолютна HTTP-дата; після витриманої паузи ендпоінт справді відновлює обслуговування. Супутні X-RateLimit-* заголовки корисні, але це конвенція без єдиного стандарту — точні назви в кожного провайдера свої.
Що має робити твій тест, коли API віддає 429?
Прочитати Retry-After, витримати паузу й повторити запит — а не червоніти з першої ж відмови. Контекст: паралельна сюїта на сотні запитів регулярно доганяє ліміт, і без backoff-логіки це виглядає як «випадкові» падіння в CI. Висновок «429 в CI — значить, лімітер поламаний» майже завжди хибний: захист якраз спрацював як належить, це тестовий клієнт поводиться неправильно. Уміння розвести «сервер захищається» і «продукт флакає» — саме те, що інтерв'юер хоче почути в цьому питанні.
Складіть негативний набір для ендпоінта створення користувача.
Відповідати варто категоріями, а не переліком випадкових значень. Зламаний синтаксис JSON → 400 ще на парсингу. Кожне обов'язкове поле: прибрати з тіла, передати "", передати null — три окремі кейси з відмовою, що називає поле. Хибні типи (age: "thirty") → контрольована відмова, не 500. Поле поза контрактом (role: "admin") → перевірка і статусу, і того, що воно не збереглося (mass assignment). Надмірна довжина, unicode, ін'єкційні payload-и → smoke на стійкість. Зайнятий email → 409. І наскрізна дисципліна для всіх кейсів: асерт на code/details замість тексту message плюс перевірка, що побічного ефекту немає — користувач не створився.
Три кейси, де негативна перевірка вирішує, чи API падає правильно: систематичний негативний набір для створення користувача з асертами на code/details, відтворення гонки конкурентного редагування до 409, і функціональний тест 429, який поважає Retry-After. Скрізь — що перевіряти й чому саме так, а не «аби помилка повернулась».
Кейс 1. Негативний набір для POST /api/users
Ендпоінт створення користувача приймає email, age і password. Позитивний тест уже зелений — тепер треба довести, що API відмовляє правильним чином. Спершу розкладаємо ввід за категоріями, кожна ламає систему в іншому місці:
| Категорія | Приклад тіла | Очікування |
|---|---|---|
| Невалідний JSON | {"email": } | 400, парсер не впав у 500 |
| Відсутнє обов'язкове поле | email прибрано | 4xx, details вказує на email |
Порожнє vs null | email: "" / email: null | окремі кейси, різна реакція можлива |
| Неправильний тип | age: "thirty" | типізована відмова, не 500 |
| Зайве поле | role: "admin" | не потрапило в базу (mass assignment) |
| Дублікат | зайнятий email | 409 Conflict |
| Довге значення / спецсимволи | email на 10 000 символів | smoke: сервіс не розсипався |
Головна дисципліна — асерт не лише на статус, а й на стабільний code, структуру details і відсутність побічного ефекту:
import { test, expect } from '@playwright/test';
test('неправильний тип age → 422 з code, а не 500', async ({ request }) => {
const res = await request.post('/api/users', {
data: { email: 'a@b.co', age: 'thirty', password: 'Str0ng!pass' },
});
expect(res.status()).toBe(422); // саме 4xx, ніколи не 5xx; точний код (400 чи 422) диктує контракт
const body = await res.json();
expect(body.code).toBe('VALIDATION_ERROR'); // асерт на стабільний code
expect(body.details).toContainEqual( // а не на текст message
expect.objectContaining({ field: 'age' }),
);
});
Окремо перевіряємо, що зайве поле не проскочило в збережений об'єкт — саме тут ховається mass assignment:
test('зайве поле role не потрапляє в базу', async ({ request }) => {
const res = await request.post('/api/users', {
data: { email: `u${Date.now()}@b.co`, age: 30, password: 'Str0ng!pass', role: 'admin' },
});
// статус може бути навіть 201 — це ще нічого не гарантує
const created = await res.json();
expect(created.role).not.toBe('admin'); // роль з запиту не прийнялась
});
Що дивитися й чому:
500на будь-якому кейсі цієї таблиці — дефект бекенда. Валідація мала відсікти сміття й дати4xx. Тригер прийшов від тесту (кривий ввід), але падіння сервера — окрема знахідка.- Асерт на
messageзаборонений. Порівняння з рядком «Request validation failed» зламається від локалізації чи переписаного копірайту — фальшивий фейл, а не баг. - Успішний статус ≠ коректна поведінка. Негативний тест на зайве поле, який перевіряє лише
201, пропустить mass assignment: полеroleмогло тихо лягти в базу. - Порожнє,
nullі відсутнє — три тести, не один. API часто обробляє їх по-різному, і реальний баг ховається саме в одному з випадків.
Кейс 2. Відтворити гонку конкурентного редагування до 409
409 не з'явиться від одного послідовного запиту — конфлікт вимагає, щоб між читанням і записом стан ресурсу змінився. Сценарій детермінований: створюємо ресурс, піднімаємо версію «від іншого клієнта», потім пишемо застарілою версією.
test('редагування застарілої версії → 409', async ({ request }) => {
const doc = await (await request.post('/api/docs', { data: { title: 'v1' } })).json();
// хтось інший уже оновив ресурс — версія на сервері зросла
await request.put(`/api/docs/${doc.id}`, {
data: { title: 'v2', version: doc.version },
});
// ми пишемо зі старою, вже неактуальною версією — це втрачене оновлення
const stale = await request.put(`/api/docs/${doc.id}`, {
data: { title: 'v3', version: doc.version },
});
expect(stale.status()).toBe(409);
});
Якщо API захищає стан на HTTP-рівні через ETag, замість тіла-версії йде умовний заголовок, а очікуваний код — 412:
test('умовний If-Match зі старим ETag → 412', async ({ request }) => {
const first = await request.get('/api/docs/42');
const etag = first.headers()['etag'];
// хтось оновив документ — ETag на сервері змінився
await request.put('/api/docs/42', { data: { title: 'updated' } });
const stale = await request.put('/api/docs/42', {
headers: { 'If-Match': etag }, // ETag, який ми бачили ДО чужого запису
data: { title: 'mine' },
});
expect(stale.status()).toBe(412);
});
Що дивитися й чому:
- Гонку треба зібрати руками. «
409не відтворюється, значить його немає» — хибний висновок: спершу підніми версію, потім пиши застарілою. 409vs412— це рівень захисту.409— прикладнаversionу тілі,412— умовний HTTP-запитIf-Match. Очікуваний код диктує контракт API.- Повторний
PUTтими самими даними має бути безпечним. Небезпечний самеPUTзі застарілим станом — ідемпотентність і конфлікт версій це різні речі.
Кейс 3. 429 і Retry-After: функціональний тест, а не навантаження
429 тестують функціонально: що після ліміту приходить саме він, що є осмислений Retry-After і що після паузи ендпоінт знову працює. Навантажувальне тестування — окрема тема; тут ми перевіряємо контракт захисту.
test('після ліміту → 429 з Retry-After, потім знову 2xx', async ({ request }) => {
let limited: Awaited<ReturnType<typeof request.get>> | undefined;
// вичерпуємо ліміт
for (let i = 0; i < 100; i++) {
const res = await request.get('/api/data');
if (res.status() === 429) { limited = res; break; }
}
expect(limited, 'ліміт не спрацював — не 500 і не тиша').toBeTruthy();
const retry = Number(limited!.headers()['retry-after']);
expect(retry).toBeGreaterThan(0); // осмислене значення в секундах
// поважаємо паузу — і ендпоінт має ожити, а не блокуватись назавжди
await new Promise((r) => setTimeout(r, retry * 1000));
const after = await request.get('/api/data');
expect(after.status()).toBe(200);
});
Той самий Retry-After рятує звичайні сюїти від фальшивого флаку — правильний клієнт чекає й повторює, а не падає з першого 429:
async function getWithBackoff(request, url, tries = 3) {
for (let i = 0; i < tries; i++) {
const res = await request.get(url);
if (res.status() !== 429) return res;
const retry = Number(res.headers()['retry-after'] ?? 1);
await new Promise((r) => setTimeout(r, retry * 1000)); // backoff замість падіння
}
throw new Error('rate limit не відпустив за відведені спроби');
}
Що дивитися й чому:
429— захист, а не баг. Реакція «429в CI = баг лімітера» майже завжди хибна: винна твоя сюїта, що впирається в ліміт без backoff.Retry-Afterмає два формати. Кількість секунд (Retry-After: 30) або абсолютна HTTP-дата — тест має розбирати обидва, якщо контракт їх допускає.- Ендпоінт мусить ожити. Перевіряємо не лише сам
429, а й що після паузи запити знову приймаються, — інакше це не rate limiting, а блокування назавжди. - Не маскуй ліміт фіксованим
sleep. Backoff читає самеRetry-Afterз відповіді; штучна затримка перетворює проблему на плаваючий флак.
Позитив, негатив і 4xx vs 5xx
- Можу пояснити різницю позитивної та негативної перевірки: перша підтверджує роботу за валідних умов, друга — коректну відмову на невалідне.
- Знаю поділ 4xx/5xx за відповідальністю:
4xx— вина клієнта,5xx— вина сервера. - Тримаю в голові головний інваріант: невалідний ввід ніколи не повинен давати
5xx— це баг, а не «ну помилка ж повернулась». - Розрізняю 5xx-підвиди:
500— впав застосунок,502/504— проблема між проксі й апстрімом,503— сервіс тимчасово недоступний.
Структура тіла помилки
- Знаю усталений мінімум тіла помилки:
code(машиночитний ідентифікатор),message(текст для розробника),details(розбивка по полях). - Можу пояснити, чому асерти вішаю на
codeі структуруdetails, а не на текстmessage(локалізація й копірайт ламають рядковий асерт). - Знаю, що таке problem+json (RFC 9457, замінив RFC 7807) і його поля:
type,title,status,detail,instance. - Пам'ятаю дві додаткові перевірки для problem+json (
Content-Type: application/problem+jsonі збіг поляstatusу тілі зі статус-кодом); але формат помилки диктує специфікація API (OpenAPI), а не сам по собі RFC — не всі API його дотримуються.
Категорії негативних вхідних даних
- Знаю кістяк негативного набору: невалідний JSON, відсутні обов'язкові поля, неправильні типи, зайві поля; довгі значення/спецсимволи/ін'єкції ганяю як smoke (сервіс не падає, не віддає стек), а глибока безпека — окрема тема.
- Розрізняю три випадки: поле відсутнє vs поле порожнє (
"") vs полеnull— і перевіряю їх окремо. - Розумію, що невалідний JSON має давати
400ще до бізнес-валідації, і що500тут — класична пастка недбалого парсера. - Знаю про mass assignment: зайве поле (
role,isVerified) перевіряю не лише на статус, а й на те, що воно не потрапило в збережений об'єкт.
400 vs 404 vs 422 vs 409
- Можу розкласти коди за етапом обробки:
400— не розібрав запит,404— ресурсу немає,422— розібрав, але семантика невалідна,409— конфлікт зі станом. - Розумію, що межа
400/422умовна: багато API віддають400на все, і це не баг, якщо консистентно й за контрактом. - Знаю, що
404іноді повертають навмисно замість403, щоб не розкривати існування закритого ресурсу.
Конкурентне редагування і rate limiting
- Можу назвати проблему втраченого оновлення (lost update) і пояснити оптимістичне блокування через
version/ETag. - Розрізняю
409 Conflict(прикладна версія в тілі) і412 Precondition Failed(умовний запитIf-Match). - Вмію відтворити гонку: створити ресурс, підняти версію «від іншого клієнта», потім записати застарілою версією й перевірити відмову.
- Знаю, що
429 Too Many Requests— захист, а не баг; тестую його функціонально, а не навантаженням. - Розумію роль
Retry-After(секунди або HTTP-дата) і що мій клієнт має поважати паузу — почекати й повторити, а не падати (інакше фальшивий флак).
Який інваріант — головний для негативного тестування API?
Питання
Позитивна vs негативна перевірка — у чому різниця?