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

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

    Тест-дизайн для API: CRUD, параметри, межі

    Зміст

    «Протестуй цей ендпоінт» — і перед тобою POST /orders з десятком полів. Скільки тут кейсів? Один happy-path, як зробить джун? Чи двісті комбінацій, які ніхто не встигне прогнати? Тест-дизайн для API — це та сама дисципліна вибору, що й для UI-форми, але зі своєю специфікою: тут немає кнопок і візуальної підказки, зате є чіткий контракт (специфікація, схема, статус-коди), стан на сервері, який живе між запитами, і залежності між методами, яких на екрані не видно.

    Тому це одна з найчастіших тем на співбесідах рівня middle+: інтерв'юер дає ендпоінт і слухає не список перевірок, а систему — як ти розкладаєш поля на класи, як бачиш CRUD цілісним флоу, де підозрюєш гонки й ідемпотентність, і як пріоритезуєш, коли на все часу нема. Ця глава спирається на техніки тест-дизайну з розділу «Тест-дизайн» (класи еквівалентності, граничні значення, таблиці рішень, переходи станів) і показує, як прикласти їх саме до API.

    Техніки над полями і параметрами

    Кожне поле тіла запиту й кожен параметр (query, path, header) — це вхід, до якого прикладаються ті самі техніки, що й до поля UI-форми. Різниця в тому, що на рівні API ти бачиш реальний контракт: тип, обмеження, обов'язковість — усе це прописано в OpenAPI-специфікації (див. OpenAPI як джерело істини), і саме звідти беруться межі, а не з візуальних здогадів.

    Базовий алгоритм для будь-якого поля:

    1. Класи еквівалентності — розбий домен на групи, у яких система поводиться однаково: валідні значення, невалідні за типом, невалідні за діапазоном. Один тест на клас, кожен невалідний клас — окремим кейсом (щоб перша ж помилка валідації не сховала другу).
    2. Граничні значення — там, де межа, там баг: off-by-one, >= замість >. Для числового поля з діапазоном 1–100 перевіряй 0, 1, 100, 101; для рядка з maxLength: 50 — 49, 50, 51 символ.
    3. Спецкласи даних — порожній рядок, пробіли, unicode й емодзі, дуже довгі значення, null. Це класика, на якій валиться валідація.

    Окрема, суто API-шна вісь — null vs відсутнє поле vs порожній рядок. Для JSON це три різні стани, і сервер може реагувати на них по-різному: "phone": null, поля phone немає взагалі, "phone": "". Плутати їх — типова діра в покритті; детально перевірки тіла розібрані в главі Перевірки відповіді.

    Обов'язкові й опціональні поля

    Обов'язковість поля — це окремий інваріант, який треба перевіряти явно:

    СитуаціяОчікувана поведінка
    Обов'язкове поле відсутнє400/422, зрозуміла помилка з назвою поля
    Опціональне поле відсутнєУспіх; застосовано значення за замовчуванням або null
    Невідоме зайве полеЗдебільшого ігнорується; іноді 400 за strict-схеми
    Обов'язкове поле = nullЧасто не те саме, що відсутнє — окремий кейс

    Пастка тут — вважати, що «відсутнє» й «null» валідуються однаково. Часто ні: required у JSON Schema перевіряє саме присутність ключа, а не його не-null значення. Тому «поле є, але null» може пройти валідацію обов'язковості й впасти вже глибше — або навпаки.

    CRUD як зв'язаний флоу, а не набір ізольованих запитів

    CRUD (Create, Read, Update, Delete) лягає на HTTP-методи майже прямо: POST створює, GET читає, PUT/PATCH оновлює, DELETE видаляє. Джун тестує кожен ендпоінт окремо. Сильний QA бачить, що вони пов'язані станом на сервері, і перевіряє флоу, бо найцікавіші баги живуть саме в стиках.

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

    • POST /orders201 Created, у відповіді id і (за конвенцією) заголовок Location з URL нового ресурсу.
    • Read-after-write: одразу GET /orders/{id} — чи справді збереглося те, що надіслали? Класичний баг — сервер приймає поле, повертає 201, але при читанні його там немає (мовчки проковтнув).
    • PUT/PATCH /orders/{id} — оновлення. Різниця важлива: PUT за семантикою замінює ресурс цілком, PATCH — частково. Типова помилка PUT — обнулення полів, яких не було в тілі запиту.
    • DELETE /orders/{id}204, після чого GET /orders/{id} має дати 404.

    Окремий пласт — залежності між сутностями: не можна видалити користувача, у якого є замовлення (або має бути каскад, або 409). Ці правила цілісності часто не документовані, і перевіряти їх треба свідомо. Стан і підготовку даних для таких ланцюжків детальніше розбирає глава Тестові дані та стан в API-тестах.

    Колекції: пагінація, фільтрація, сортування

    GET-ендпоінти, що повертають списки, — це окрема родина параметрів зі своїми межами.

    Пагінація. Два поширені підходи:

    • Offset-based (limit + offset, або page + size) — простий, але «пливе»: якщо між сторінками хтось додав чи видалив запис, елементи дублюються або зникають на межах сторінок.
    • Cursor/keyset-based (курсор із попередньої відповіді) — стабільніший до змін даних, тому в тестах на живих даних поводиться передбачуваніше.

    Що перевіряти, застосувавши граничні значення до limit/offset/page: limit=0, limit=1, limit більший за розмір колекції, limit більший за дозволений максимум (є він взагалі?), offset за межами всіх даних (порожній список, не помилка), від'ємні й нечислові значення, дефолт при відсутньому параметрі. Наскрізний інваріант: пройшовши всі сторінки, отримуєш кожен елемент рівно раз — без дублів і пропусків.

    Сортування. sort=createdAt, напрямок asc/desc. Перевіряй: валідне поле сортує правильно; невалідне ім'я поля дає 400, а не мовчки ігнорується (інакше тест «зелений», а сортування не працює); напрямок за замовчуванням; стабільність — за однакових значень ключа порядок має бути детермінованим (інакше пагінація «пливе»); сортування рядків з урахуванням регістру й локалі.

    Фільтрація. Комбінації фільтрів (кілька разом — це AND чи ігнорування зайвого?), фільтр, що не дає збігів (порожній результат — це 200 з [], а не 404), невідоме поле фільтра, спецсимволи в значенні. Останнє — заодно смоук на ін'єкції; глибші перевірки безпеки — тема окремого розділу, тут достатньо переконатися, що лапка чи відсоток у фільтрі не валять 500.

    Стани ресурсу і переходи

    Багато ресурсів — це не просто запис, а скінченний автомат: замовлення, платіж, тікет проходять через статуси, і не всі переходи дозволені. Це пряме застосування техніки переходів станів (розділ «Тест-дизайн») до API: подія — це виклик методу, а перевіряти треба не лише дозволені переходи, а й заборонені.

    POST /orders

    pay

    cancel

    ship

    refund

    deliver

    Created

    Paid

    Cancelled

    Shipped

    Refunded

    Delivered

    POST /orders

    pay

    cancel

    ship

    refund

    deliver

    Created

    Paid

    Cancelled

    Shipped

    Refunded

    Delivered

    Найцінніші кейси тут — невалідні переходи: оплатити вже скасоване замовлення, відвантажити неоплачене, скасувати доставлене. Правильна відповідь — 409 Conflict (стан ресурсу конфліктує із запитом) або 422, а не 500 і не мовчазний успіх, що псує дані. Ще один клас — операції над ресурсом у кінцевому стані: DELETE уже видаленого, повторний cancel. Ці переходи на UI часто просто недоступні (кнопки нема), тому виявити діру можна лише прямим викликом API.

    Ідемпотентність: повторний PUT/DELETE та Idempotency-Key

    Ідемпотентність (idempotency) — властивість методу, за якої кілька однакових запитів дають той самий стан сервера, що й один. Це не про однаковий код відповіді, а саме про ефект на дані. За HTTP-семантикою (RFC 9110) ідемпотентні: GET, HEAD, OPTIONS, TRACE, PUT, DELETE. POST — ні.

    Що це означає для перевірок:

    • PUT двічі поспіль з тим самим тілом → ресурс у тому самому стані. Якщо друге застосування щось накопичує чи змінює — це баг ідемпотентності.
    • DELETE двічі. Перший — 200/204, другий — найчастіше 404 (ресурсу вже нема). Різні коди відповіді — це нормально й ідемпотентності не порушує: стан сервера (ресурс відсутній) однаковий. Плутати «різний статус» із «не ідемпотентно» — типова помилка кандидата.
    • POST не ідемпотентний: два POST /orders створять два замовлення. І ось тут — реальний біль: мережа моргнула, клієнт не отримав відповідь, повторив запит — і виникло два платежі.

    Розв'язання цієї проблеми для POST — заголовок Idempotency-Key: клієнт генерує унікальний ключ (зазвичай UUID) і шле його з запитом; сервер запам'ятовує результат за цим ключем, і на повтор із тим самим ключем повертає той самий збережений результат замість повторного виконання операції. Це де-факто галузева конвенція (її популяризував Stripe у платіжних API; є й IETF-чернетка цього заголовка, хоча в затверджений стандарт вона поки не оформилась).

    Що тут перевіряє QA:

    • повтор із тим самим ключем і тим самим тілом → одна операція, один створений ресурс;
    • ключ живе обмежений час і має заявлену область дії — варто уточнити в специфікації;
    • повтор із тим самим ключем, але іншим тілом — коректний сервер має відповісти помилкою (422), а не тихо застосувати нове тіло під старим ключем.
    // Idempotency: два POST з одним ключем не мають створити два ресурси
    const key = crypto.randomUUID();
    const first = await api.post('/orders', { headers: { 'Idempotency-Key': key }, data: order });
    const retry = await api.post('/orders', { headers: { 'Idempotency-Key': key }, data: order });
    expect(retry.status()).toBe(first.status());
    const listed = await (await api.get('/orders?customerId=42')).json();
    expect(listed.items.filter((o) => o.ref === order.ref)).toHaveLength(1);

    Конкурентні запити і гонки

    Стан на сервері спільний, тому запити можуть накластися в часі. Класика — втрачене оновлення (lost update): двоє читають ресурс, обоє редагують, зберігають — і зміни того, хто зберіг першим, затираються другим без сліду.

    Механізм захисту — оптимістичне блокування (optimistic locking) через валідатор версії. Сервер віддає ETag (або поле version) при читанні; клієнт надсилає його назад у заголовку If-Match при оновленні. Якщо за цей час ресурс змінився, ETag уже не збігається — сервер відповідає 412 Precondition Failed, і оновлення не проходить.

    Клієнт BСерверКлієнт AКлієнт BСерверКлієнт AGET /orders/7 (ETag "v1")GET /orders/7 (ETag "v1")PUT /orders/7, If-Match "v1"200 OK (тепер ETag "v2")PUT /orders/7, If-Match "v1"412 Precondition FailedКлієнт BСерверКлієнт AКлієнт BСерверКлієнт AGET /orders/7 (ETag "v1")GET /orders/7 (ETag "v1")PUT /orders/7, If-Match "v1"200 OK (тепер ETag "v2")PUT /orders/7, If-Match "v1"412 Precondition Failed

    Що перевіряти: чи взагалі є захист від lost update; чи повертається 412/409 при простроченому ETag; що відбувається при двох майже одночасних POST, які створюють сутність з унікальним полем (мають розійтися на 409, а не створити дублікат в обхід unique-обмеження). Відтворювати гонки в автотестах допомагає Promise.all для паралельного запуску запитів. Важливо: нестабільність таких тестів — окрема тема; чому падає тест, а не застосунок, розбирає глава Стабільність API-тестів.

    Пріоритезація покриття

    Повне покриття всіх комбінацій параметрів неможливе — це той самий комбінаторний вибух, що й на UI. Тому питання не «які кейси існують», а «які писати першими». Орієнтир — за спаданням цінності:

    1. Happy-path кожної CRUD-операції — базовий смоук: створити, прочитати, оновити, видалити проходять узагалі.
    2. State-changing операції понад читання. Баг у POST/PUT/DELETE псує дані; баг у GET лише показує неправильне. Тому запити, що змінюють стан, — пріоритет.
    3. Межі й невалідні класи ключових полів — там, де баги скупчуються.
    4. Заборонені переходи станів і залежності цілісності — те, чого не видно на UI.
    5. Ідемпотентність і гонки для критичних операцій — насамперед грошей і всього, що не можна робити двічі.

    Критерій «що критичне» — це ризик: ймовірність збою × вплив (ризик-орієнтований підхід — тема розділу «Основи тестування»). Платіж важить більше за зміну аватарки, тому й покриття отримує глибше.

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

    • Виглядає як перевірка сортування, а насправді ні. Тест шле sort=badField, отримує 200 і список — і вважається зеленим. Насправді сервер проігнорував невідоме поле й повернув дефолтний порядок; перевірка нічого не гарантує. Треба асертити сам порядок або очікувати 400.
    • Виглядає як ідемпотентність, а насправді про статус-коди. Кандидат каже «DELETE не ідемпотентний, бо вдруге дає 404». Ідемпотентність — про стан сервера, а не про код відповіді; стан однаковий, метод ідемпотентний.
    • Виглядає як POST створив ресурс, а насправді проковтнув поле. 201 отримано, тест зелений — але read-after-write показав би, що одне з полів не збереглося. Без окремого GET баг непомітний.
    • Виглядає як порожній результат — помилка. Фільтр без збігів повертає 200 [], а тест чекає на дані й падає — або, гірше, ендпоінт віддає 404 замість порожньої колекції. Порожній список — валідна відповідь, а не помилка.
    • Виглядає як null = відсутнє поле. Перевірили відсутність обов'язкового поля, а "field": null не перевірили — а сервер валідує їх по-різному, і діра лишилась.

    Підсумок

    • Кожне поле й параметр API — вхід для класів еквівалентності й граничних значень; межі беруться з контракту (специфікації), а не з інтуїції.
    • null, відсутнє поле й порожній рядок — три різні стани; обов'язковість перевіряє присутність ключа, а не не-null значення.
    • CRUD тестується як зв'язаний флоу зі станом на сервері: POST → read-after-write → PUT/PATCHDELETE404; найцінніші баги — у стиках і залежностях цілісності.
    • Ідемпотентність — про однаковий стан сервера від повторів, а не про однаковий статус; PUT/DELETE ідемпотентні, POST — ні, і для нього існує Idempotency-Key.
    • Пріоритет покриття: спершу happy-path CRUD, потім state-changing операції, межі, заборонені переходи, а гонки й ідемпотентність — для критичних операцій за ризиком.

    Що питають на співбесіді

    • «Дано POST /users. Які тест-кейси?» Дивляться, чи є система: уточнення контракту → поля по класах і межах → обов'язкові/опціональні → read-after-write → негативні → дублікати за unique-полем → пріоритезація. Голий список happy-path — слабка відповідь.
    • «Які HTTP-методи ідемпотентні й чому?» Очікують GET/HEAD/OPTIONS/TRACE/PUT/DELETE, POST — ні, з поясненням «однаковий стан сервера від N однакових запитів». Бонус — приклад DELETE двічі й що різний статус ідемпотентності не ламає.
    • «Як не створити два платежі при повторі запиту?» Тут чекають на Idempotency-Key: унікальний ключ від клієнта, сервер дедуплікує за ним. Сильний кандидат згадає, чому саме POST вразливий.
    • «Як протестувати lost update?» Двоє читають, обоє пишуть; захист — оптимістичне блокування через ETag/If-Match і 412. Дивляться, чи розумієш різницю між 409 і 412.
    • «Як тестувати пагінацію?» Межі limit/offset, порожня й остання сторінка, дрейф offset при зміні даних, наскрізний інваріант «кожен елемент рівно раз».

    Джерела