Перевірки відповіді: статус, тіло, заголовки, схема
Зміст
Запит ви вже вмієте надіслати. Питання, яке відрізняє QA від людини, що просто «клікнула Send у Postman», — а що саме тут перевіряти? Відповідь на API-запит — це не «зелений кружечок» чи «красивий JSON». Це кілька незалежних шарів контракту: статус-рядок, заголовки, серіалізоване тіло і форма даних усередині нього. На кожному шарі баг виглядає по-різному, і хороша перевірка каже не лише «щось не так», а й де саме не так.
Ця тема — фундамент усього API-тестування і дуже частий блок на співбесіді. Типовий провал кандидата: перевірив 200 OK і на цьому заспокоївся. А сервіс тим часом повернув порожній масив замість даних, число замість рядка, дату в чужій таймзоні або поле, якого за контрактом бути не повинно. Нижче — систематичний набір перевірок і пасток, через які ці баги проходять повз слабкі тести.
Чек-лист: що взагалі перевіряти у відповіді
Розкладімо відповідь на шари й перевіряймо їх у порядку від найзагальнішого до найдетальнішого. Порядок не випадковий: якщо впала перша перевірка, решту немає сенсу навіть запускати, а сам номер шару вже локалізує проблему.
Базовий чек-лист для будь-якої відповіді:
- Статус-код — саме той, що очікує сценарій (
201на створення,200на читання,404на неіснуючий ресурс). Означення кодів — у главі HTTP статус-коди. - Заголовки — насамперед
Content-Type; для колекцій і кешу —Content-Length,Cache-Control, кастомніX-*. - Тіло парситься — те, що прийшло, справді валідний JSON (або XML), а не HTML-сторінка помилки чи обрізаний потік.
- Форма тіла (схема) — обов'язкові поля присутні, типи правильні, зайвих полів немає.
- Значення — конкретні дані відповідають тому, що ми надсилали або сідили.
- Час відповіді — у межах розумного (функціональний smoke, не навантаження).
Далі розберемо шари, на яких найчастіше й ховаються баги.
Статус каже не все: пастка «200 OK з помилкою в тілі»
Найпоширеніша ілюзія новачка: 200 OK означає «все добре». Не означає. Статус-код описує лише транспортний рівень — «запит дійшов, сервер його обробив і сформував відповідь». Що саме в тій відповіді — код не гарантує.
Реальний контрприклад. Багато старих або погано спроєктованих API на будь-який результат повертають 200, а справжній результат кладуть у тіло:
HTTP/1.1 200 OK
Content-Type: application/json
{ "success": false, "error": "INSUFFICIENT_FUNDS", "data": null }
Транспортом усе гаразд, а бізнес-операція провалилася. Тест, який перевіряє лише expect(response.status()).toBe(200), тут зелений — і пропускає баг. Тому перевірка статусу майже ніколи не буває самодостатньою: за нею має йти перевірка тіла.
Крайній випадок цього патерну — GraphQL: він майже завжди відповідає 200, а помилки складає в масив errors поруч із частковими даними (докладніше — GraphQL: особливості тестування). Дзеркальна пастка теж існує: сервіс повертає 500, але з осмисленим тілом помилки — і якщо тест дивиться лише в тіло, він проґавить, що замість 400 Bad Request користувач отримав внутрішню помилку сервера. Висновок один: статус і тіло перевіряють разом, бо вони описують різні речі.
Заголовки: Content-Type і серіалізація
Тіло відповіді — це завжди байти. Щоб перетворити їх на структуру (об'єкт, масив), клієнт має знати формат — а його оголошує заголовок Content-Type. Для REST-API це зазвичай application/json; charset=utf-8.
Чому це перевіряють окремо. Уявіть, що бекенд через помилку конфігурації віддав вам HTML-сторінку 502 Bad Gateway від проксі, але ваш тест наосліп викликав .json(). Ви отримаєте не зрозумілий фейл, а падіння парсера на Unexpected token '<' — і півгодини дебагу не туди. Тому дисципліна така: спершу переконатися, що Content-Type — саме JSON, і лише потім парсити тіло.
const res = await request.get('/api/users/1');
expect(res.headers()['content-type']).toContain('application/json');
const body = await res.json();
Серіалізація (serialization) — це і є перетворення внутрішнього об'єкта сервера на текст для передачі; десеріалізація — зворотний бік у клієнта. На цьому стику виникає окремий клас багів:
- Число, яке серіалізувалося як рядок:
"price": "19.99"замість"price": 19.99. Обидва «виглядають як 19.99», але тип різний — і споживач, що чекає число, зламається. Content-Type: application/json, а всередині насправді не-JSON (обрізаний потік, зайвий BOM на початку).- Кодування: сервер оголосив
charset=utf-8, а віддав, скажімо, дані вwindows-1251— кирилиця перетвориться на «кракозябри».
Заголовки — теж частина контракту, тож перевіряйте назви без урахування регістру (у HTTP вони регістронезалежні; клієнти зазвичай віддають їх у нижньому регістрі).
Тіло: JSON Schema як контракт форми
Перевіряти кожне поле руками — марудно й крихко. Коли важлива саме форма відповіді (набір полів і їхні типи, а не конкретні значення), користуються JSON Schema — стандартом, що описує структуру JSON-документа декларативно. Схема — це той самий контракт, лише машиночитний; звідки його брати системно, розкрито у главі OpenAPI/Swagger: специфікація як джерело істини.
Три ключові поняття, які треба розуміти на співбесіді:
type— очікуваний тип значення:string,number,integer,boolean,object,array,null. Самеtypeловить класику «число прийшло рядком».required— масив імен полів, які обов'язково мають бути присутні. Важливий нюанс:requiredперевіряє лише наявність ключа, а не те, що значення неnull.additionalProperties— чи дозволені поля, яких немає в схемі. За замовчуваннямtrue(зайве дозволене). ВиставленняadditionalProperties: falseробить схему суворою: будь-яке несподіване поле — фейл.
Останній момент — дуже практичний. Слабка схема пропускає баг, коли бекенд випадково почав віддавати зайве поле (наприклад, passwordHash у публічному профілі — це вже й витік). Сувора схема з additionalProperties: false таке ловить одразу.
У коді схему перевіряють валідатором — для JS/TS це найчастіше ajv:
import Ajv from 'ajv';
const ajv = new Ajv();
const userSchema = {
type: 'object',
required: ['id', 'email'],
additionalProperties: false,
properties: {
id: { type: 'integer' },
email: { type: 'string' },
name: { type: ['string', 'null'] },
},
};
const valid = ajv.validate(userSchema, await res.json());
expect(valid, JSON.stringify(ajv.errors)).toBe(true);
Зверніть увагу на name: { type: ['string', 'null'] } — так у схемі явно дозволяють null. Механіку валідації схем у коді (ajv, zod) детальніше розбирає глава API-автотести в коді.
null, відсутнє поле і порожній рядок — три різні речі
Це одне з найулюбленіших питань співбесід, бо тут плутаються навіть мідли. Три стани поля виглядають схоже, а означають різне й ламають систему по-різному:
| Стан | Як у JSON | Що означає |
|---|---|---|
| Відсутнє поле | ключа взагалі немає | «про це нічого не сказано» |
null | "middleName": null | «значення явно відоме й воно порожнє/невідоме» |
| Порожній рядок | "middleName": "" | «значення є, і це рядок нульової довжини» |
Чому це болить. Клієнт, який робить user.middleName.length, впаде на відсутньому полі й на null (бо в них немає .length), але спокійно поверне 0 на порожньому рядку. Валідація форми «поле обов'язкове» зазвичай пропустить null, але зачепиться за "" — або навпаки, залежно від реалізації. А required у JSON Schema, нагадаю, вважає поле зі значенням null присутнім.
Практичний висновок для перевірок: не змішуйте ці три випадки в один. Для кожного поля свідомо вирішіть, який стан є валідним за контрактом, і перевіряйте саме його. expect(body.middleName).toBe(null) і expect(body).not.toHaveProperty('middleName') — це дві різні перевірки для двох різних контрактів.
Дати, таймзони, числа, unicode
Чотири категорії даних, на яких тести падають «іноді» й «лише вночі».
Дати й таймзони. Канонічний формат обміну — ISO 8601 (його профіль для інтернету — RFC 3339): 2026-07-17T09:30:00Z, де Z означає UTC. Пастки:
- Дата без зони (
2026-07-17T09:30:00) — незрозуміло, у якому поясі; порівняння з очікуваним значенням стає ненадійним. - Тест, що жорстко чекає локальний час, зелений вдень і червоний вночі, бо перетнув межу доби в іншій таймзоні. Порівнюйте час в UTC або з допуском, а не побайтово.
- Різні представлення одного моменту:
...T09:30:00Zі...T12:30:00+03:00— це один і той самий момент, але різні рядки. Порівняння рядків тут збреше.
Числа. JSON не обмежує точність чисел, зате JavaScript зберігає їх як 64-бітний float (IEEE 754). Наслідок, який треба знати: цілі числа, більші за Number.MAX_SAFE_INTEGER (це 9007199254740991), у JS втрачають точність. Тому великі ідентифікатори (id замовлення, snowflake-id) серйозні API віддають рядком, а не числом. Друга класика — дроби: 0.1 + 0.2 у float не дорівнює рівно 0.3. Тому гроші порівнюють з допуском або тримають у мінімальних одиницях (копійках) цілим числом. Ці ж підводні камені мови докладніше — у розділі про JS/TS.
Unicode. Текст їде в UTF-8. Що перевіряти: кирилицю й діакритику (щоб не було «кракозябр» — ознака збитого кодування), емодзі та інші символи поза базовою площиною (вони займають дві кодові одиниці в JS, тож "😀".length === 2 — і перевірки довжини поля можуть брехати), а також нормалізацію. Один і той самий на вигляд символ (наприклад, é) буває записаний двома способами — одним кодпоінтом (NFC) або «e + комбінований акцент» (NFD); рядки різні, порівняння === їх не зрівняє, поки не нормалізувати. Не забувайте і про порожній рядок та пробіли на краях як окремі тестові дані.
Колекції: порядок, дублікати, пагінація
Коли відповідь — масив, додається окремий пласт перевірок, який легко проґавити.
Порядок. Питання номер один: чи гарантований він? Якщо API обіцяє сортування (?sort=createdAt), порядок — частина контракту й його треба перевіряти. Якщо не обіцяє — прив'язуватися до порядку в тесті небезпечно: сьогодні база віддала записи в одному порядку, завтра після зміни індексу — в іншому, і тест «раптом» червоний, хоча дані ті самі. У такому разі перевіряйте склад множини, а не послідовність.
Дублікати. Масив може містити той самий елемент двічі через баг у джойні на бекенді (класична причина — JOIN, що розмножує рядки). Перевірка проста, але її часто забувають: кількість унікальних id має дорівнювати довжині масиву.
const ids = (await res.json()).map((u) => u.id);
expect(new Set(ids).size).toBe(ids.length); // немає дублікатів
Пагінація. Відповідь-сторінка описується не лише масивом data, а й метаданими: total, page, pageSize, посилання next/prev або курсор. Що перевіряти:
data.lengthне перевищуєpageSize;totalузгоджений із реальною кількістю (сума по сторінках дорівнюєtotal);- між сторінками немає дублікатів і пропусків — це головний ризик пагінації, особливо коли дані додаються паралельно з читанням (тоді offset-пагінація зсувається);
- крайові сторінки: перша, остання, сторінка за межами діапазону (порожня
data, а не помилка чи500).
Тест-дизайн для таких параметрів — межі, фільтри, сортування — систематично розкриває глава Тест-дизайн для API.
Точна vs часткова перевірка
Ключове рішення для кожної асерції: звіряти відповідь повністю чи лише потрібні поля. Обидві крайнощі мають ціну.
Точна перевірка (toEqual на весь об'єкт) ловить будь-яку несподівану зміну, зокрема появу зайвих полів. Але вона крихка: варто відповіді містити хоч одне «летке» поле — згенерований id, createdAt, поточний час — і тест червоніє на кожному прогоні без реального бага. Прив'язуватися до всього тіла має сенс лише коли ви повністю контролюєте вхід, а вихід детермінований.
Часткова перевірка звіряє лише те, що справді важливо для сценарію, і не звертає уваги на решту. У Playwright/Jest це toMatchObject та асиметричні матчери:
expect(await res.json()).toMatchObject({
status: 'active',
email: 'user@example.com',
id: expect.any(Number), // важливий тип, не значення
createdAt: expect.any(String), // летке поле — не прив'язуємось
});
Розумний баланс на практиці такий: схема (JSON Schema) стереже форму й типи всієї відповіді, а точкові асерти перевіряють конкретні значення тих кількох полів, які цей тест справді валідує. Так тест і стабільний, і не сліпий: летке не ламає його даремно, а важливе — під контролем. Надто часткова перевірка («аби 200 і поле id було») — це антитест: він зелений завжди й не ловить нічого.
Типові помилки
- Виглядає як «тест перевірив статус
200, отже, відповідь правильна» — а насправді статус описує лише транспорт; у тілі спокійно живе"success": falseабо порожній масив замість даних. - Виглядає як «поле є в схемі,
requiredйого вимагає — значить, значення точно осмислене» — а насправдіrequiredгарантує лише наявність ключа; значення при цьому може бутиnull. - Виглядає як «дата збіглася, тест зелений» — а насправді порівняли рядки в різних таймзонах;
...T09:30Zі...T12:30+03:00— один момент, але тест то падає, то ні залежно від того, де він біжить. - Виглядає як «id прийшов, усе гаразд» — а насправді великий id приїхав числом і в JS втратив останні цифри; серйозні API віддають такі id рядком.
- Виглядає як «масив містить усі потрібні записи» — а насправді він містить їх двічі через дубль у джойні, і перевірка на унікальність цього не робилась.
- Виглядає як «
toEqualна все тіло — найсуворіша перевірка» — а насправді вона червоніє на кожномуcreatedAtі команда швидко починає її ігнорувати; сувора там, де є летке поле, означає «вимкнена». - Виглядає як «
Content-Typeневажливий, головне тіло» — а насправді без перевірки формату тест падає на парсері зUnexpected token '<', коли проксі повернув HTML-помилку.
Підсумок
- Статус і тіло перевіряють разом:
200 OKне означає успіх бізнес-операції, а5xxбуває з осмисленим тілом — кожен шар відповідає за своє. - Порядок перевірок «статус → заголовки → схема → значення» локалізує баг: перший шар, що впав, і є місцем проблеми.
null, відсутнє поле і порожній рядок — три різні контракти;requiredу JSON Schema стежить лише за наявністю ключа, не за не-null.- Дати, великі числа, дроби й unicode — джерела «іноді»-фейлів: UTC із допуском, великі id рядком, гроші цілим, текст у UTF-8 з нормалізацією.
- Схема стереже форму й типи, точкові асерти — конкретні значення; надто точна перевірка крихка, надто часткова нічого не ловить.
Що питають на співбесіді
- «Прийшов
200 OK. Тест зелений?» — інтерв'юер перевіряє, чи не зупиняєтесь ви на статусі. Сильна відповідь: статус — лише транспорт; треба перевірити тіло, бо буває200з"success": false, порожнім масивом чи помилкою в тілі (класика — GraphQL). - «Що ви перевіряєте у відповіді API?» — чекають структурований чек-лист (статус, заголовки/
Content-Type, парситься тіло, схема, значення, час), а не «дивлюсь, щоб JSON був». - «Різниця між
null, відсутнім полем і порожнім рядком?» — дивляться на розуміння контракту й наслідків для споживача; бонус — згадати, щоrequiredвважаєnullприсутнім. - «Як перевірити відповідь, де є
idіcreatedAt, які щоразу нові?» — перевіряють знання часткової перевірки: схема на форму +expect.any/toMatchObject, а неtoEqualна все тіло. - «Відповідь — масив із 50 записів. Що перевіряти?» — чекають: склад vs порядок (чи гарантований), дублікати, пагінацію (межі,
total, пропуски/перекриття між сторінками). - «Чому великі id віддають рядком?» — точкове питання на розуміння float у JS і
Number.MAX_SAFE_INTEGER.
Джерела
- ISTQB Certified Tester Foundation Level (CTFL) v4.0 — техніки тест-дизайну й термінологія тестування; підґрунтя вибору технік перевірки.
- JSON Schema — specification —
type,required,additionalPropertiesта інші ключові слова. - RFC 8259 — The JavaScript Object Notation (JSON) Data Interchange Format — канонічне означення формату JSON.
- RFC 3339 — Date and Time on the Internet: Timestamps — профіль ISO 8601 для обміну датами в мережі.
- MDN: Number.MAX_SAFE_INTEGER — межа точності цілих у JavaScript.
Що ви перевіряєте у відповіді API?
Відповідь — не «зелений кружечок», а кілька незалежних шарів контракту, і перевіряти їх варто від найзагальнішого до найдетальнішого. Мій чек-лист: статус-код саме той, що очікує сценарій; заголовки, насамперед Content-Type; тіло реально парситься як JSON, а не HTML-сторінка помилки; форма тіла за схемою (обов'язкові поля, типи, відсутність зайвих); конкретні значення відповідають тому, що надсилали чи сідили; і час відповіді в межах розумного. Порядок не косметичний: якщо впала перша перевірка, решту запускати немає сенсу, а номер шару вже локалізує проблему. Слабка відповідь на цьому питанні — «дивлюсь, щоб JSON був»; сильна — саме структурований список шарів.
Прийшов 200 OK. Тест зелений?
Ні, статусу самого по собі недостатньо. 200 OK описує лише транспортний рівень — «запит дійшов, сервер його обробив і сформував відповідь», але що саме в тій відповіді, код не гарантує. Багато старих або погано спроєктованих API на будь-який результат повертають 200, а справжній результат кладуть у тіло: { "success": false, "error": "INSUFFICIENT_FUNDS" }. Транспортом усе гаразд, а бізнес-операція провалилася — і тест, що перевіряє лише expect(res.status()).toBe(200), тут зелений і пропускає баг. Тому перевірка статусу майже ніколи не самодостатня: за нею завжди має йти перевірка тіла.
Чому статус і тіло треба перевіряти разом, а не окремо?
Бо вони описують різні речі й ловлять різні класи багів. Статус — це транспорт («запит опрацьовано»), тіло — результат бізнес-операції. Класична пастка в один бік: 200 із "success": false чи порожнім масивом замість даних, і тест на самий статус його не бачить. Дзеркальна пастка в інший бік: сервіс повертає 500, але з осмисленим тілом помилки — тест, що дивиться лише в тіло, проґавить, що замість очікуваного 400 Bad Request користувач отримав внутрішню помилку сервера. Крайній випадок першого патерну — GraphQL: він майже завжди відповідає 200, а помилки складає в масив errors поруч із частковими даними.
Навіщо перевіряти Content-Type окремо, перед тим як парсити тіло?
Бо тіло — це завжди байти, і щоб перетворити їх на структуру, клієнт має знати формат, який оголошує саме Content-Type. Уявіть, що бекенд через помилку конфігурації віддав HTML-сторінку 502 Bad Gateway від проксі, а тест наосліп викликав .json(). Ви отримаєте не зрозумілий фейл, а падіння парсера на Unexpected token '<' — і півгодини дебагу не туди. Тому дисципліна така: спершу переконатися, що Content-Type — саме application/json, і лише потім парсити тіло. Заголовки в HTTP регістронезалежні, тож назви перевіряють без урахування регістру (клієнти зазвичай віддають їх у нижньому).
Що таке серіалізація й десеріалізація і які баги виникають на цьому стику?
Серіалізація — це перетворення внутрішнього об'єкта сервера на текст для передачі, десеріалізація — зворотний бік у клієнта. На цьому стику живе окремий клас багів. Найтиповіший — число, що серіалізувалося як рядок: "price": "19.99" замість "price": 19.99; обидва «виглядають як 19.99», але тип різний, і споживач, який чекає число, зламається. Далі — Content-Type: application/json, а всередині насправді не-JSON: обрізаний потік або зайвий BOM на початку. І кодування: сервер оголосив charset=utf-8, а віддав дані, скажімо, у windows-1251 — кирилиця перетвориться на «кракозябри». Усе це проходить повз тест, який дивиться лише статус.
Що таке JSON Schema і навіщо вона в тестах?
JSON Schema — стандарт, що декларативно описує структуру JSON-документа: набір полів і їхні типи. Коли важлива саме форма відповіді, а не конкретні значення, перевіряти кожне поле руками марудно й крихко — схема робить це одним викликом валідатора. Це той самий контракт, лише машиночитний. Три ключові поняття, які варто розуміти: type — очікуваний тип значення (string, number, integer, boolean, object, array, null), саме він ловить класику «число прийшло рядком»; required — масив імен полів, які обов'язково мають бути присутні; additionalProperties — чи дозволені поля, яких немає в схемі. У JS/TS схему перевіряють валідатором, найчастіше ajv.
Що робить additionalProperties: false і навіщо це на практиці?
Робить схему суворою: будь-яке несподіване поле, якого немає в схемі, — фейл валідації. За замовчуванням additionalProperties дорівнює true, тобто зайві поля дозволені, і слабка схема пропускає баг, коли бекенд випадково почав віддавати щось зайве. Найболючіший приклад — passwordHash у публічному профілі: це вже не просто зайве поле, а витік чутливих даних. Сувора схема з additionalProperties: false ловить таке одразу, тоді як точкові асерти на очікувані поля цього не помітять — вони перевіряють наявність потрібного, а не відсутність зайвого. Тому для перевірки на витоки й «дрейф контракту» сувора схема цінна.
У чому різниця між null, відсутнім полем і порожнім рядком?
Це три різні контракти, які виглядають схоже, а означають різне. Відсутнє поле (ключа взагалі немає) — «про це нічого не сказано». null ("middleName": null) — «значення явно відоме й воно порожнє або невідоме». Порожній рядок ("middleName": "") — «значення є, і це рядок нульової довжини». Болить це так: клієнт, який робить user.middleName.length, впаде на відсутньому полі й на null (у них немає .length), але спокійно поверне 0 на порожньому рядку. Валідація «поле обов'язкове» зазвичай пропустить null, але зачепиться за "" — або навпаки, залежно від реалізації. Практичний висновок: для кожного поля свідомо виріши, який стан валідний, і перевіряй саме його; expect(body.x).toBe(null) і expect(body).not.toHaveProperty('x') — дві різні перевірки.
Чому required у JSON Schema не гарантує, що значення осмислене?
Бо required перевіряє лише наявність ключа, а не те, що значення не null. Поле зі значенням null для required вважається присутнім — умова виконана. Тому логіка «поле є в схемі, required його вимагає, отже, значення точно осмислене» хибна: схема пройде, а споживач, який очікував рядок, отримає null і зламається. Щоб схема відбивала реальний контракт, тип для поля, яке дійсно може бути порожнім, задають явно — type: ['string', 'null'] дозволяє null, а type: 'string' без null його заборонить. Це часте питання-пастка на співбесіді, бо на ньому плутаються навіть мідли.
Які пастки чатують на тести з датами й таймзонами?
Канонічний формат обміну — ISO 8601 (його інтернет-профіль — RFC 3339), наприклад 2026-07-17T09:30:00Z, де Z означає UTC. Перша пастка — дата без зони (2026-07-17T09:30:00): незрозуміло, у якому поясі, і порівняння з очікуваним стає ненадійним. Друга — тест, що жорстко чекає локальний час: зелений удень і червоний уночі, бо перетнув межу доби в іншій таймзоні. Третя, найпідступніша — різні представлення одного моменту: ...T09:30:00Z і ...T12:30:00+03:00 це той самий момент, але різні рядки, тож побайтове порівняння рядків збреше. Лікування — порівнювати час в UTC або з допуском, а не як рядки.
Чому серйозні API віддають великі id рядком, а не числом?
Бо JavaScript зберігає всі числа як 64-бітний float (IEEE 754), і цілі, більші за Number.MAX_SAFE_INTEGER (це 9007199254740991), у JS втрачають точність — останні цифри «пливуть». Тому великі ідентифікатори (id замовлення, snowflake-id від Twitter/Discord) серйозні API серіалізують саме рядком, щоб клієнт на JS не зіпсував їх при десеріалізації. Пастка для тесту: «id прийшов, усе гаразд», а насправді число вже втратило точність, і подальше порівняння чи повторний запит за цим id підуть не туди. Якщо API документує id як рядок, а віддає числом (або навпаки) — це знахідка для звіту.
Чому не можна порівнювати гроші через звичайну рівність float?
Бо у float 0.1 + 0.2 не дорівнює рівно 0.3 — це фундаментальна властивість IEEE 754, а не баг мови. Тому асерт на точну рівність дробових грошових сум «іноді» падає без реальної проблеми в даних. Два робочі підходи: порівнювати з допуском (toBeCloseTo чи явна дельта) або тримати гроші в мінімальних одиницях — копійках/центах — цілим числом, і тоді арифметика точна. Разом із проблемою великих id це два боки однієї медалі: JSON не обмежує точність чисел, а JS обмежує, тож на числових полях завжди варто питати, чи не постраждає точність.
Що перевіряти в unicode-даних?
Текст їде в UTF-8, і тут кілька окремих речей. Кирилиця й діакритика — щоб не було «кракозябр», які є ознакою збитого кодування. Емодзі та інші символи поза базовою площиною — вони займають дві кодові одиниці в JS, тож "😀".length === 2, і перевірки довжини поля можуть брехати. Нормалізація — один і той самий на вигляд символ (наприклад, é) буває записаний одним кодпоінтом (NFC) або як «e + комбінований акцент» (NFD); рядки при цьому різні, і порівняння через === їх не зрівняє, поки не нормалізувати. Не варто забувати й про порожній рядок та пробіли на краях як окремі тестові дані.
Відповідь — масив із 50 записів. Що перевіряти?
Тут додається окремий пласт перевірок поверх схеми. Перше — порядок: чи він гарантований контрактом. Якщо API обіцяє сортування (?sort=createdAt) — порядок частина контракту й його перевіряють; якщо не обіцяє — прив'язуватися до порядку небезпечно, бо після зміни індексу база віддасть ті самі дані інакше, і тест «раптом» червоний; тоді перевіряють склад множини, а не послідовність. Друге — дублікати: масив може містити той самий елемент двічі через баг у джойні на бекенді, і кількість унікальних id має дорівнювати довжині масиву. Третє — пагінація: межі, узгодженість total, відсутність пропусків і перекриттів між сторінками.
Як перевірити масив на дублікати?
Порівняти кількість унікальних значень ключа з довжиною масиву. Класична причина дублів — JOIN на бекенді, який розмножує рядки, і візуально масив «містить усі потрібні записи», просто деякі двічі. Перевірка коротка, але її часто забувають:
const ids = (await res.json()).map((u) => u.id);
expect(new Set(ids).size).toBe(ids.length);
Set викидає повтори, тож якщо його розмір менший за довжину масиву — є дублікати. Ця ж ідея працює для будь-якого поля-ідентифікатора, не лише id.
Що конкретно перевіряти в пагінації?
Сторінка описується не лише масивом data, а й метаданими: total, page, pageSize, посилання next/prev або курсор. Перевіряю: data.length не перевищує pageSize; total узгоджений із реальною кількістю (сума по сторінках дорівнює total); і головний ризик — між сторінками немає дублікатів і пропусків. Останнє особливо болить у offset-пагінації, коли дані додаються паралельно з читанням: вставка нового запису зсуває offset, і на межі сторінок один запис або двоїться, або губиться. Обов'язково крайові сторінки: перша, остання і сторінка за межами діапазону — там очікується порожня data, а не помилка чи 500.
Точна чи часткова перевірка тіла — коли яка?
Це рішення для кожної асерції, і обидві крайнощі мають ціну. Точна перевірка (toEqual на весь об'єкт) ловить будь-яку несподівану зміну, зокрема появу зайвих полів, але вона крихка: досить одного «леткого» поля — згенерованого id, createdAt, поточного часу — і тест червоніє на кожному прогоні без реального бага, після чого команда починає його ігнорувати. Часткова перевірка звіряє лише те, що важливо для сценарію, і не зважає на решту. Розумний баланс: схема стереже форму й типи всієї відповіді, а точкові асерти перевіряють конкретні значення тих кількох полів, які цей тест справді валідує. Так тест і стабільний, і не сліпий.
Як перевірити відповідь, де є id і createdAt, які щоразу нові?
Не через toEqual на все тіло — воно червонітиме на кожному прогоні через ці летючі поля. Правильно розділити: схема (JSON Schema) стереже форму й типи всієї відповіді, а конкретні значення перевіряють частково — toMatchObject плюс асиметричні матчери для летких полів:
expect(await res.json()).toMatchObject({
status: 'active',
email: 'user@example.com',
id: expect.any(Number), // важливий тип, не значення
createdAt: expect.any(String), // летке поле — не прив'язуємось
});
Так летке не ламає тест даремно, а важливі значення (status, email) під контролем. Це і є та часткова перевірка, якої чекають на співбесіді у відповідь на це питання.
Чому надто часткова перевірка — це антитест?
Бо перевірка на кшталт «аби 200 і поле id існувало» зелена завжди й не ловить нічого змістовного. Часткова перевірка корисна, коли звіряє те, що справді важливо для сценарію, і свідомо ігнорує летке. Але якщо звести її до мінімуму, тест перестає бути оракулом: бекенд може повернути порожні дані, неправильний статус активності, зайве поле з витоком — тест усе одно зелений. Тому баланс критичний: схема бере на себе форму й типи всієї відповіді, а точкові асерти — конкретні важливі значення. Надто точна перевірка крихка й швидко ігнорується, надто часткова нічого не ловить; хороший тест сидить між ними.
Три кейси, де перевірка вирішує, ловить тест баг чи спить зеленим: пошарова перевірка відповіді проти пастки «200 OK з помилкою в тілі», JSON Schema як контракт форми з ajv (типи, required, суворість, null), і стабільна перевірка колекції з леткими полями — часткові асерти плюс контроль дублікатів і межі пагінації. Скрізь код на Playwright і що саме дивитися.
Кейс 1. Пошарова перевірка: статус це ще не успіх
Ендпоінт списання коштів повертає 200, тест зелений, а гроші не списалися. Причина класична: сервіс на будь-який результат віддає 200, а справжній результат кладе в тіло. Перевіряти треба шарами — і зупинятися на першому, що впав.
HTTP/1.1 200 OK
Content-Type: application/json
{ "success": false, "error": "INSUFFICIENT_FUNDS", "data": null }
Тест, що перевіряє лише статус, тут сліпий. Правильна перевірка йде по шарах:
import { test, expect } from '@playwright/test';
test('списання: статус, формат і бізнес-результат — разом', async ({ request }) => {
const res = await request.post('/api/payments', {
data: { amount: 100, account: 'acc-1' },
});
// шар 1: транспорт
expect(res.status()).toBe(200);
// шар 2: формат — ДО парсингу тіла
expect(res.headers()['content-type']).toContain('application/json');
// шар 3: тіло парситься і бізнес-операція справді успішна
const body = await res.json();
expect(body.success, JSON.stringify(body)).toBe(true);
expect(body.data).not.toBeNull();
});
Що дивитися і чому:
- Статус і тіло — різні шари контракту.
200каже лише «запит опрацьовано», а не «операція вдалася». Асерт наbody.successловить саме той баг, якийexpect(status).toBe(200)пропускає. Content-Typeперевіряють перед.json(). Якщо проксі поверне HTML-сторінку502, викликаний наосліп.json()впаде наUnexpected token '<'— і півгодинний дебаг піде не туди. Перевірка формату дає зрозумілий фейл замість падіння парсера.- Друга половина повідомлення в асерті — не косметика.
JSON.stringify(body)уexpect(..., msg)показує реальне тіло прямо у фейлі, тож не треба перезапускати тест із логами, щоб побачитиerror: "INSUFFICIENT_FUNDS". - Дзеркальний випадок теж вартий тесту. Негативний сценарій має перевіряти, що при нестачі коштів приходить осмислена помилка (
4xxабо документованийsuccess: false), а не500з падінням на бекенді.
Кейс 2. JSON Schema як контракт форми
Перевіряти кожне поле руками — марудно й крихко. Коли важлива саме форма відповіді (набір полів і типи), контракт описують схемою й валідують ajv. Ця схема ловить одразу кілька класів багів: число, що приїхало рядком, зайве поле й неправильний тип.
import { test, expect } from '@playwright/test';
import Ajv from 'ajv';
const ajv = new Ajv();
const userSchema = {
type: 'object',
required: ['id', 'email'],
additionalProperties: false, // будь-яке зайве поле — фейл
properties: {
id: { type: 'integer' },
email: { type: 'string' },
name: { type: ['string', 'null'] }, // null дозволено явно
},
};
test('GET /users/1 — відповідь відповідає контракту форми', async ({ request }) => {
const res = await request.get('/api/users/1');
expect(res.headers()['content-type']).toContain('application/json');
const valid = ajv.validate(userSchema, await res.json());
expect(valid, JSON.stringify(ajv.errors)).toBe(true);
});
Що дивитися і чому:
typeловить «число рядком». Якщо бекенд віддасть"id": "1"замість"id": 1, схема зtype: 'integer'червоніє — руками таку підміну легко проґавити, бо в JSON вона «виглядає майже однаково».additionalProperties: falseстереже від дрейфу контракту. Найнебезпечніший приклад — випадковийpasswordHashу публічному профілі: точкові асерти на очікувані поля його не бачать, а сувора схема падає одразу. Це перевірка не лише форми, а й на витік.requiredгарантує ключ, не значення. Поле зі значеннямnullсхема вважає присутнім. Томуnullдозволяють свідомо черезtype: ['string', 'null']; якщоnameза контрактом не може бути порожнім —nullзі списку прибирають, і тодіnullстане фейлом.ajv.errorsу повідомленні асерту. Без нього фейл каже лише «valid === false»; з ним видно конкретне поле і причину (must be integer,must NOT have additional properties) — діагноз замість здогадки.
Кейс 3. Стабільна перевірка колекції з леткими полями
Відповідь — сторінка списку, де в кожного запису свій згенерований id і createdAt. toEqual на все тіло тут червонітиме на кожному прогоні без реального бага. Правильно розділити: форму стереже схема, конкретні значення перевіряють частково, а поверх — інваріанти колекції.
import { test, expect } from '@playwright/test';
test('список замовлень: часткова перевірка + інваріанти колекції', async ({ request }) => {
const res = await request.get('/api/orders?page=1&pageSize=20&sort=createdAt');
expect(res.status()).toBe(200);
const body = await res.json();
// конкретні поля — точково, летке — через матчер типу
expect(body.data[0]).toMatchObject({
status: 'new',
id: expect.any(Number), // важливий тип, не значення
createdAt: expect.any(String), // летке — не прив'язуємось
});
// інваріанти колекції
const ids = body.data.map((o: { id: number }) => o.id);
expect(new Set(ids).size).toBe(ids.length); // немає дублікатів
expect(body.data.length).toBeLessThanOrEqual(20); // не більше pageSize
});
Окремим тестом варто накрити межу пагінації — сторінку за діапазоном:
test('сторінка за межами діапазону — порожня data, а не 500', async ({ request }) => {
const res = await request.get('/api/orders?page=9999&pageSize=20');
expect(res.status()).toBe(200);
expect((await res.json()).data).toEqual([]);
});
Що дивитися і чому:
toMatchObject+expect.anyзамістьtoEqual. Летке поле (id,createdAt, поточний час) не ламає тест даремно, а важливе (status) під контролем.toEqualна все тіло тут — «сувора там, де є летке», тобто перевірка, яку команда швидко вимкне.- Дублікати ловить
Set.JOIN, що розмножує рядки, дає масив, який «містить усі потрібні записи», просто деякі двічі.new Set(ids).size === ids.length— коротка перевірка, яку часто забувають. - Порядок перевіряємо, лише коли він у контракті. Запит із
?sort=createdAtобіцяє сортування, тож порядок можна асертити. Без явногоsortприв'язка до послідовності — джерело флаку: після зміни індексу база віддасть ті самі дані інакше. - Крайова сторінка — окремий кейс. Головний ризик пагінації — пропуски й дублі на межі та поведінка за діапазоном. Правильна відповідь на неіснуючу сторінку — порожня
data, а не помилка чи500.
Шари відповіді й порядок перевірок
- Можу назвати шари від найзагальнішого до найдетальнішого — статус → заголовки → тіло парситься → форма (схема) → значення → час — і чому перший шар, що впав, локалізує проблему.
- Знаю, що перевірка статусу майже ніколи не самодостатня — за нею завжди йде перевірка тіла.
Статус vs тіло
- Розумію, що
200 OKописує лише транспорт, а не успіх бізнес-операції, тож статус і тіло перевіряють разом. - Можу навести пастку «
200з"success": false», дзеркальну «500з осмисленим тілом замість400» і GraphQL, що майже завжди відповідає200з помилками в масивіerrors.
Заголовки й серіалізація
- Знаю, чому
Content-Typeперевіряють ДО парсингу тіла (інакше.json()падає на HTML-помилці зUnexpected token '<'), і що назви заголовків перевіряють без урахування регістру. - Розумію серіалізацію/десеріалізацію і клас багів на стику: число як рядок (
"19.99"замість19.99), обрізаний потік, BOM, збите кодування (utf-8протиwindows-1251= «кракозябри»).
JSON Schema як контракт форми
- Розумію, коли брати схему замість ручних асертів: коли важлива форма (набір полів і типи), а не конкретні значення.
- Знаю три ключові слова:
type(ловить «число рядком»),required(наявність ключа),additionalProperties(falseробить схему суворою й ловить зайве поле — аж до витокуpasswordHash). - Знаю, що схему валідують бібліотекою (
ajv), аnullдозволяють явно черезtype: ['string', 'null'].
null, відсутнє поле, порожній рядок
- Розрізняю три стани як три різні контракти (ключа немає /
null/"") і наслідок для споживача:.lengthпадає на відсутньому йnull, але дає0на порожньому рядку. - Пам'ятаю ключовий нюанс:
requiredвважає поле зі значеннямnullприсутнім — гарантує ключ, не осмислене значення. - Знаю, що
toBe(null)іnot.toHaveProperty(...)— дві різні перевірки для двох різних контрактів.
Дати, числа, unicode
- Знаю канонічний формат обміну — ISO 8601 / RFC 3339 з
Zдля UTC; порівнюю час в UTC або з допуском, бо...T09:30Zі...T12:30+03:00— один момент, різні рядки. - Можу пояснити, чому великі id віддають рядком (
Number.MAX_SAFE_INTEGER, втрата точності float), і чому гроші не порівнюють звичайним float (0.1 + 0.2 !== 0.3) — допуск або цілі копійки. - Пам'ятаю пастки unicode:
"😀".length === 2, «кракозябри» від збитого кодування, нормалізація NFC vs NFD.
Колекції
- Розумію різницю «склад множини vs порядок» і коли порядок є частиною контракту (є
?sort=), а коли ні. - Знаю перевірку на дублікати:
new Set(ids).size === ids.length, і що причина дублів — розмноження рядків уJOIN. - Можу перелічити, що перевіряти в пагінації:
data.length ≤ pageSize, узгодженістьtotal, відсутність пропусків/перекриттів (offset зсувається при паралельному записі), крайові сторінки.
Точна vs часткова перевірка
- Розумію ціну обох крайнощів:
toEqualна все тіло крихкий на летких полях, а надто часткова перевірка («аби200і полеid») нічого не ловить; баланс — схема стереже форму й типи, точкові асерти — конкретні важливі значення. - Можу перевірити відповідь із летким
id/createdAtчерезtoMatchObject+expect.any(...), а неtoEqual.
Ендпоінт списання коштів повернув 200 OK. Чи достатньо цього, щоб вважати операцію успішною?
Питання
На які шари розкладають відповідь API і в якому порядку їх перевіряють?