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

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

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

    Зміст

    Позитивний тест доводить, що фіча працює, коли їй дали правильні дані. Але користувач рідко буває чемним: він надішле порожнє поле, вставить емодзі в номер телефону, натисне «Зберегти» двічі, а фронтенд у розпал релізу зліпить кривий 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. Базові поля (кожне з них опціональне):

    ПолеЗначення
    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"
    }

    Стандарт дозволяє додавати власні поля (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, дата в минулому, сума від'ємна.

    Ні, кривий 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) і конкурентне редагування.

    Проблема конкурентного редагування називається втрачене оновлення (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 і 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. Дивляться на системність, а не на випадкові приклади.

    Джерела