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

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

    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.

    синтаксична помилка

    невідоме поле, кривий тип

    резолвер кинув помилку

    POST /graphql

    Парсинг: синтаксис GraphQL

    errors, data ВІДСУТНЄ
    резолвери не викликались

    Валідація за схемою:
    поля, типи аргументів, форма

    Виконання резолверів

    data з дірками + errors
    partial data

    повні data, errors немає

    синтаксична помилка

    невідоме поле, кривий тип

    резолвер кинув помилку

    POST /graphql

    Парсинг: синтаксис GraphQL

    errors, data ВІДСУТНЄ
    резолвери не викликались

    Валідація за схемою:
    поля, типи аргументів, форма

    Виконання резолверів

    data з дірками + errors
    partial data

    повні 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.