GraphQL: особливості тестування
Зміст
REST привчив нас читати відповідь за статус-кодом: 200 — успіх, 404 — не знайшли, 400 — крива форма запиту. GraphQL цю звичку ламає. Той самий ендпоінт /graphql, той самий метод POST, і майже завжди 200 OK — навіть коли всередині лежить помилка «поле не знайдено» або «немає доступу». Автотест, який перевіряє тільки статус, тут зелений завжди й не ловить нічого.
Для QA це означає одне: центр ваги перевірки зміщується зі статус-рядка в тіло відповіді. Треба навчитися читати конверт (envelope) із двома полями — data і errors, розуміти, чому вони бувають одночасно, і де проходить межа між «запит невалідний за схемою» та «резолвер (resolver) упав на бізнес-логіці». Ця глава — про те, чим тестування GraphQL відрізняється від REST і які пастки чекають на того, хто переносить старі звички один-в-один. Основи перевірки тіла й «пастку 200 OK з помилкою» ми розбирали в главі «Перевірки відповіді» — GraphQL доводить цю пастку до крайності.
Один ендпоінт і майже завжди 200
У 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/403/500.
Чому так? Тому що GraphQL — це шар поверх HTTP, а не REST-семантика. Один запит може одночасно зачепити десять полів, з яких дев'ять успішні, а одне впало. Який тоді статус ставити на всю відповідь? Специфікація вирішує цю дилему просто: транспорт (HTTP) відповідає за доставку, а прикладні помилки живуть у тілі, у полі errors.
Практичний наслідок для автотесту: 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, де сервер уже може повертати 4xx ще до виконання — наприклад, 400, коли не зміг розібрати запит, чи 422 на запит, що не пройшов валідацію. Тому правильна звичка — не «статус завжди 200», а «статус у GraphQL ненадійний як оракул; перевіряй тіло». Який саме режим у вашого сервера — з'ясовується один раз на початку роботи з API.
Масив 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 цілком (а не окреме поле). Це означає, що впало поле, яке не дозволяє null (non-null), і помилка «спливла» вгору по дереву аж до кореня. Для негативних сценаріїв це нормальний результат; головне — перевіряти й data, і errors разом, а не окремо. Загальні принципи негативних перевірок ми розбираємо в главі «Негативні перевірки й обробка помилок» — GraphQL лише міняє, де саме шукати ознаку провалу.
Валідність запиту vs логіка резолверів
Це найважливіша межа теми, і саме її люблять питати. У GraphQL є два принципово різні рівні «неправильно», і плутати їх не можна.
Перш ніж виконати запит, сервер проганяє його через два етапи: парсинг (чи це взагалі синтаксично коректний GraphQL) і валідацію за схемою (чи існують запитані поля, чи правильні типи аргументів, чи не порушені обов'язковість і форма). Якщо запит не проходить ці етапи — жоден резолвер не викликається взагалі. Відповідь містить errors, а data немає зовсім (не null, а відсутнє — так приписує специфікація, і так поводяться типові сервери).
// поля phone у типі User немає — це помилка ВАЛІДАЦІЇ
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 waterfall).
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 } });
expect(res.status()).toBe(200);
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і радіє. У GraphQL це майже завжди правда — навіть коли в тіліerrorsіdata: null. Перевіряй тіло, а не статус. - Виглядає як «поле повернуло null через баг даних», а насправді резолвер цього поля впав.
nullуdataбуває від реальної відсутності значення й від помилки резолвера — це різні речі. Розводить їх полеerrors[].path: воно показує саме те поле, що зламалося. - Виглядає як помилка бекенду, яку ловитимемо за 5xx, а насправді GraphQL віддав 200. «Сервер упав» у GraphQL приїжджає як
200із записом уerrors. Негативний тест, що чекає на500, тут ніколи не спрацює. - Виглядає як «схема гарантує коректність», а насправді лише форму й типи. Валідний за схемою запит легко несе логічний баг (від'ємна сума, чужий
id). Схема — про типи й nullability, бізнес-правила перевіряють резолвери й твої тести. - Виглядає як «інтроспекція є завжди», а насправді на проді її часто вимикають. Інструмент, що будує тести зі схеми через інтроспекцію, зелений на staging і падає на проді. Тримай еталонну схему у репозиторії або передбач цей режим.
- Виглядає як «один ендпоінт — один тест». Насправді форму відповіді задає запит: та сама сутність із різним набором полів — це різні перевірки з різною логікою резолверів і різними правами доступу.
Підсумок
- Статус-код у GraphQL — не оракул успіху. Майже завжди
200; ознака успіху/провалу живе в тілі: якщо єerrors— щось не так, навіть при200. dataйerrorsспівіснують: часткові дані — штатна поведінка. Дивисьerrors[].path, щоб точно знайти поле, яке впало.- Є дві різні межі «неправильно»: невалідний за схемою запит (резолвери не запускаються,
dataнемає) і помилка виконання резолвера (dataз дірками +errors). Не плутай їх у баг-репортах і в тест-дизайні. - Схема — контракт про типи, форму й nullability, але не про бізнес-логіку. Валідність запиту ≠ правильність резолвера. Інтроспекція дає схему в рантаймі, але на проді її часто вимикають.
- Клієнт сам обирає поля (кінець over-/under-fetching), натомість з'являються серверні ризики глибини/складності й N+1 — окремі класи перевірок. Інструмент вторинний: логіка перевірки та сама скрізь.
Що питають на співбесіді
- «Чим тестування GraphQL відрізняється від REST?» — базове питання на розуміння. Інтерв'юер слухає, чи назвеш один ендпоінт замість багатьох маршрутів,
200+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 Specification — канонічна специфікація: формат відповіді (
data/errors), валідація, виконання, інтроспекція. - GraphQL over HTTP — як GraphQL лягає на HTTP: медіа-типи, статус-коди, коли сервер може відповісти не
200. - graphql.org/learn — офіційний навчальний розділ: запити, змінні, схема й типи, мутації.
- graphql.org/learn/introspection — інтроспекція та мета-поля
__schema/__type.
Чим тестування GraphQL відрізняється від REST?
Головна відмінність — де живе оракул успіху. У REST кожен ресурс має свій URL і метод, а статус-код відповіді каже долю запиту: 200 — успіх, 404 — не знайшли, 400 — крива форма. У GraphQL є один ендпоінт (зазвичай POST /graphql), а що саме робити описано в тілі запиту; транспортний статус майже завжди 200, навіть коли всередині помилка. Тому головні перевірки переїжджають у тіло відповіді: долю запиту читаємо з полів data й errors. Плюс до цього схема (schema) стає вбудованим контрактом, а over-/under-fetching зникає, бо клієнт сам перелічує потрібні поля. Відбутися фразою «це теж API поверх HTTP» не вийде — інтерв'юер чекає саме цих чотирьох відмінностей.
GraphQL повернув 200 — тест пройшов?
Ні, 200 каже лише, що запит доїхав до сервера й той його опрацював, а не що операція вдалася. У класичному транспорті з медіа-типом application/json GraphQL відповідає 200 навіть на «поле не знайдено», «немає прав» чи «резолвер упав» — прикладна помилка приїжджає в тілі, у полі errors. Тому асерт 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 — навпаки, одного запиту мало, і щоб зібрати екран, доводиться робити ланцюжок звернень (класичний «водоспад запитів»). 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' }) замість рядкової конкатенації в текст запиту треба з кількох причин: так робить реальний клієнт, код читається чистіше, і зникають проблеми з екрануванням (лапки, спецсимволи в рядкових значеннях). Вклеювання значень у текст запиту крихке й нагадує SQL-ін'єкцію за стилем помилок. Тому в автотесті 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, а операція не вдалася повністю. GraphQL віддає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 немає взагалі.
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.- Ознака валідації — відсутнє
data(toBeUndefined), а неnull. Саме це відрізняє провал схеми від провалу резолвера. Якщо асертитиtoBeNull, тест збреше про природу помилки. - При помилці резолвера перевіряй
dataйerrorsразом. Дивитися лише наdata.user.id— і пропустишemail: null; дивитися лише наerrors— і не побачиш, що сусідні поля резолвнулися.errors[].pathприв'язує помилку до конкретного поля. - Негативний тест, що чекає на
500/403, у GraphQL не спрацює. «Сервер упав» і «немає прав» приїжджають як200із записом уerrors, а не як5xx/4xx. Асерт на статус-код помилки тут завжди червоний або завжди зелений — обидва варіанти безкорисні.
Кейс 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 і методі. - Знаю, що статус майже завжди
200навіть при помилці, тож оракул успіху — відсутністьerrorsі наявність очікуванихdata, а неstatus === 200. - Пам'ятаю про медіа-тип
application/graphql-response+json, де сервер уже може віддати4xx/5xx, тож правильна звичка — «перевіряй тіло», а не «статус завжди 200».
Часткові дані й масив 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, а не вклеюю значення в текст запиту рядковою конкатенацією.
GraphQL повернув 200 OK. Що це достеменно означає для автотесту?
Питання
Чому статус-код у GraphQL — не оракул успіху?