Тест-дизайн для API: CRUD, параметри, межі
Зміст
«Протестуй цей ендпоінт» — і перед тобою POST /orders з десятком полів. Скільки тут кейсів? Один happy-path, як зробить джун? Чи двісті комбінацій, які ніхто не встигне прогнати? Тест-дизайн для API — це та сама дисципліна вибору, що й для UI-форми, але зі своєю специфікою: тут немає кнопок і візуальної підказки, зате є чіткий контракт (специфікація, схема, статус-коди), стан на сервері, який живе між запитами, і залежності між методами, яких на екрані не видно.
Тому це одна з найчастіших тем на співбесідах рівня middle+: інтерв'юер дає ендпоінт і слухає не список перевірок, а систему — як ти розкладаєш поля на класи, як бачиш CRUD цілісним флоу, де підозрюєш гонки й ідемпотентність, і як пріоритезуєш, коли на все часу нема. Ця глава спирається на техніки тест-дизайну з розділу «Тест-дизайн» (класи еквівалентності, граничні значення, таблиці рішень, переходи станів) і показує, як прикласти їх саме до API.
Техніки над полями і параметрами
Кожне поле тіла запиту й кожен параметр (query, path, header) — це вхід, до якого прикладаються ті самі техніки, що й до поля UI-форми. Різниця в тому, що на рівні API ти бачиш реальний контракт: тип, обмеження, обов'язковість — усе це прописано в OpenAPI-специфікації (див. OpenAPI як джерело істини), і саме звідти беруться межі, а не з візуальних здогадів.
Базовий алгоритм для будь-якого поля:
- Класи еквівалентності — розбий домен на групи, у яких система поводиться однаково: валідні значення, невалідні за типом, невалідні за діапазоном. Один тест на клас, кожен невалідний клас — окремим кейсом (щоб перша ж помилка валідації не сховала другу).
- Граничні значення — там, де межа, там баг: off-by-one,
>=замість>. Для числового поля з діапазоном 1–100 перевіряй 0, 1, 100, 101; для рядка зmaxLength: 50— 49, 50, 51 символ. - Спецкласи даних — порожній рядок, пробіли, 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 /orders→201 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: подія — це виклик методу, а перевіряти треба не лише дозволені переходи, а й заборонені.
Найцінніші кейси тут — невалідні переходи: оплатити вже скасоване замовлення, відвантажити неоплачене, скасувати доставлене. Правильна відповідь — 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, і оновлення не проходить.
Що перевіряти: чи взагалі є захист від lost update; чи повертається 412/409 при простроченому ETag; що відбувається при двох майже одночасних POST, які створюють сутність з унікальним полем (мають розійтися на 409, а не створити дублікат в обхід unique-обмеження). Відтворювати гонки в автотестах допомагає Promise.all для паралельного запуску запитів. Важливо: нестабільність таких тестів — окрема тема; чому падає тест, а не застосунок, розбирає глава Стабільність API-тестів.
Пріоритезація покриття
Повне покриття всіх комбінацій параметрів неможливе — це той самий комбінаторний вибух, що й на UI. Тому питання не «які кейси існують», а «які писати першими». Орієнтир — за спаданням цінності:
- Happy-path кожної CRUD-операції — базовий смоук: створити, прочитати, оновити, видалити проходять узагалі.
- State-changing операції понад читання. Баг у
POST/PUT/DELETEпсує дані; баг уGETлише показує неправильне. Тому запити, що змінюють стан, — пріоритет. - Межі й невалідні класи ключових полів — там, де баги скупчуються.
- Заборонені переходи станів і залежності цілісності — те, чого не видно на UI.
- Ідемпотентність і гонки для критичних операцій — насамперед грошей і всього, що не можна робити двічі.
Критерій «що критичне» — це ризик: ймовірність збою × вплив (ризик-орієнтований підхід — тема розділу «Основи тестування»). Платіж важить більше за зміну аватарки, тому й покриття отримує глибше.
Типові помилки
- Виглядає як перевірка сортування, а насправді ні. Тест шле
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/PATCH→DELETE→404; найцінніші баги — у стиках і залежностях цілісності. - Ідемпотентність — про однаковий стан сервера від повторів, а не про однаковий статус;
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 при зміні даних, наскрізний інваріант «кожен елемент рівно раз».
Джерела
- ISTQB Certified Tester Foundation Level Syllabus v4.0 — розділ 4 «Test Analysis and Design»: техніки чорної скриньки, що застосовуються тут до полів і параметрів.
- RFC 9110 — HTTP Semantics — §9.2 (safe/idempotent methods), §13 (Conditional Requests,
If-Match), §8.8.3 (ETag), §15.5.13 (412 Precondition Failed). - MDN: Idempotent — семантика ідемпотентних методів із прикладами (
DELETEдвічі,POST— ні). - MDN: HTTP conditional requests — валідатори
ETag/If-Matchта оптимістичне блокування через412. - IETF draft: The Idempotency-Key HTTP Header Field — чернетка (наразі expired) конвенції дедуплікації для
POST.
Тобі дали POST /orders з десятком полів. З чого починаєш тест-дизайн?
Не зі списку перевірок, а зі структури: спершу дістаю контракт — специфікацію (OpenAPI), схему тіла, статус-коди, — щоб знати типи, обмеження й обов'язковість полів, а не гадати. Далі розкладаю кожне поле на класи еквівалентності (валідні, невалідні за типом, невалідні за діапазоном) і беру граничні значення там, де є межі. Окремо проходжу обов'язкові/опціональні поля, null vs відсутнє vs порожній рядок, потім read-after-write через GET, негативні кейси й дублікати за unique-полем. Наприкінці пріоритезую: happy-path, потім межі ключових полів, потім рідше. Голий список happy-path — слабка відповідь; інтерв'юер слухає систему, а не кількість кейсів.
Як застосувати класи еквівалентності й граничні значення до поля API?
Це ті самі техніки чорної скриньки, що й для UI-форми, але на рівні API межі беруться з контракту, а не з візуальних здогадів. Клас еквівалентності — це група значень, у якій система поводиться однаково: один тест на клас, і кожен невалідний клас окремим кейсом, щоб перша помилка валідації не сховала другу. Граничні значення перевіряють off-by-one — типову плутанину >= і >: для числового поля з діапазоном 1–100 беру 0, 1, 100, 101, для рядка з maxLength: 50 — 49, 50, 51 символ. Плюс спецкласи даних: порожній рядок, пробіли, unicode й емодзі, дуже довгі значення, null — класика, на якій валиться валідація.
Чому null, відсутнє поле й порожній рядок — це три різні тест-кейси?
Бо для JSON це три різні стани, і сервер може реагувати на них по-різному: "phone": null, поля phone немає взагалі, "phone": "". Плутати їх — типова діра в покритті: перевірив відсутність обов'язкового поля, а "field": null не перевірив, і баг лишився. Ключ у тому, що required у JSON Schema перевіряє саме присутність ключа, а не його не-null значення. Тому «поле є, але null» може пройти валідацію обов'язковості й впасти вже глибше — або навпаки. Кожен із трьох станів заслуговує окремого кейса, особливо для полів, де порожнеча має бізнес-сенс.
Як явно перевіряти обов'язковість поля?
Обов'язковість — це окремий інваріант, і його треба перевіряти чотирма ситуаціями, а не однією. Обов'язкове поле відсутнє → 400/422 зі зрозумілою помилкою, що називає поле. Опціональне поле відсутнє → успіх, застосовано значення за замовчуванням або null. Невідоме зайве поле → здебільшого ігнорується, іноді 400 за strict-схеми. І окремо — обов'язкове поле = null, яке часто не те саме, що відсутнє. Пастка тут — вважати, що «відсутнє» й «null» валідуються однаково; часто ні, бо required перевіряє присутність ключа.
Що таке read-after-write і навіщо він, якщо POST уже повернув 201?
Read-after-write — це одразу після створення зробити GET /orders/{id} і звірити, що збереглося саме те, що надіслали. Потрібен він, бо 201 каже лише «запит прийнято», а не «всі поля лягли в базу». Класичний баг — сервер приймає поле, повертає 201, але при читанні його там немає: мовчки проковтнув. Без окремого GET така діра непомітна, тест зелений, а дані неповні. Тому статус-код — необхідна, але недостатня перевірка створення; оракул істини тут — саме читання.
У чому різниця між PUT і PATCH при тестуванні оновлення?
За семантикою PUT замінює ресурс цілком, а PATCH оновлює його частково, і це змінює набір кейсів. Типова помилка PUT — обнулення полів, яких не було в тілі запиту: надіслав тіло з двома полями, а третє, не згадане, сервер затер у null. Тому для PUT обов'язковий кейс «оновлюю частину — що стало з рештою полів». Для PATCH навпаки перевіряю, що незгадані поля лишились недоторканими, а згадані змінились. Плюс обидва треба гнати через read-after-write, бо відповідь 200 ще не гарантує, що зміна реально збереглася.
Чому CRUD треба тестувати як зв'язаний флоу, а не кожен ендпоінт окремо?
Бо CRUD-методи пов'язані станом на сервері, і найцікавіші баги живуть саме в стиках. Щоб прочитати ресурс, його спершу треба створити; щоб оновити — знати id, який повернув POST. Природний ланцюжок: POST → 201 з id і заголовком Location → read-after-write через GET → PUT/PATCH → DELETE → 204, після якого GET має дати 404. Ізольований тест кожного методу пропустить, наприклад, що DELETE начебто відпрацював, а ресурс усе ще читається. Окремий пласт — залежності цілісності: не можна видалити користувача із замовленнями (або каскад, або 409), і ці правила часто не документовані.
Як тестувати пагінацію, щоб нічого не пропустити?
Пагінація — це набір параметрів (limit/offset або page/size), до яких прикладаються граничні значення. Перевіряю: limit=0, limit=1, limit більший за розмір колекції, limit більший за дозволений максимум (а він взагалі є?), offset за межами всіх даних (має бути порожній список, не помилка), від'ємні й нечислові значення, дефолт при відсутньому параметрі. Головний наскрізний інваріант: пройшовши всі сторінки, отримуєш кожен елемент рівно раз — без дублів і пропусків. Саме цей інваріант ловить «дрейф» offset-пагінації, коли між сторінками хтось додав чи видалив запис.
У чому різниця між offset- і cursor-пагінацією для тестувальника?
Offset-based (limit+offset) простий, але «пливе»: якщо між запитами двох сторінок хтось додав або видалив запис, елементи на межах сторінок дублюються або зникають. Cursor/keyset-based передає курсор із попередньої відповіді й стабільніший до змін даних, тому на живих даних поводиться передбачуваніше. Для тестів це означає, що offset-пагінацію треба свідомо перевіряти на дрейф — модифікувати колекцію між сторінками й дивитись на наскрізний інваріант «кожен елемент рівно раз». А з cursor-пагінацією цей клас багів менш імовірний, зате з'являється своя перевірка: коректність і термін життя самого курсора.
Тест шле sort=badField, отримує 200 і список — це достатня перевірка сортування?
Ні, це фальшиво зелений тест. Сервер міг проігнорувати невідоме поле й повернути дефолтний порядок; статус 200 і наявність списку нічого не гарантують. Правильно — або асертити сам порядок елементів, або очікувати 400 на невалідне ім'я поля. По сортуванню перевіряю: валідне поле сортує правильно, невалідне дає 400 (а не мовчить), напрямок за замовчуванням, стабільність за однакових значень ключа (інакше пагінація «пливе») і сортування рядків з урахуванням регістру й локалі. Асерт має перевіряти саме інваріант — впорядкованість, — а не факт відповіді.
Фільтр не дав збігів. Яка правильна відповідь і де тут пастка?
Правильна відповідь — 200 з порожнім масивом [], а не 404. Порожній список — це валідний результат, а не помилка: даних, що підходять під фільтр, просто немає. Пастка двобічна: або тест чекає на дані й падає на порожньому результаті, або сам ендпоінт віддає 404 замість порожньої колекції, і це вже баг контракту. По фільтрації ще перевіряю комбінації кількох фільтрів (це AND чи ігнорування зайвого?), невідоме поле фільтра й спецсимволи в значенні — заодно смоук на ін'єкції, щоб лапка чи відсоток не валили 500.
Що таке переходи станів ресурсу і які кейси тут найцінніші?
Багато ресурсів — це скінченний автомат: замовлення чи платіж проходять статуси (Created → Paid → Shipped → Delivered), і не всі переходи дозволені. Техніка переходів станів лягає на API прямо: подією стає виклик методу, а в моделі мене цікавлять і дозволені стрілки, і їх відсутність. Найбільше багів дають саме заборонені переходи — оплата скасованого замовлення, відвантаження неоплаченого, скасування доставленого: сервер має відмовити через 409 Conflict або 422, тоді як 500 чи мовчазний успіх означають зіпсовані дані. Окремо перевіряю операції над ресурсом у кінцевому стані: DELETE уже видаленого, повторний cancel. Інтерфейс таких дій зазвичай і не пропонує — кнопки просто немає, — тому знайти цю діру можна лише прямим викликом API.
Що таке ідемпотентність і які HTTP-методи ідемпотентні?
Метод ідемпотентний, якщо N однакових запитів залишають сервер у тому самому стані, що й один; мірило — ефект на дані, а не однаковий код відповіді. За HTTP-семантикою (RFC 9110) ідемпотентні GET, HEAD, OPTIONS, TRACE, PUT, DELETE, а POST — ні. Практично: після другого PUT з тим самим тілом ресурс має виглядати так само, як після першого, і якщо повторне застосування щось докидає чи змінює — ідемпотентність зламано. POST не ідемпотентний, бо два POST /orders створять два замовлення, і саме тут виникає ризик подвійних платежів при повторі запиту.
Кандидат каже: «DELETE не ідемпотентний, бо вдруге дає 404». Де він помиляється?
Він плутає різний статус-код із порушенням ідемпотентності. DELETE двічі: перший дає 200/204, другий — найчастіше 404, бо ресурсу вже нема. Але ідемпотентність — про стан сервера, а не про код відповіді, і стан після обох викликів однаковий: ресурс відсутній. Тому різні коди тут — це нормально й ідемпотентності не порушує. Це одна з класичних пасток на співбесіді: сильна відповідь чітко розділяє «однаковий стан сервера від N викликів» і «однаковий статус кожного виклику».
Як не створити два платежі, коли клієнт повторив POST після обриву мережі?
Через заголовок Idempotency-Key: до запиту клієнт додає згенерований унікальний ключ (зазвичай UUID), а сервер зберігає результат першого виконання під цим ключем — і, побачивши повтор, віддає збережену відповідь, не виконуючи операцію вдруге. Це закриває вразливість саме POST, який неідемпотентний: мережа моргнула, клієнт не отримав відповідь, повторив — і без ключа виникло б два замовлення. QA тут перевіряє: повтор із тим самим ключем і тілом дає одну операцію й один ресурс; ключ живе обмежений час і має заявлену область дії; повтор із тим самим ключем, але іншим тілом коректний сервер відхиляє (422), а не тихо застосовує нове тіло під старим ключем. Це де-факто галузева конвенція (її популяризував Stripe).
Що таке lost update і як його протестувати?
Lost update (втрачене оновлення) — це коли два клієнти прочитали той самий ресурс, обидва відредагували й записали, і зміни першого мовчки зникли під записом другого. Захист — оптимістичне блокування через валідатор версії: разом із прочитаним ресурсом клієнт отримує ETag (або поле version) і при записі повертає його в заголовку If-Match; якщо ресурс тим часом встиг змінитися, валідатор уже застарілий, і сервер відхиляє запис із 412 Precondition Failed. Тестую так: два клієнти читають той самий ETag, перший PUT проходить (200, новий ETag), другий PUT зі старим ETag має отримати 412. Відтворити гонку в автотесті допомагає Promise.all для паралельного запуску.
У чому різниця між 409 Conflict і 412 Precondition Failed?
Обидва сигналять конфлікт, але про різне. 409 — запит конфліктує з поточним станом ресурсу як таким: невалідний перехід стану (оплатити скасоване), дублікат за unique-полем, спроба видалити сутність із залежностями. 412 — вужчий і про умовні запити: клієнт надіслав передумову (If-Match з ETag), і вона не виконалась, бо ресурс змінився з моменту читання. Тобто 409 — «так з цим ресурсом зараз не можна», 412 — «твоя копія застаріла, перечитай і спробуй ще». Плутати їх на співбесіді з оптимістичного блокування — типова помилка; правильна відмова простроченому ETag — саме 412.
Часу мало. Як пріоритезувати покриття API?
Повне покриття комбінацій параметрів неможливе — це комбінаторний вибух. Тому питання не «які кейси існують», а «які писати першими», і орієнтир — цінність за спаданням. Спершу happy-path кожної CRUD-операції (базовий смоук, що створення/читання/оновлення/видалення проходять узагалі). Потім state-changing операції понад читання: баг у POST/PUT/DELETE псує дані, баг у GET лише показує неправильне. Далі межі й невалідні класи ключових полів, потім заборонені переходи станів і залежності цілісності (те, чого не видно на UI), і насамкінець ідемпотентність та гонки для критичних операцій. Критерій «що критичне» — ризик: ймовірність збою × вплив, тому платіж отримує глибше покриття, ніж зміна аватарки.
Три кейси, що показують тест-дизайн API у дії: розкладання полів POST /users на класи й межі у вигляді таблиці рішень, зв'язаний CRUD-флоу з read-after-write і DELETE→404 у Playwright, і контрактні перевірки ідемпотентності та lost update. Скрізь — що перевіряємо і чому саме так.
Кейс 1. Розкладаємо POST /users на класи й межі
Дано ендпоінт із контракту:
# скорочено зі схеми OpenAPI (структура спрощена)
POST /users
required: [email, age]
properties:
email: { type: string, format: email, maxLength: 254 }
age: { type: integer, minimum: 18, maximum: 120 }
nickname: { type: string, maxLength: 30 } # опціональне
Замість «набити кейсів» проходимо кожне поле алгоритмом класи → межі → спецдані. Виходить компактна таблиця рішень — по одному тесту на клас:
| Поле | Кейс | Значення | Очікування |
|---|---|---|---|
| валідний клас | user@test.io | 201 | |
| невалідний за форматом | user@, plain | 400/422 | |
| межа довжини | 254 / 255 символів | 201 / 422 | |
| age | нижня межа | 17, 18, 19 | 422 / 201 / 201 |
| age | верхня межа | 119, 120, 121 | 201 / 201 / 422 |
| age | невалідний тип | "25", 25.5 | 400/422 |
| nickname | опціональне відсутнє | ключа немає | 201, застосовано дефолт/null |
| nickname | межа довжини | 30 / 31 символ | 201 / 422 |
| обов'язкове відсутнє | ключа немає | 422, помилка називає поле | |
обов'язкове = null | "email": null | окремий кейс — часто НЕ те саме, що відсутнє |
Що дивитися і чому:
- Межі
age— це17/18/19і120/121, а не «якесь мале й якесь велике». Саме на межі живе off-by-one:>замість>=пропустить17або зріже18. Три точки навколо кожної межі ловлять цю плутанину. nullі «відсутнє» — два рядки таблиці, не один.requiredперевіряє присутність ключа, тому"email": nullможе пройти валідацію обов'язковості й впасти глибше. Один кейс на обидва стани — типова діра.- Кожен невалідний клас — окремий запит. Якщо в одному тілі одразу битий email і від'ємний age, перша ж помилка валідації сховає другу, і покриття буде уявним.
- Опціональне поле теж має інваріант. Відсутній
nickname— це не «пропустити», а перевірити, що сервер підставив дефолт абоnull, а не впав.
Кейс 2. CRUD як флоу: read-after-write і DELETE → 404
Ізольований тест POST побачить 201 і заспокоїться. Зв'язаний флоу ловить баги в стиках: чи справді збереглося те, що надіслали, і чи DELETE реально прибрав ресурс.
import { test, expect } from '@playwright/test';
test('CRUD flow: створення живе, оновлення застосовується, видалення прибирає', async ({ request }) => {
const payload = { email: 'crud@test.io', age: 30, nickname: 'neo' };
// POST → 201, дістаємо id і Location
const created = await request.post('/users', { data: payload });
expect(created.status()).toBe(201);
const { id } = await created.json();
expect(created.headers()['location']).toContain(`/users/${id}`);
// read-after-write: 201 ще не значить «збереглося все»
const read = await request.get(`/users/${id}`);
expect(read.status()).toBe(200);
expect(await read.json()).toMatchObject(payload); // проковтнуте поле спливе саме тут
// PATCH оновлює частково — незгадані поля лишаються
const patched = await request.patch(`/users/${id}`, { data: { nickname: 'trinity' } });
expect(patched.status()).toBe(200);
const afterPatch = await (await request.get(`/users/${id}`)).json();
expect(afterPatch.nickname).toBe('trinity');
expect(afterPatch.age).toBe(30); // PATCH не мав чіпати age
// DELETE → 204, після чого ресурсу немає
expect((await request.delete(`/users/${id}`)).status()).toBe(204);
expect((await request.get(`/users/${id}`)).status()).toBe(404);
});
Що дивитися і чому:
matchObjectпісляGET— головний оракул створення. Без нього баг «сервер прийняв поле, повернув201, але не зберіг» лишається невидимим: асерт на статус його не ловить.PATCHперевіряємо на те, чого він НЕ чіпав.ageпісля зміниnicknameмає лишитись30. Якби це бувPUTбезageу тілі, тут спливло б обнулення незгаданого поля.DELETEдоводимо читанням.204каже «прийняв», аGET→404доводить, що ресурс справді зник, а не «видалився» лише в статусі.Location— частина контракту201. Перевірка заголовка ловить розбіжність між тілом і задекларованою адресою нового ресурсу.
Кейс 3. Ідемпотентність POST і захист від lost update
Дві найдорожчі помилки на state-changing операціях — подвійне створення при повторі й затерте оновлення при гонці. Обидві перевіряються контрактно.
import { test, expect } from '@playwright/test';
test('Idempotency-Key: повтор POST не створює другий ресурс', async ({ request }) => {
const key = crypto.randomUUID();
const order = { ref: 'A-100', customerId: 42, amount: 500 };
const first = await request.post('/orders', {
headers: { 'Idempotency-Key': key }, data: order,
});
// повтор із тим самим ключем і тілом — імітація моргання мережі
const retry = await request.post('/orders', {
headers: { 'Idempotency-Key': key }, data: order,
});
expect(retry.status()).toBe(first.status()); // той самий збережений результат
const listed = await (await request.get('/orders?customerId=42')).json();
expect(listed.items.filter((o) => o.ref === order.ref)).toHaveLength(1); // рівно один
});
test('optimistic locking: прострочений ETag дає 412, а не тихе затирання', async ({ request }) => {
// двоє читають ту саму версію
const read = await request.get('/orders/7');
const etag = read.headers()['etag'];
// перший пише — версія стає новою
const okUpdate = await request.put('/orders/7', {
headers: { 'If-Match': etag }, data: { amount: 600 },
});
expect(okUpdate.status()).toBe(200);
// другий пише зі старим ETag — має відлетіти в 412, а не затерти зміну першого
const staleUpdate = await request.put('/orders/7', {
headers: { 'If-Match': etag }, data: { amount: 999 },
});
expect(staleUpdate.status()).toBe(412);
});
Гонку на створення дубліката за unique-полем відтворюємо паралельним запуском:
test('два майже одночасні POST з unique-полем розходяться на 409', async ({ request }) => {
const body = { email: 'race@test.io', age: 25 };
const [a, b] = await Promise.all([
request.post('/users', { data: body }),
request.post('/users', { data: body }),
]);
const codes = [a.status(), b.status()].sort();
expect(codes).toEqual([201, 409]); // один створив, другий отримав конфлікт
});
Що дивитися і чому:
- Оракул ідемпотентності — не статус, а кількість ресурсів.
retry.status()може збігтися випадково; доказ істини —filter(...).toHaveLength(1)у списку. Саме він відрізняє «повтор дедуплікувався» від «створилось два замовлення». 412, а не409, на простроченомуETag. Тут не виконалась передумоваIf-Matchумовного запиту — це рівно412 Precondition Failed. Асерт на409тут був би змістовною помилкою.- Гонку на unique-полі розводить
Promise.all. Правильний результат —[201, 409]: один запит створив, другий упав у конфлікт.[201, 201]означало б, що unique-обмеження обійшли, і в базі дублікат. - Ці кейси не видно на UI. Кнопку «створити» користувач двічі за мілісекунду не натисне, а мережа моргне легко — тому такі баги ловляться лише прямими паралельними викликами API.
Техніки над полями і параметрами
- Розумію, що кожне поле тіла й кожен параметр (query, path, header) — вхід для класів еквівалентності й граничних значень, а межі беру з контракту (OpenAPI), не з візуальних здогадів.
- Можу застосувати граничні значення до числа (
0,1,100,101для діапазону 1–100) і до рядка (maxLength: 50→ 49, 50, 51 символ). - Даю кожному невалідному класу окремий кейс і пам'ятаю спецдані: порожній рядок, пробіли, unicode й емодзі, дуже довгі значення,
null.
null, відсутнє поле, порожній рядок
- Розумію, що
"phone": null, відсутній ключphoneі"phone": ""— три різні стани JSON з різною реакцією сервера. - Знаю, що
requiredу JSON Schema перевіряє присутність ключа, а не не-null значення; обов'язковість гоню чотирма ситуаціями (обов'язкове відсутнє / опціональне відсутнє / зайве поле / обов'язкове =null).
CRUD як зв'язаний флоу
- Можу пояснити, чому CRUD — це флоу зі станом на сервері:
POST→201зidіLocation→ read-after-write черезGET→PUT/PATCH→DELETE→204→GETдає404. - Розумію, навіщо read-after-write:
201не гарантує, що поля лягли — сервер міг мовчки проковтнути поле. - Знаю різницю
PUT(ризик обнулити незгадані поля) vsPATCHі пам'ятаю про залежності цілісності: видалення сутності із залежностями — це каскад або409.
Колекції: пагінація, сортування, фільтрація
- Можу пояснити offset-based (
limit+offset, «пливе» при змінах даних) vs cursor/keyset (стабільніший). - Знаю граничні кейси пагінації (
limit0/1/більший за колекцію/більший за максимум,offsetза межами → порожній список) і наскрізний інваріант «кожен елемент рівно раз». - Розумію, чому
sort=badFieldз200— фальшиво зелений тест (асертити порядок або400), і що фільтр без збігів — це200 [], а не404.
Стани ресурсу і переходи
- Застосовую техніку переходів станів: перевіряю не лише дозволені, а й заборонені переходи (оплатити скасоване, відвантажити неоплачене).
- Знаю, що невалідний перехід — це
409/422, а не500чи мовчазний успіх, і що на UI такі переходи часто недоступні — ловляться лише прямим викликом API.
Ідемпотентність
- Можу визначити ідемпотентність як однаковий стан сервера від N запитів (про ефект на дані, не про статус) і назвати ідемпотентні методи (
GET,HEAD,OPTIONS,TRACE,PUT,DELETE), аPOST— ні. - Розумію, чому
DELETEдвічі (204, потім404) НЕ порушує ідемпотентність — стан сервера однаковий. - Можу пояснити
Idempotency-Key(клієнт шле UUID, сервер дедуплікуєPOST) і що повтор ключа з іншим тілом коректний сервер відхиляє (422).
Конкурентність і гонки
- Можу пояснити lost update і захист — оптимістичне блокування через
ETag/If-Matchз відповіддю412на прострочений валідатор. - Розрізняю
409 Conflict(конфлікт зі станом ресурсу) і412 Precondition Failed(не виконалась передумова умовного запиту); гонки відтворюю черезPromise.all.
Пріоритезація покриття
- Можу впорядкувати покриття за цінністю: happy-path CRUD → state-changing операції → межі ключових полів → заборонені переходи й цілісність → ідемпотентність і гонки.
- Розумію, чому state-changing (
POST/PUT/DELETE) пріоритетніші заGET, і знаю критерій ризику — ймовірність збою × вплив (платіж глибше за аватарку).
Для JSON `"phone": null`, відсутній ключ `phone` і `"phone": ""` — це:
Питання
Базовий алгоритм тест-дизайну для поля API