vyvchy
    Теми розділу

    04 · API-тестування

    Негативні перевірки й обробка помилок

    Зміст

    Позитивний тест доводить, що фіча працює, коли їй дали правильні дані. Але користувач рідко буває чемним: він надішле порожнє поле, вставить емодзі в номер телефону, натисне «Зберегти» двічі, а фронтенд у розпал релізу зліпить кривий JSON. Зрілість API видно не з того, як він обробляє , а з того, як він відмовляє: чи повертає осмислений код помилки, чи не падає в 500 від зайвого пробілу, чи каже клієнту що саме не так.

    Саме тому негативні перевірки відрізняють того, хто вміє клікати happy path, від того, хто думає про надійність (robustness). Ця глава — про те, як улаштована помилка на рівні HTTP, які негативні вхідні дані треба ганяти обов'язково і як не переплутати 400, 404, 422 та 409.

    Позитив, негатив і «happy path бреше»

    Позитивна перевірка (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 невалідний», і «пароль закороткий», і «такий логін зайнятий». Щоб клієнт (і тестувальник) зрозумів конкретику, помилка має нести структуроване тіло. Стандартом тут є RFC 9457 (див. нижче), а трійка code/message/detailsпоширена практика самописних форматів, яку ти зустрінеш у багатьох API:

    • 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, у стандартному — type і розширення. Правило не самодіяльне: RFC 9457 прямо каже, що не слід парсити текстове пояснення заради даних. Тест, який порівнює 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. Базові поля (кожне з них опціональне):

    ПолеЗначення
    typeURI, що ідентифікує тип проблеми (посилання на її опис)
    titleКороткий людиночитний заголовок типу проблеми
    statusHTTP-статус-код, продубльований у тілі
    detailПояснення саме цього випадку
    instanceURI конкретного інциденту (напр., посилання на цей запит)
    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"
    }

    Ключове для тестів: первинним ідентифікатором типу проблеми стандарт називає саме type — споживач мусить розгалужувати логіку по ньому, а не по текстових полях. Розбивку по полях RFC теж описує сам: канонічний приклад розширення — масив, у якому кожен елемент має detail і pointer (JSON Pointer) на місце проблеми в тілі запиту. Для тесту проти такого 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 — переконатись, що вхід санітизується і межі довжини є. Глибоке (, 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, дата в минулому, сума від'ємна.

    Ні, кривий JSON

    Так

    Ні

    Так

    Ні

    Так

    Порушує правило

    Конфлікт стану

    Усе ок

    Прийшов запит

    Тіло парситься?

    400 Bad Request

    Ресурс існує?

    404 Not Found

    Синтаксис і типи валідні?

    400 або 422 — за контрактом

    Проходить бізнес-правила?

    422 Unprocessable Content

    409 Conflict

    2xx

    Ні, кривий JSON

    Так

    Ні

    Так

    Ні

    Так

    Порушує правило

    Конфлікт стану

    Усе ок

    Прийшов запит

    Тіло парситься?

    400 Bad Request

    Ресурс існує?

    404 Not Found

    Синтаксис і типи валідні?

    400 або 422 — за контрактом

    Проходить бізнес-правила?

    422 Unprocessable Content

    409 Conflict

    2xx

    Важлива чесна поправка: межа 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) і конкурентне редагування.

    Проблема конкурентного редагування — це окремий випадок конкурентності (concurrency), коли дві операції перекриваються в часі над спільним станом. Називається вона (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 — конфлікт зі станом; але точний код диктує , не твоє уявлення.
    • Конкурентне редагування ловиться через версію/ETag: 409 (прикладна версія) або 412 (умовний If-Match); гонку треба вміти відтворити, а не лише назвати.
    • 429 з Retry-After — це захист, а не баг; тестуй його функціонально й змушуй свій клієнт поважати паузу, щоб не отримати фальшивий .

    Можливі питання

    • «Передали рядок замість числа й отримали 500. Це баг?» — Так. Валідація мала відсікти невалідний тип і повернути 400/422. 500 означає необроблений виняток: дані дійшли до коду, який їх не чекав. Інтерв'юер дивиться, чи розумієш поділ 4xx/5xx за відповідальністю.
    • «Яка різниця між 400, 404 і 422400 — зламаний синтаксис запиту; 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. Дивляться на системність, а не на випадкові приклади.

    Джерела

    Позитив, негатив і «happy path бреше»

    • ISTQB CTFL Syllabus v4.0.1 — негативні кейси є вимогою техніки, а не додатком: 100 % за класами вимагає зачепити всі класи, включно з невалідними, і кожен окремим тестом.
    • OWASP API Security Top 10 — 2023 — перелік 2023 як каркас того, що саме має ламатися правильним чином.

    4xx проти 5xx: чия це вина

    • RFC 9110 — HTTP Semantics — лінія розлому: 4xx — «запит містить помилку або не може бути виконаний», 5xx — «сервер не впорався з очевидно валідним запитом».
    • MDN — HTTP response status codes — підвиди 5xx: 500 — узагальнена помилка застосунку, 502 — некоректна відповідь від апстріму, 503 — тимчасова неготовність, 504 не дочекався.

    Структура тіла помилки: code, message, details

    • RFC 9457 — Problem Details for HTTP APIs — розподіл ролей і правила полів: type — первинний ідентифікатор проблеми, status у тілі має збігатися зі статусом відповіді, а detail споживачам НЕ СЛІД парсити заради даних; канонічне розширення несе pointer на місце проблеми.

    Категорії негативних вхідних даних

    • ISTQB CTFL Syllabus v4.0.1 — кожен потребує окремого тесту, бо покриття рахується по класах.
    • JSON Schema Validation, draft 2020-12 — звідки категорії: type із шести плюс integer, required як наявність імені, обмеження довжини.
    • OWASP API Security Top 10 — 2023API3:2023 обʼєднує зайві дані у відповіді й mass assignment: причина одна — брак перевірки прав на рівні властивості обʼєкта.
    • Big List of Naughty Strings — README — готовий набір «неслухняних» рядків для smoke: мета — знайти симптом глибших проблем валідації, і джерело саме називає межу — це не заміна формального тестування безпеки.

    404 vs 400 vs 422: класична плутанина

    • MDN — HTTP response status codes400 — «я не зрозумів, що ти написав», 422 — «я зрозумів, але так не можна»; 404 в API — і невідомий шлях, і відсутній обʼєкт.
    • GraphQL over HTTP (робочий проєкт специфікації) — нормативний прецедент розведення: не розібрався документ запиту → 400; не пройшов валідацію → 422.
    • MDN — 403 Forbidden404 замість 403 легітимне: власник має право не підтверджувати існування ресурсу тим, у кого немає прав.

    409 і конкурентне редагування

    • MDN — HTTP conditional requests — механіка втраченого оновлення й оптимістичне блокування: If-Match дає сильну валідацію, відмова — 412 Precondition Failed.
    • IETF Internet-Draft — The Idempotency-Key HTTP Header Field (-06) — друга природа 409: конфлікт із операцією, що ще обробляється, — клієнту нічого виправляти, треба дочекатися.

    Rate limiting: 429 і Retry-After

    • RFC 6585 — Additional HTTP Status Codes429 означає забагато запитів за проміжок часу; пояснення в тілі — SHOULD, а Retry-After — лише MAY.
    • MDN — 429 Too Many Requests — ліміт буває на весь сервер або на окремий ресурс і рахується зазвичай за IP клієнта — звідси й поведінка паралельної сюїти.
    • MDN — Retry-After header — два формати заголовка — HTTP-дата або кількість секунд; той самий заголовок приходить і при 503, і при редиректі.
    • OWASP API Security Top 10 — 2023 — у редакції 2023 ризик перейменовано на Unrestricted Resource Consumption: акцент змістився з ліміту на вичерпання ресурсів.

    Типові помилки

    • RFC 9457 — Problem Details for HTTP APIs — асерти вішають на type і розширення, а не на текст: парсити detail заради даних споживачам не слід.
    • RFC 9110 — HTTP Semantics5xx — це «сервер не впорався з очевидно валідним запитом», тож на кривий ввід він не є коректною відповіддю.
    • RFC 6585 — Additional HTTP Status Codes429 без Retry-After формально RFC не порушує — вимогливішим буває контракт конкретного API.
    • MDN — Retry-After header — тест, який читає Retry-After як число, зламається на датовому варіанті.
    • OWASP API Security Top 10 — 2023 — «зайве поле проігноровано» перевіряють і на боці збереженого обʼєкта — це той самий ризик API3:2023.

    Пояснення

    «Поясни» працює з власним API-ключем Anthropic: запит іде з вашого браузера прямо до Anthropic.

    Свій API-ключ (BYOK)

    Вставте власний ключ Anthropic — пояснення працюватиме на реальній моделі. Ключ зберігається лише у вашому браузері (localStorage), нікуди не надсилається, крім api.anthropic.com, і не логується.