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

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

    Перевірки відповіді: статус, тіло, заголовки, схема

    Зміст

    Запит ви вже вмієте надіслати. Питання, яке відрізняє QA від людини, що просто «клікнула Send у Postman», — а що саме тут перевіряти? Відповідь на API-запит — це не «зелений кружечок» чи «красивий JSON». Це кілька незалежних шарів контракту: статус-рядок, заголовки, серіалізоване тіло і форма даних усередині нього. На кожному шарі баг виглядає по-різному, і хороша перевірка каже не лише «щось не так», а й де саме не так.

    Ця тема — фундамент усього API-тестування і дуже частий блок на співбесіді. Типовий провал кандидата: перевірив 200 OK і на цьому заспокоївся. А сервіс тим часом повернув порожній масив замість даних, число замість рядка, дату в чужій таймзоні або поле, якого за контрактом бути не повинно. Нижче — систематичний набір перевірок і пасток, через які ці баги проходять повз слабкі тести.

    Чек-лист: що взагалі перевіряти у відповіді

    Розкладімо відповідь на шари й перевіряймо їх у порядку від найзагальнішого до найдетальнішого. Порядок не випадковий: якщо впала перша перевірка, решту немає сенсу навіть запускати, а сам номер шару вже локалізує проблему.

    Ні

    Так

    Ні

    Так

    Ні

    Так

    Ні

    Так

    Відповідь отримана

    Статус-код очікуваний?

    Стоп: не той сценарій
    дивись тіло помилки

    Content-Type і заголовки?

    Стоп: не той формат
    тіло парсити не можна

    Тіло валідне за схемою?

    Стоп: зламаний контракт форми

    Значення й бізнес-логіка?

    Дефект даних або логіки

    Перевірка пройдена

    Ні

    Так

    Ні

    Так

    Ні

    Так

    Ні

    Так

    Відповідь отримана

    Статус-код очікуваний?

    Стоп: не той сценарій
    дивись тіло помилки

    Content-Type і заголовки?

    Стоп: не той формат
    тіло парсити не можна

    Тіло валідне за схемою?

    Стоп: зламаний контракт форми

    Значення й бізнес-логіка?

    Дефект даних або логіки

    Перевірка пройдена

    Базовий чек-лист для будь-якої відповіді:

    • Статус-код — саме той, що очікує сценарій (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 часткова перевірка

    Ключове рішення для кожної асерції: звіряти відповідь повністю чи лише потрібні поля. Обидві крайнощі мають ціну.

    Так

    Ні: час, id, порядок

    Форма

    Конкретика

    Що перевіряємо в тілі?

    Значення детерміноване
    і контролюємо його?

    Точна перевірка
    toEqual на конкретне значення

    Важливий тип/форма
    чи конкретика?

    Схема + toMatchObject
    expect.any для летких полів

    Асерт лише на потрібні поля

    Так

    Ні: час, id, порядок

    Форма

    Конкретика

    Що перевіряємо в тілі?

    Значення детерміноване
    і контролюємо його?

    Точна перевірка
    toEqual на конкретне значення

    Важливий тип/форма
    чи конкретика?

    Схема + toMatchObject
    expect.any для летких полів

    Асерт лише на потрібні поля

    Точна перевірка (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.

    Джерела