GraphQL: особливості тестування
Зміст
REST привчив нас читати відповідь за : 200 — успіх, 404 — не знайшли, 400 — крива форма запиту. GraphQL цю звичку ламає. Той самий /graphql, той самий метод POST — і в легасі-режимі з медіа-типом application/json сервер віддає 200 OK навіть тоді, коли всередині лежить помилка «поле не знайдено». Автотест, який перевіряє тільки статус, у такому режимі зелений завжди й не ловить нічого.
Для QA це означає одне: центр ваги перевірки зміщується зі статус-рядка в тіло відповіді. Треба навчитися читати (envelope) із двома полями — data і errors, розуміти, чому вони бувають одночасно, і де проходить межа між «запит невалідний за схемою» та «резолвер (resolver) упав на бізнес-логіці». Ця глава — про те, чим тестування GraphQL відрізняється від REST і які пастки чекають на того, хто переносить старі звички один-в-один. Основи перевірки тіла й «пастку 200 OK з помилкою» ми розбирали в главі «Перевірки відповіді» — GraphQL доводить цю пастку до крайності.
Один ендпоінт і статус, що не є оракулом
У REST кожен ресурс має свій URL: GET /users/42, POST /orders, DELETE /carts/7. Маршрут і метод несуть частину сенсу, а статус-код відповіді — успіху. У GraphQL усе інакше: є один ендпоінт (зазвичай POST /graphql), а що саме ти хочеш — читати чи змінювати, один об'єкт чи десять, — описано в тілі запиту.
query GetUser($id: ID!) {
user(id: $id) {
id
email
orders {
id
total
}
}
}
Клієнт надсилає цей текст запиту разом з об'єктом змінних (variables), а сервер повертає JSON тієї ж форми, що й запит. Ключова відмінність від REST: у легасі-режимі транспортний статус-код лишається 200 незалежно від того, чи все спрацювало, — «користувача не існує» і «поле впало» приїжджають усередині 200 OK, а не як 404/500. Окремо стоїть на рівні всього запиту: якщо клієнту взагалі не дозволено його виконувати, специфікація велить серверу відповісти 401, 403 або подібним кодом. А от польова авторизація — це вже інший випадок: там запит виконується, частина полів резолвиться, і відмова приїжджає як часткова відповідь.
Чому так? Один запит може одночасно зачепити десять полів, з яких дев'ять успішні, а одне впало. Який тоді статус ставити на всю відповідь? Розподілу відповідальності «транспорт доставляє, помилки живуть у тілі» сама специфікація GraphQL не робить — вона про HTTP майже не говорить. Той документ, що говорить, — GraphQL over HTTP, — навпаки робить статуси нормативними: якщо у відповіді є data і воно не null, сервер МУСИТЬ віддати 2xx; якщо data немає зовсім — МУСИТЬ віддати відповідний 4xx або 5xx. Для часткового успіху (є і data, і errors) документ пропонує окремий код 294, хоч у сусідньому розділі прикладів радить у тому самому випадку 200; ця розбіжність усередині документа є, і пояснень їй у ньому немає.
Практичний наслідок для автотесту: у легасі-режимі expect(res.status()).toBe(200) — це перевірка, що сервер живий і запит доїхав, а не що операція вдалася. Справжній оракул успіху — відсутність errors і наявність очікуваних data.
const res = await request.post('/graphql', {
data: {
query: `query GetUser($id: ID!) { user(id: $id) { id email } }`,
variables: { id: '42' },
},
});
expect(res.status()).toBe(200); // у легасі-режимі 200 — це НЕ оракул успіху
const body = await res.json();
expect(body.errors).toBeUndefined(); // ось справжня перевірка, що все спрацювало
expect(body.data.user.email).toBe('user@example.com');
Одне застереження, щоб не збрехати: «завжди 200» — це поведінка легасі-режиму з медіа-типом application/json, і специфікація GraphQL over HTTP називає такий сервер саме легасі — тим, що їй не відповідає. Відповідний сервер МУСИТЬ підтримувати медіа-тип application/graphql-response+json, і там статуси нормативні: 400, коли не зміг розібрати документ запиту, 422 — коли запит не пройшов валідацію або не приводяться змінні, 405 — на мутацію через GET. Тому правильна звичка — не «статус завжди 200», а «зʼясуй режим свого сервера й перевіряй тіло». Сам документ GraphQL over HTTP при цьому має статус чернетки (draft).
Масив errors і часткові дані
Відповідь GraphQL — це конверт із двох головних полів верхнього рівня — data і errors (специфікація дозволяє ще опційне extensions для метаданих сервера):
{
"data": { "user": { "id": "42", "email": null } },
"errors": [
{
"message": "Not authorized to read email",
"path": ["user", "email"],
"locations": [{ "line": 1, "column": 34 }],
"extensions": { "code": "FORBIDDEN" }
}
]
}
Найважливіша й найнеочікуваніша для новачка річ: data і errors можуть бути присутні одночасно. Це називають (partial data). Тут user.id резолвнувся успішно, а user.email — ні (немає прав), тож у data він приїхав як null, а поруч у errors лежить пояснення, чому саме.
Логіка така: GraphQL виконує запит поле за полем через окремі резолвери. Якщо резолвер одного поля кинув помилку, GraphQL не викидає всю відповідь — він ставить null на місце проблемного поля (з поправкою на nullability), додає запис у errors і йде далі. Тому одна відповідь може містити і корисні дані, і список того, що зламалося.
Для QA звідси випливає жорстке правило перевірки: errors треба перевіряти завжди, навіть коли data виглядає заповненим. Тест, який дивиться лише на data.user.id і радіє, що воно є, пропустить факт, що email мовчки став null через помилку доступу.
Найкорисніше поле помилки для тестувальника — path. Воно вказує точний шлях до поля, яке впало: ["user", "email"]. Не треба гадати, чому в даних дірка, — path показує саме те місце. Поле extensions.code (машиночитний код помилки на кшталт FORBIDDEN, NOT_FOUND) зручніше для , ніж message: текст повідомлення розробники міняють вільно, а код зазвичай тримають стабільним. Але extensions — розширення, не гарантоване специфікацією; перевір, чи ваш сервер його заповнює, перш ніж будувати на ньому перевірки.
Окремий випадок — коли data дорівнює null цілком (а не окреме поле). Загальне правило специфікації ширше: якщо під час виконання зʼявилася помилка, яка завадила сформувати валідну відповідь, data має бути null. Спливання null по non-null-полях — один зі шляхів туди: якщо всі поля від кореня запиту до джерела помилки мають non-null-типи, null спливає вгору аж до кореня. Для негативних сценаріїв це нормальний результат; головне — перевіряти й data, і errors разом, а не окремо. Загальні принципи негативних перевірок ми розбираємо в главі «Негативні перевірки й обробка помилок» — GraphQL лише міняє, де саме шукати ознаку провалу.
Валідність запиту vs логіка резолверів
Це найважливіша межа теми. У GraphQL є два принципово різні рівні «неправильно», і плутати їх не можна.
Перш ніж виконати запит, сервер проганяє його через два етапи: парсинг (чи це взагалі синтаксично коректний GraphQL) і валідацію за схемою (чи існують запитані поля, чи правильні типи аргументів, чи не порушені обов'язковість і форма). Якщо запит не проходить ці етапи — жоден резолвер не викликається взагалі. Відповідь містить errors, а data немає зовсім (не null, а відсутнє). І тут важлива деталь про статус: якщо у відповіді немає запису data, сервер, що відповідає GraphQL over HTTP, МУСИТЬ віддати 4xx/5xx, а для непройденої валідації РЕКОМЕНДОВАНО саме 422. Тобто асерт status === 200 разом із data === undefined проти такого сервера зелений не буде — звіряйся з режимом свого сервера.
// поля phone у типі User немає — це помилка ВАЛІДАЦІЇ
// легасі-режим віддасть 200; сервер за специфікацією — 422
const body = await postQuery(`{ user(id: "42") { phone } }`);
expect(body.errors?.[0].message).toContain('Cannot query field');
expect(body.data).toBeUndefined(); // виконання не почалось — data немає взагалі
Другий рівень — помилки виконання (execution errors): запит валідний, резолвери запустилися, але один із них кинув помилку — не знайшов запис, отримав відмову в доступі, впав на зверненні до БД. Тут ми отримуємо ту саму картину «часткові дані»: data з дірками плюс errors.
Чому ця межа важлива на практиці? Бо вона розводить два різні класи багів. Помилка валідації — це зазвичай проблема клієнта або контракту: фронтенд запитав поле, якого вже немає, чи передав рядок замість ID. Помилка виконання — це проблема бекенду або даних: резолвер не впорався. Коли пишеш баг-репорт, важливо не переплутати «фронт шле застарілий запит» із «бекенд падає на валідному запиті». А коли проєктуєш негативні тести, ти свідомо покриваєш обидва рівні: криві запити (перевіряєш, що схема їх відбиває на валідації) і валідні запити з поганими даними чи без прав (перевіряєш логіку резолверів).
Ще одна пастка: схема гарантує лише форму й типи, а не бізнес-правила. Запит createOrder(total: -100) може бути ідеально валідним за схемою (total — число), але семантично абсурдним. Валідність за схемою — це необхідна, а не достатня умова коректності. Логіку «сума не може бути від'ємною» перевіряють резолвери, і саме її має ловити твій тест-дизайн.
Схема й інтроспекція як контракт
У REST джерелом істини про API зазвичай є OpenAPI-специфікація — про неї йдеться в главі «OpenAPI/Swagger». У GraphQL контракт вбудований у сам сервіс: це схема (schema), написана мовою опису схем (Schema Definition Language, SDL).
type User {
id: ID!
email: String
orders: [Order!]!
}
type Query {
user(id: ID!): User
}
Схема строго типізована: ID! означає обов'язкове (non-null) поле типу ID, [Order!]! — обов'язковий масив, у якому кожен елемент теж non-null. Ця типізація — уже частина контракту, який можна тестувати: якщо схема каже, що orders не буває null, а сервер повертає null — це розбіжність між контрактом і реальністю, тобто баг.
Найцікавіше для QA — (introspection). GraphQL дозволяє запитати саму схему в рантаймі через спеціальні мета-поля __schema і __type:
{
__schema {
types {
name
fields { name }
}
}
}
Це потужний інструмент: інтроспекція дає повну, машиночитну картину API без окремої документації. На ній тримаються GraphiQL, автодоповнення в IDE, генерація типів для клієнтів і — що для нас важливо — можливість автоматично звіряти реальну схему з очікуваною. Порівняння схеми між ловить breaking changes (зникло поле, змінилася nullability, поле стало обов'язковим) ще до того, як щось упаде в клієнті. Це полегшений варіант , про повноцінну версію якого — глава «Контрактне тестування».
Але тут чатує реальна пастка. Інтроспекцію часто вимикають на проді з міркувань безпеки: відкрита схема полегшує зловмиснику розвідку API. Якщо твій генератор тестів чи інструмент спирається на інтроспекцію, він чудово працюватиме на dev/staging і раптово впаде на проді з помилкою на кшталт «introspection is disabled». Це не баг твоїх тестів — це свідоме рішення бекенду, і його треба закладати в стратегію (наприклад, тримати SDL-файл схеми в репозиторії як еталон замість того, щоб щоразу тягнути її з середовища).
Over-fetching і under-fetching
Головна причина, чому GraphQL узагалі з'явився, — боротьба з двома болями REST.
Over-fetching — сервер віддає більше, ніж треба. Мобільному застосунку потрібні тільки ім'я й аватар користувача, а GET /users/42 повертає повний об'єкт із адресою, налаштуваннями й історією — зайвий трафік і обробка.
Under-fetching — навпаки, одного запиту мало. Щоб показати сторінку користувача з його замовленнями, доводиться зробити GET /users/42, потім GET /users/42/orders, потім по запиту на кожне замовлення. Класична проблема ланцюжка послідовних запитів (request ).
GraphQL розв'язує обидві: клієнт в одному запиті перелічує рівно ті поля, які йому потрібні, на будь-якій глибині вкладеності. Ні зайвого, ні кількох походів.
Що це міняє для тестувальника? Об'єкт перевірки зміщується. У REST відповідь фіксована — ти перевіряєш той набір полів, який віддає ендпоінт. У GraphQL форму відповіді визначає запит, тож той самий user в одному місці застосунку приходить із трьома полями, а в іншому — з п'ятнадцятьма й вкладеними замовленнями. Тому:
- Немає сенсу перевіряти «повний об'єкт» — перевіряй саме ті поля, які запитала операція, що ти тестуєш.
- З'являється клас перевірок «а чи можна запитати оце поле в цьому контексті» — доступ до полів може залежати від ролі.
- Одна й та сама сутність у різних запитах — це різні форми відповіді, і кожна форма варта окремого тесту, якщо за нею стоїть окрема логіка резолверів.
Глибина, складність і N+1 (оглядово)
Гнучкість GraphQL має зворотний бік, який породжує окремі перевірки. Оскільки клієнт сам будує запит, він може побудувати зловмисно важкий запит.
Класичний приклад — глибока вкладеність через циклічні зв'язки: користувач має замовлення, замовлення має користувача, у якого знову замовлення, і так далі. Достатньо кількох рядків, щоб згенерувати запит, який навантажить сервер на порядки сильніше за звичайний. Тому продакшн-сервери зазвичай ставлять захист: обмеження глибини (depth limiting) і аналіз складності/вартості (query complexity / cost analysis) — кожному полю призначають «ціну», сумарний бюджет запиту обмежують, і надто дорогий запит відхиляється ще до виконання.
Для QA це — окрема негативних перевірок: переконатися, що надто глибокий чи надто складний запит відбивається з осмисленою помилкою, а не кладе сервіс. Межі глибини й вартості — частина контракту, і вони варті тесту.
Друга наскрізна проблема — N+1. Уяви запит «список із 20 користувачів, і для кожного — його замовлення». Наївний резолвер зробить 1 запит по користувачів, а потім по окремому запиту в БД на замовлення кожного — 1 + 20 звернень. На великих списках це вбиває продуктивність. Стандартне лікування на боці бекенду — батчинг (batching) через патерн на кшталт DataLoader, який збирає всі звернення за один тик і робить один запит замість двадцяти. Ручному QA не треба це реалізовувати, але варто знати симптом: якщо запит зі списком і вкладеними полями раптом драматично повільніший за очікуване, перша гіпотеза — N+1. Продуктивність під навантаженням — тема окремого розділу (performance-тестування), тут N+1 згадуємо лише як характерний для GraphQL клас деградації.
Інструменти: GraphiQL, Postman, код
Логіка перевірки в GraphQL та сама на будь-якому інструменті — читаємо data й errors. Інструменти різняться лише зручністю на різних етапах.
- GraphiQL / Apollo Sandbox — вбудований у браузер редактор запитів. Головна цінність — інтроспекція «з коробки»: бачиш повну схему, автодоповнення полів, вбудовану документацію типів. Ідеальний для розвідки незнайомого API: що взагалі можна запитати, які аргументи, яка форма відповіді. Це «руки», щоб швидко зрозуміти контракт перед тим, як писати тести.
- Postman / Insomnia / Bruno — мають окремий режим GraphQL: підтягують схему, дають автодоповнення, окреме поле для змінних. Зручні для ручних перевірок, збереження запитів у колекції та простих асертів. Основи Postman — у главах «Postman: основи» і «Postman: скрипти».
- Код (Playwright / CodeceptJS / supertest) — для автотестів. Тут немає жодної магії: GraphQL-запит — це звичайний
POSTз JSON-тілом{ query, variables }. Ніякого спеціального клієнта не потрібно; той самийAPIRequestContext, що й для REST.
// GraphQL-запит — це звичайний POST; спеціальний клієнт не обов'язковий
async function postQuery(query: string, variables = {}) {
const res = await request.post('/graphql', { data: { query, variables } });
// жорсткий асерт на 200 годиться лише для легасі-режиму application/json:
// сервер за специфікацією віддасть 400/422 на криву чи невалідну операцію
expect(res.ok()).toBeTruthy();
return res.json();
}
const body = await postQuery(
`query($id: ID!) { user(id: $id) { id email } }`,
{ id: '42' },
);
expect(body.errors).toBeUndefined();
expect(body.data.user).toMatchObject({ id: '42' });
Патерн API-клієнта, ланцюжки запитів і структуру таких тестів детально розбирає глава «API-автотести в коді» — GraphQL сюди вкладається без винятків, лише з поправкою на «перевіряй тіло, не статус».
Окрема практична дрібниця: змінні (variables) завжди виноси в окремий об'єкт, а не вклеюй значення в текст запиту рядковою конкатенацією. Це не лише чистіше, а й ближче до того, як працює реальний клієнт, і рятує від помилок екранування.
Типові помилки
- Виглядає як зелений тест, а насправді відповідь із помилкою. Тест перевіряє
status === 200і радіє. У легасі-режимі це правда навіть тоді, коли в тіліerrorsіdata: null. Перевіряй тіло, а не статус. - Виглядає як «поле повернуло null через баг даних», а насправді резолвер цього поля впав.
nullуdataбуває від реальної відсутності значення й від помилки резолвера — це різні речі. Розводить їх полеerrors[].path: воно показує саме те поле, що зламалося. - Виглядає як помилка бекенду, яку ловитимемо за 5xx, а насправді сервер віддав 200. У легасі-режимі «сервер упав» приїжджає як
200із записом уerrors, тож негативний тест, що чекає на500, там не спрацює. На сервері, що відповідає специфікації, картина інша: без записуdataвін мусить віддати4xx/5xx. - Виглядає як «схема гарантує коректність», а насправді лише форму й типи. Валідний за схемою запит легко несе логічний баг (від'ємна сума, чужий
id). Схема — про типи й nullability, бізнес-правила перевіряють резолвери й твої тести. - Виглядає як «інтроспекція є завжди», а насправді на проді її часто вимикають. Інструмент, що будує тести зі схеми через інтроспекцію, зелений на staging і падає на проді. Тримай еталонну схему у репозиторії або передбач цей режим.
- Виглядає як «один ендпоінт — один тест». Насправді форму відповіді задає запит: та сама сутність із різним набором полів — це різні перевірки з різною логікою резолверів і різними правами доступу.
Підсумок
- Статус-код у GraphQL — не оракул успіху в легасі-режимі
application/json: там завжди200, а ознака провалу живе в тілі. Сервер, що відповідає специфікації, МУСИТЬ підтримуватиapplication/graphql-response+json, і статуси там нормативні (400,422,405). dataйerrorsспівіснують: часткові дані — штатна поведінка. Дивисьerrors[].path, щоб точно знайти поле, яке впало.- Є дві різні межі «неправильно»: невалідний за схемою запит (резолвери не запускаються,
dataнемає) і помилка виконання резолвера (dataз дірками +errors). Не плутай їх у баг-репортах і в тест-дизайні. - Схема — контракт про типи, форму й nullability, але не про бізнес-логіку. Валідність запиту ≠ правильність резолвера. Інтроспекція дає схему в рантаймі, але на проді її часто вимикають.
- Клієнт сам обирає поля (кінець over-/under-fetching), натомість з'являються серверні глибини/складності й N+1 — окремі класи перевірок. Інструмент вторинний: логіка перевірки та сама скрізь.
Можливі питання
- «Чим тестування GraphQL відрізняється від REST?» — базове питання на розуміння. Інтерв'юер слухає, чи назвеш один ендпоінт замість багатьох маршрутів, тіло з
errorsзамість статус-коду як оракула (у легасі-режимі), схему як вбудований контракт і зникнення over-/under-fetching. Відповідь «ну, це теж API поверх HTTP» — недостатня. - «GraphQL повернув 200 — тест пройшов?» — пастка на статус як оракул. Сильна відповідь: у легасі-режимі
200каже лише, що запит доїхав; успіх визначає відсутністьerrorsі наявність очікуванихdata. Дуже сильна — додає, що на сервері за специфікацією статуси нормативні. - «Що таке partial data і як його перевіряти?» — перевіряють, чи розумієш, що
dataйerrorsбувають разом і щоnullу полі може ховати помилку. Згадка проerrors[].pathяк спосіб локалізувати проблемне поле — сильний сигнал. - «Де різниця між помилкою валідації та помилкою резолвера?» — питання на глибину. Валідація відбувається до виконання (криве поле/тип →
dataнемає взагалі), помилка резолвера — під час виконання (dataз дірками +errors). Це розводить баги клієнта/контракту й баги бекенду/даних. - «Що таке інтроспекція і чому її можуть вимикати?» — інтроспекція дає схему в рантаймі (
__schema,__type); вимикають на проді з міркувань безпеки. Бонус — згадати, як це впливає на інструменти, що генерують тести зі схеми. - «Що таке N+1 у GraphQL і чому це турбує QA?» — оглядове питання на продуктивність: наївні резолвери роблять запит на кожен елемент списку; симптом — раптова деградація на вкладених списках; лікування на боці бекенду — батчинг (DataLoader).
Джерела
Один ендпоінт і статус, що не є оракулом
- GraphQL over HTTP (робочий проєкт специфікації) — «завжди
200» — опис легасі-режиму: сервер МУСИТЬ підтримуватиapplication/graphql-response+json, і там правила статусів нормативні (єdataнеnull→2xx; немаєdata→4xx/5xx). - graphql.org/learn — Queries and Mutations — форму відповіді задає запит: результат повертається під ключем
data, а інформація про помилки — підerrors. - GraphQL Specification (October 2021) — відповідь сервісу описує результат виконання операції і всі помилки, що виникли під час запиту.
- GraphQL Specification (October 2021) — конверт формально:
dataвідсутнє, якщо помилка сталася до виконання, і дорівнюєnull, якщо валідної відповіді не вийшло;errorsза відсутності помилок не присутнє взагалі, аpathє вимогою для помилок конкретного поля — саме щоб відрізнити навмиснийnullвід помилки.
Валідність запиту vs логіка резолверів
- GraphQL Specification (October 2021) — request errors виникають до початку виконання (дані не повертаються), field errors — під час резолву поля й дають часткову відповідь; на поле додається рівно одна помилка.
- GraphQL over HTTP (робочий проєкт специфікації) — нормативна мапа статусів для першого рівня: не розібрався документ запиту →
400; не пройшов валідацію або не приводяться змінні →422.
Схема й інтроспекція як контракт
- graphql.org/learn — Introspection — схему можна запитати в самої служби:
__schemaдоступне на кореневому типі запиту, а інтроспекційні запити живлять інструменти розробки. - GraphQL Specification (October 2021) — нормативний бік: подвійне підкреслення — зарезервований префікс, мета-поля
__schema/__typeнеявні й не зʼявляються у списку полів; єдиний виняток — корінь підписки.
Over-fetching і under-fetching
- graphql.org/learn — Queries and Mutations — запити обходять повʼязані обʼєкти й тягнуть повʼязані дані одним запитом замість кількох походів; у REST передається один набір аргументів, а тут власні аргументи має кожне поле.
Інструменти: GraphiQL, Postman, код
- graphql.org/learn — Introspection — редактори живляться інтроспекцією: той, хто не проєктував систему типів, може спитати про неї саму службу.
- Postman Docs — Postman Sandbox API reference (обʼєкт pm) —
pm.requireпрацює у скриптах запитів HTTP, gRPC і GraphQL — окремого рантайму для GraphQL немає. - Playwright — API testing — у коді це звичайний контекст запитів: спеціального клієнта GraphQL не потрібно.
- GraphQL over HTTP (робочий проєкт специфікації) — у легасі-режимі статус доводить лише доставку; на сервері за специфікацією відсутність
dataзобовʼязує віддати4xx/5xx. - GraphQL Specification (October 2021) —
nullу полі буває навмисним і буває наслідком помилки — розводить їхerrors[].path, і саме для цього він і введений. - graphql.org/learn — Introspection — інтроспекція дає лише картину схеми — типи й форму, а не бізнес-правила.
Чим тестування GraphQL відрізняється від REST?
Головна відмінність — де живе успіху. У REST кожен ресурс має свій URL і метод, а відповіді каже долю запиту: 200 — успіх, 404 — не знайшли, 400 — крива форма. У GraphQL є один (зазвичай POST /graphql), а що саме робити описано в тілі запиту; у легасі-режимі application/json транспортний статус лишається 200, навіть коли всередині помилка. Тому головні перевірки переїжджають у тіло відповіді: долю запиту читаємо з полів data й errors. Плюс до цього схема (schema) стає вбудованим контрактом, а over-/under-fetching зникає, бо клієнт сам перелічує потрібні поля. Відбутися фразою «це теж API поверх HTTP» не вийде — інтерв'юер чекає саме цих чотирьох відмінностей.
GraphQL повернув 200 — тест пройшов?
Ні, 200 каже лише, що запит доїхав до сервера й той його опрацював, а не що операція вдалася. У легасі-режимі з медіа-типом application/json GraphQL відповідає 200 навіть на «поле не знайдено» чи «резолвер упав» — бізнес-помилка приїжджає в тілі, у полі errors. (Сервер, що відповідає GraphQL over HTTP, поводиться інакше: без запису data він мусить віддати 4xx/5xx, а на заборонений запит цілком — 401, 403 або подібний код.) Тому expect(res.status()).toBe(200) перевіряє, що сервер живий, а не що все спрацювало. Успіх засвідчує тіло: errors немає, а очікувані data на місці. Асерт лише на статус зелений за будь-якого результату — тобто не перевіряє нічого.
Що таке конверт відповіді GraphQL і які поля він містить?
(envelope) — це верхній рівень JSON-відповіді: поля data і errors, до яких сервер за специфікацією може додати опційний extensions зі своїми метаданими. У data лежить результат тієї ж форми, що й запит; у errors — масив бізнес-помилок, кожна зі своїм message, path, locations і, можливо, extensions. Ключова ідея — транспорт (HTTP) відповідає лише за доставку, а сенс операції читається саме з цих двох полів. Тому будь-яка перевірка GraphQL-відповіді починається з розбору конверта, а не зі статус-коду.
Що таке часткові дані і чому data й errors можуть бути разом?
(partial data) — це коли у відповіді одночасно присутні й data, і errors. Причина в моделі виконання: кожне поле обробляє свій резолвер (resolver), і провал одного з них не скасовує решту. Проблемне поле перетворюється на null, у errors додається запис про причину, а виконання інших полів триває. У підсумку та сама відповідь несе водночас робочі дані й перелік поламаного: наприклад, user.id резолвнувся, а user.email став null через в доступі. Правило для QA: у кожній перевірці заглядай у errors, хай навіть data на вигляд повне. Тест, що зупинився на data.user.id, не помітить, як email тихо перетворився на null через помилку.
Тест бачить null у полі. Як зрозуміти, це реальна відсутність значення чи помилка резолвера?
null у data буває з двох різних причин: значення справді немає (порожнє поле) або резолвер цього поля впав. Розводить їх масив errors: якщо в ньому є запис із path, що вказує саме на це поле, — значить, поле зламалося, а не було порожнім. path називає конкретне місце провалу, наприклад ["user", "email"], тож здогадуватися про причину дірки не доводиться. Практичний висновок: побачив null — перевір, чи немає для цього шляху запису в errors. Саме тому перевіряти data й errors завжди треба разом, а не окремо.
Навіщо тестувальнику поля errors[].path і extensions.code?
path — найкорисніше поле помилки для локалізації: воно веде просто до поля, що впало (["user", "email"]), і проблемне місце видно одразу, без гадання. extensions.code — машиночитний код помилки (FORBIDDEN, NOT_FOUND); саме на нього варто спиратися в асертах замість message, бо формулювання тексту змінюється без попередження, а коди команди тримають незмінними. Тому асерт варто будувати на коді, а не на рядку повідомлення, який зламає тест від будь-якого переформулювання. Одне застереження: extensions — це розширення, не гарантоване специфікацією, тож перш ніж на ньому будувати перевірки, треба переконатися, що ваш сервер його заповнює.
Яка різниця між помилкою валідації та помилкою виконання резолвера?
Це найважливіша межа теми. До виконання запит проходить два фільтри: парсинг (синтаксична коректність GraphQL) і валідацію за схемою (існування полів, типи аргументів, обов'язковість). Запит, що не пройшов фільтри, до резолверів не потрапляє взагалі: у відповіді є errors, а data немає зовсім (не null, а відсутнє). Помилка виконання (execution error) — інша: запит валідний, резолвери запустилися, але один із них кинув помилку (не знайшов запис, відмова в доступі, впав на БД), і тоді отримуємо часткові дані — data з дірками плюс errors. На практиці ця межа розводить два класи багів: валідація — це зазвичай проблема клієнта чи контракту (фронт шле застаріле поле), виконання — проблема бекенду чи даних (резолвер не впорався). Плутати їх у баг-репортах не можна.
Запит просить поле, якого немає у схемі. Що буде у відповіді?
Це помилка валідації, тож запит навіть не почне виконуватися. Сервер відхилить його на етапі валідації за схемою з повідомленням на кшталт Cannot query field, у відповіді буде errors, а data буде відсутнє повністю — не null, а взагалі немає ключа. Це важливо для асерту: перевіряти треба одночасно наявність errors і те, що data відсутнє (expect(body.data).toBeUndefined()), бо саме відсутність data відрізняє провал валідації від провалу резолвера. Такий негативний тест підтверджує, що схема реально відбиває криві запити, а не пропускає їх до виконання.
data дорівнює null цілком, а не окреме поле. Що це означає?
Це сигнал, що під час виконання сталася помилка, яка завадила сформувати валідну відповідь. Найчастіший шлях туди — провал у non-null полі: поставити туди null не можна, тож помилка пропагується батьківськими рівнями аж до кореня. Логіка nullability така: якщо резолвер non-null поля кинув помилку, GraphQL не може поставити туди null, тож пропагує провал на батьківський рівень — і так далі, доки не натрапить на поле, що null дозволяє, або не дійде до самого data. Для негативних сценаріїв це штатний результат, а не . Головне — перевіряти data й errors разом: сам по собі data: null без погляду на errors не скаже, що саме зламалося.
Що таке схема і SDL і чому це контракт?
Схема (schema) — це опис усіх типів, полів і операцій API мовою опису схем (Schema Definition Language, SDL). Наприклад, type User { id: ID! email: String orders: [Order!]! } каже, що id — обов'язкове (non-null) поле типу ID, а orders — масив, який сам не буває null і не містить null-елементів. На відміну від REST, де контрактом зазвичай є окрема OpenAPI-специфікація, у GraphQL контракт вбудований у сам сервіс. Ця типізація вже тестована: якщо за схемою orders не буває null, а сервер його повернув, — маємо розходження контракту з реальністю, тобто повноцінний баг. Тому схема — не документація «для довідки», а джерело перевірюваних інваріантів про форму, типи й nullability.
Що таке інтроспекція і чому її можуть вимикати на проді?
(introspection) — можливість запитати саму схему в рантаймі через мета-поля __schema і __type: повний машиночитний опис API дістається без жодної окремої документації. Саме через неї працюють GraphiQL, автодоповнення в IDE, генерація типів клієнтів і автоматичне звіряння реальної схеми з очікуваною — фактично полегшене , що ловить breaking changes між . Але на проді інтроспекцію часто глушать: схема, видима будь-кому, — подарунок зловмиснику для розвідки API. Практичний наслідок: інструмент, що будує тести зі схеми через інтроспекцію, працює на dev/staging, а на проді несподівано зустрічає «introspection is disabled». Це не баг тестів, а свідоме рішення бекенду; його треба закладати в стратегію — наприклад, тримати SDL-файл схеми в репозиторії як еталон.
Схема каже, що запит валідний. Це гарантує коректність результату?
Ні. Схема відповідає лише за форму й типи — бізнес-правил вона не знає. Запит createOrder(total: -100) схема пропустить без зауважень — total же число, — хоча за сенсом він абсурдний: від'ємна сума неможлива. Тобто валідність за схемою — це вхідний квиток, а не гарантія коректності. Логіка «сума не може бути від'ємною», «id належить іншому користувачу», «дата в минулому» лежить на резолверах — і саме на неї націлюється тест-дизайн. Тому негативні тести обов'язково включають валідні за схемою запити з поганими даними чи без прав, а не лише криві за формою.
Що таке over-fetching і under-fetching і що вони міняють для тестувальника?
Over-fetching — сервер віддає більше, ніж треба: мобільному застосунку потрібні ім'я й аватар, а REST-ендпоінт повертає повний об'єкт із адресою й історією. Under-fetching — навпаки, одного запиту мало, і щоб зібрати екран, доводиться робити (класичний request ). GraphQL розв'язує обидва: клієнт одним запитом замовляє точно потрібний набір полів на довільній глибині вкладеності. Для тестувальника це зміщує об'єкт перевірки: форму відповіді визначає запит, тож той самий user в одному місці приходить із трьома полями, а в іншому — з п'ятнадцятьма й вкладеними замовленнями. «Повний об'єкт» перевіряти нема чого — асерти пишуться на той набір полів, який замовила конкретна операція.
Чому в GraphQL немає сенсу перевіряти «повний об'єкт»?
Бо в GraphQL немає фіксованої відповіді, яку віддає ендпоінт, — форму відповіді задає сам запит. Одна й та сама сутність у різних операціях приходить із різним набором полів: десь три поля, десь п'ятнадцять із вкладеними зв'язками. Асерт «перевіримо, що прийшов увесь user» просто не має сенсу, бо «увесь» залежить від того, що ти запитав. Тому перевіряй рівно ті поля, які запитала операція, а різні форми тієї самої сутності — це різні перевірки, кожна варта окремого тесту, якщо за нею стоїть окрема логіка резолверів чи окремі права доступу.
Що таке обмеження глибини й аналіз складності і чому це окремий клас перевірок?
Оскільки клієнт сам будує запит, він може побудувати зловмисно важкий запит — наприклад, через циклічні зв'язки (користувач має замовлення, замовлення має користувача, у якого знову замовлення) кількома рядками згенерувати запит, що навантажить сервер на порядки. Тому продакшн-сервери ставлять захист: обмеження глибини (depth limiting) відхиляє надто глибоку вкладеність, а аналіз складності/вартості (query complexity / cost analysis) рахує «ціну» кожного поля й відкидає запити, що вилазять за бюджет, ще до виконання. Для QA це окрема негативних перевірок: заважкий запит має отримати зрозумілу відмову, а не покласти сервіс. Самі межі глибини й вартості — задокументована частина контракту, тож заслуговують на власний тест.
Що таке N+1 у GraphQL і чому це турбує QA?
N+1 — проблема продуктивності наївних резолверів. Уяви запит «список із 20 користувачів, і для кожного — його замовлення»: наївний резолвер дістає список одним запитом, а далі ходить у БД окремо по замовлення кожного користувача — разом 1 + 20 звернень. На великих списках продуктивність осідає. Лікують це на бекенді батчингом (batching): DataLoader чи аналогічний патерн акумулює окремі звернення й ходить у БД одним запитом замість двадцяти. Реалізовувати це — робота бекенду, а от упізнавати симптом мусить і QA: список із вкладеними полями відповідає в рази повільніше, ніж мав би, — першою перевіряй гіпотезу N+1. Це характерний для GraphQL клас деградації, хоч сама продуктивність під навантаженням — тема окремого розділу.
Чому змінні треба виносити в окремий об'єкт, а не вклеювати значення в текст запиту?
GraphQL-запит складається з двох частин: тексту операції та об'єкта змінних (variables), і сервер отримує їх як { query, variables }. Виносити значення в окремий об'єкт (variables: { id: '42' }) замість рядкової конкатенації в текст запиту треба з кількох причин: так робить реальний клієнт, код читається чистіше, і зникають проблеми з екрануванням (лапки, спецсимволи в рядкових значеннях). Вклеювання значень у текст запиту крихке й нагадує за стилем помилок. Тому в автотесті postQuery(query, variables) — правильний патерн, а конкатенація рядків — джерело плаваючих багів.
Чи потрібен для GraphQL-автотестів спеціальний клієнт?
Ні. Під капотом GraphQL-запиту — звичайний POST із JSON { query, variables }, тож вистачає того самого APIRequestContext у Playwright (чи supertest, CodeceptJS), яким тестуєш REST. Жодної магії немає: надсилаєш текст запиту та змінні, читаєш JSON-відповідь, розбираєш data й errors. Спеціалізовані клієнти (Apollo, urql) дають зручності на кшталт кешу чи типізації, але для тестування контракту вони не обов'язкові. Єдина поправка проти REST-звички — перевіряй тіло (errors/data), а не статус.
Три кейси, де GraphQL ламає REST-звички: розбір відповіді 200 OK із помилкою й , Playwright-тести, де успіху — тіло, а не статус, і «валідація чи резолвер», яка визначає, куди заводити дефект. Скрізь — що дивитися і чому.
Кейс 1. Читаємо конверт: 200, а всередині помилка
Тест зелений: status === 200, data.user.id на місці. А користувач скаржиться, що email не показується. Перш ніж лізти у фронтенд, розберемо саме тіло відповіді — воно й пояснює, чому «все ок» насправді не ок.
{
"data": {
"user": { "id": "42", "email": null }
},
"errors": [
{
"message": "Not authorized to read email",
"path": ["user", "email"],
"locations": [{ "line": 1, "column": 34 }],
"extensions": { "code": "FORBIDDEN" }
}
]
}
Що дивитися і чому:
- Статус
200, а операція не вдалася повністю. У легасі-режиміapplication/jsonсервер віддає200навіть коли частина полів упала. на статус тут зелений і не бачить нічого — оракул провалу живе вerrors. dataйerrorsприсутні одночасно — це часткові дані (partial data).user.idрезолвнувся, аuser.email— ні, тож уdataвін приїхав якnull, а поруч уerrorsлежить причина. Тест, що дивиться лише наdata.user.id, пропускає мовчазнийnullвemail.path: ["user", "email"]локалізує проблему точно. Не треба гадати, чому в даних дірка —pathпоказує саме те поле, що зламалося. Це перше, куди дивишся при несподіваномуnull.extensions.code: "FORBIDDEN"— для асерту надійніший заmessage. Текст «Not authorized to read email» розробники перепишуть будь-коли, а код зазвичай тримають стабільним. Але спершу переконайся, що ваш сервер узагалі заповнюєextensions— специфікація його не гарантує.
Кейс 2. Playwright: оракул — тіло, а не статус
Логіка тесту GraphQL проста, якщо прибрати REST-звичку. Хелпер шле звичайний POST з тілом { query, variables }, а перевіряємо errors/data, а не статус. Спеціальний GraphQL-клієнт не потрібен — той самий APIRequestContext, що й для REST.
import { test, expect, APIRequestContext } from '@playwright/test';
// GraphQL-запит — це звичайний POST; змінні виносимо в окремий об'єкт
async function postQuery(request: APIRequestContext, query: string, variables = {}) {
const res = await request.post('/graphql', { data: { query, variables } });
expect(res.status()).toBe(200); // легасі-режим: це «сервер живий», а НЕ «операція вдалася»
return res.json();
}
test('позитив: немає errors, дані на місці', async ({ request }) => {
const body = await postQuery(
request,
`query GetUser($id: ID!) { user(id: $id) { id email } }`,
{ id: '42' },
);
expect(body.errors).toBeUndefined(); // ось справжній оракул успіху
expect(body.data.user.email).toBe('user@example.com');
});
Негативні сценарії розводять дві різні межі «неправильно». Перша — запит невалідний за схемою: резолвери не запускаються, data немає взагалі. Приклад нижче написаний під легасі-режим (звідси 200 у хелпері); сервер, що відповідає GraphQL over HTTP, на непройдену валідацію віддасть 422, і хелпер довелося б послабити до res.ok().
test('валідація: невідоме поле — data відсутнє повністю', async ({ request }) => {
// поля phone у типі User немає — помилка ВАЛІДАЦІЇ, не резолвера
const body = await postQuery(request, `{ user(id: "42") { phone } }`);
expect(body.errors?.[0].message).toContain('Cannot query field');
expect(body.data).toBeUndefined(); // виконання не почалось — ключа data немає
});
Друга — той самий валідний запит, але під користувачем, який не має права читати чужий email: резолвер кидає помилку доступу, отримуємо часткові дані й асертимо path.
test('резолвер: немає прав — data з діркою + errors[].path', async ({ request }) => {
// request тут авторизований користувачем БЕЗ права читати email іншого
const body = await postQuery(
request,
`query($id: ID!) { user(id: $id) { id email } }`,
{ id: '42' },
);
expect(body.data.user.id).toBe('42'); // сусіднє поле резолвнулось
expect(body.data.user.email).toBeNull(); // проблемне поле — null
expect(body.errors?.[0].path).toEqual(['user', 'email']); // локалізація провалу
expect(body.errors?.[0].extensions?.code).toBe('FORBIDDEN');
});
Що дивитися і чому:
expect(res.status()).toBe(200)лишається, але як перевірка транспорту в легасі-режимі. Вона каже, що запит доїхав; успіх операції визначаєerrors/data. Плутати ці дві ролі статусу — головна пастка новачка в GraphQL; а перед тим, як зашивати200в хелпер, зʼясуй режим свого сервера.- Ознака валідації — відсутнє
data(toBeUndefined), а неnull. Саме це відрізняє провал схеми від провалу резолвера. Якщо асертитиtoBeNull, тест збреше про природу помилки. - При помилці резолвера перевіряй
dataйerrorsразом. Дивитися лише наdata.user.id— і пропустишemail: null; дивитися лише наerrors— і не побачиш, що сусідні поля резолвнулися.errors[].pathприв'язує помилку до конкретного поля. - Негативний тест, що чекає на
500, у легасі-режимі не спрацює. «Сервер упав» і на рівні поля приїжджають як200із записом уerrors. А от відмова цілому запиту — інша річ: специфікація велить віддати401,403або подібний код, тож перед асертом на статус розділяй ці два випадки.
Кейс 3. Куди заводити дефект: таблиця рішень
Одна й та сама зовнішня ознака — «у відповіді є errors» — приховує різні класи багів. Перш ніж писати баг-репорт, треба визначити, на якому етапі впало: це відповідає на питання, чий це дефект.
| Симптом у відповіді | Етап | Клас проблеми | Куди дивитись |
|---|---|---|---|
errors є, data відсутнє повністю | Валідація за схемою | Клієнт / контракт | Фронт шле застаріле чи криве поле; звір запит зі схемою |
errors є, data з дірками (null у полі) | Виконання резолвера | Бекенд / дані / права | Резолвер поля з path — не знайшов запис, відмова, впав на БД |
errors немає, але дані логічно абсурдні | Виконання пройшло | Бізнес-логіка | Схема пропустила за типами (total: -100), правило не перевірене |
errors про глибину/вартість запиту | До виконання | Захист сервера | Depth limiting / complexity відбив важкий запит — часто очікувано |
| Раптова деградація на вкладеному списку | Виконання | Продуктивність (N+1) | Наївний резолвер робить 1 + N звернень; лікує батчинг |
Як цим користуватись:
dataвідсутнє чи з дірками — це різні адресати дефекту. Відсутнєdataозначає, що виконання не почалося: винен запит клієнта або зміна контракту, а не резолвер. Дірка вdataозначає, що резолвер конкретного поля (дивисьpath) упав уже під час виконання — це бекенд або дані.errorsнемає, а результат абсурдний — це теж дефект, і найпідступніший. Схема гарантує лише форму й типи, не бізнес-правила. Валідний за схемоюcreateOrder(total: -100)пройде всі етапи чисто, тож такий тест мусиш проєктувати свідомо — асерт на саме значення, а не на наявністьerrors.- Помилку глибини/вартості не плутай із багом. Якщо сервер відбив надто глибокий запит осмисленою помилкою — це захист спрацював правильно; баг був би, якби такий запит поклав сервіс. Межі глибини й вартості — частина контракту, варта окремого позитивного й негативного тесту.
- N+1 не видно в тілі — його видно в часі. Відповідь коректна,
errorsнемає, але запит зі списком і вкладеними полями драматично повільніший за очікуване — перша гіпотеза N+1. Це не функціональний баг конкретної відповіді, а клас деградації під навантаженням.
Статус і тіло відповіді
- Знаю, що в GraphQL один (
POST /graphql), а що робити описано в тілі запиту, а не в URL і методі. - Знаю, що в легасі-режимі
application/jsonстатус лишається200навіть при помилці, тож успіху — відсутністьerrorsі наявність очікуванихdata, а неstatus === 200. - Пам'ятаю, що сервер за специфікацією мусить підтримувати медіа-тип
application/graphql-response+json, де статуси нормативні (400— не розібрано документ запиту,422— не пройдено валідацію,405— мутація черезGET), а сервер без нього специфікації не відповідає.
Часткові дані й масив errors
- Знаю, що має два головні поля —
dataйerrors(плюс опційнеextensions); вони можуть бути присутні одночасно (partial data), бо резолвери виконуються поле за полем, тожerrorsперевіряю завжди, навіть колиdataзаповнене. - Розрізняю
nullчерез реальну відсутність значення іnullчерез помилку резолвера — розводить їх запис уerrorsіз відповіднимpath;extensions.codeстабільніший для , ніжmessage, алеextensionsне гарантоване специфікацією. - Розумію, що
data: nullцілком означає «виконання почалося, але валідної відповіді не вийшло»; провал non-null поля, що сплив угору по дереву до кореня, — один зі шляхів туди.
Валідація vs логіка резолверів
- Можу пояснити межу між помилкою валідації (до виконання,
dataвідсутнє зовсім, резолвери не викликаються) і помилкою виконання резолвера (partial data:dataз дірками плюсerrors). - Розрізняю в баг-репорті проблему клієнта/контракту (валідація) і проблему бекенду/даних (резолвер).
- Пам'ятаю, що схема гарантує лише форму й типи, а не бізнес-правила: валідний за схемою запит легко несе логічний баг (
total: -100).
Схема, SDL та інтроспекція
- Розумію, що контракт GraphQL вбудований у сервіс — це схема мовою SDL, а не окрема специфікація як OpenAPI в REST; читаю типізацію (
ID!— обов'язкове,[Order!]!— обов'язковий масив із non-null елементами). - Знаю, що розбіжність nullability між схемою й реальною відповіддю (
ordersставnull) — це баг контракту. - Розумію, що таке (
__schema,__type) і як на ній тримаються GraphiQL, автодоповнення й генерація типів. - Можу пояснити, чому інтроспекцію часто вимикають на проді, і знаю рецепт: тримати еталонний SDL-файл у репозиторії, а не тягнути схему із середовища щоразу.
Форма відповіді: over-/under-fetching
- Можу пояснити over-fetching (зайві поля) і under-fetching () як болі REST, які й породили GraphQL.
- Розумію, що форму відповіді задає запит: немає сенсу перевіряти «повний об'єкт», а та сама сутність із різним набором полів — це різні перевірки з різними правами доступу.
Глибина, складність, N+1
- Знаю про depth limiting і query complexity/cost analysis як захист від зловмисно важких запитів і частину контракту, варту негативного тесту.
- Можу описати симптом N+1 (
1 + Nзвернень до БД на список) і його лікування батчингом через DataLoader.
Інструменти
- Розумію, що логіка перевірки та сама скрізь — читаємо
dataйerrors(GraphiQL/Apollo Sandbox — розвідка схеми, Postman/Insomnia/Bruno — ручні перевірки, код — автотести), а GraphQL-запит у коді — звичайнийPOSTз тілом{ query, variables }, без спеціального клієнта. - Виношу змінні в окремий об'єкт
variables, а не вклеюю значення в текст запиту рядковою конкатенацією.
Квіз
Перед стартом
- Питань: 16
- Поріг «зараховано»: ≥70% правильних відповідей.
- Результат впливає на прогрес; завалені питання підуть у чергу повторення.
- Квіз впливає на компліт теми: тема стає «пройдено», лише коли прочитано теорію І квіз складено на ≥70%.
Питання
Чому статус-код у GraphQL — не оракул успіху?