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

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

    API-автотести в коді: клієнти, структура, ланцюжки

    Зміст

    Postman і Newman добре тримають першу сотню запитів. Але коли перевірок стають тисячі, коли їх треба ганяти в CI на кожен PR, перевикористовувати авторизацію, ділити тестові дані між прогонами й читати падіння в трейсі — колекція перетворюється на клубок скриптів, який ніхто не наважується чіпати. Тут API-тести переїжджають у код: той самий мовний стек, той самий репозиторій і той самий рев'ю-процес, що й у продукту.

    Ця глава — про інженерію API-тестів, а не про тест-дизайн (техніки — у главі про тест-дизайн для API) і не про мову як таку (JS/TS — окремий розділ). Питають це на співбесідах на middle-позиції багато й прицільно: «чим ти ходиш в API з коду», «як готуєш стан», «чому fetch не впав на 500», «ретраї — це добре чи погано». Відповіді відрізняють того, хто писав автотести руками, від того, хто копіпастив приклади. (Передумова: базовий JS/TS.)

    HTTP-клієнти: чим ходити в API

    «HTTP-клієнт» — це бібліотека, що вміє відправити запит і повернути відповідь як об'єкт, з яким працює твій код. Вибір клієнта — не питання смаку: він визначає, як ти обробляєш помилки, таймаути й серіалізацію. Чотири інструменти, які реально зустрічаються в JS/TS-проєктах:

    КлієнтДе живеКидає на 4xx/5xxТиповий контекст
    fetchВбудований у браузер і в Node.js (з 18-ї версії)НіЛегкі перевірки без залежностей
    axiosЗовнішня бібліотекаТак (за замовчуванням)Окремі API-сюїти, багато проміжної логіки
    APIRequestContextPlaywrightНіAPI-тести й підготовка стану поруч з e2e
    supertestЗовнішня бібліотекаНі (асертиться окремо)Тестування свого сервера в процесі

    Ключова відмінність, яку люблять питати: fetch не вважає HTTP-помилку помилкою. Проміс fetch відхиляється лише на мережевому збої (DNS не резолвиться, з'єднання обірвалось). Статус 404 чи 500 — це успішно виконаний запит, у якого просто response.ok === false. Тому наївний код нижче ловить не те, що здається:

    // Пастка: catch НЕ спрацює на 500 — fetch вважає це успіхом
    try {
      const res = await fetch('https://api.example.com/orders');
      const body = await res.json();
    } catch (e) {
      // сюди зайдемо лише на розриві мережі, а не на 5xx
    }

    axios поводиться протилежно: на будь-який статус поза діапазоном 2xx він відхиляє проміс (це регулюється опцією validateStatus). Зручно для продакшн-коду, але для тестів радше заважає — ти й так хочеш перевіряти статус явно, а не через try/catch. APIRequestContext (фікстура request у Playwright) поводиться як fetch: помилка HTTP — це нормальна відповідь, яку ти асертиш. Його бонус — інтеграція з рештою Playwright: власний baseURL, запис запитів у трейс і спільне сховище cookie з браузерним контекстом через page.request/context.request (зручно для «залогінься через API, а перевіряй в UI»); сама фікстура request — ізольований контекст, cookie сторінки вона не бачить.

    supertest стоїть окремо: він призначений тестувати HTTP-сервер у тому ж процесі, піднімаючи його на ефемерному порту. Ти передаєш йому свій застосунок (наприклад, Express) — це швидко й без мережі, але працює лише коли сервер під рукою в коді. Для тестування задеплоєного API по URL він не потрібен.

    Практичний висновок: для більшості продуктових API-сюїтів, що ходять по мережі до реального середовища, природний вибір — APIRequestContext (якщо стек уже на Playwright) або axios/fetch у самостійній сюїті. supertest — це про компонентні тести бекенду зсередини.

    Анатомія API-тесту: arrange–act–assert

    Структура тесту не залежить від того, UI це чи API. Канон — arrange–act–assert (AAA), «підготуй — дій — перевір» (детально — у розділі про архітектуру автотестів). Для API три фази читаються так:

    • Arrange — усе, що має існувати до головного запиту: токен авторизації, створений через API ресурс, вихідний стан бази. Це не предмет перевірки, а декорації сцени.
    • Actрівно один запит, поведінку якого перевіряє тест. Якщо в тесті два «головні» запити — це два тести.
    • Assert — перевірки відповіді: статус, тіло, заголовки, відповідність схемі (повний чек-лист — у главі про перевірки відповіді).
    test('POST /orders створює замовлення й повертає 201 зі схемою Order', async ({ request }) => {
      // Arrange
      const token = await auth.loginAs('customer');
    
      // Act
      const res = await request.post('/orders', {
        headers: { Authorization: `Bearer ${token}` },
        data: { productId: 42, qty: 2 },
      });
    
      // Assert
      expect(res.status()).toBe(201);
      const body = await res.json();
      expect(body).toMatchObject({ productId: 42, qty: 2, status: 'created' });
    });

    Два інваріанти, які роблять тест придатним до життя. По-перше, назва описує перевірку, а не дію: «повертає 201 зі схемою Order», а не «тест POST orders» — тоді червоний тест у звіті вже сам розповідає, що зламалось. По-друге, тест не залежить від порядку: він сам готує свій стан і не розраховує, що попередній тест щось лишив. Це передумова для паралельного запуску, до якого ми ще дійдемо.

    Патерн API-клієнта

    Якщо в кожному тесті писати сирий request.post('/orders', { headers: ... }), то заголовок авторизації, базовий шлях і розбір відповіді розповзуться копіпастою по всій сюїті. Змінився формат авторизації — правиш у ста місцях. Розв'язання те саме, що Page Object дає для UI: патерн API-клієнта — клас, який ховає ендпоінт за методом з людською назвою.

    class OrdersApi {
      constructor(private request: APIRequestContext, private token: string) {}
    
      createOrder(data: OrderInput) {
        return this.request.post('/orders', {
          headers: { Authorization: `Bearer ${this.token}` },
          data,
        });
      }
    
      getOrder(id: string) {
        return this.request.get(`/orders/${id}`, {
          headers: { Authorization: `Bearer ${this.token}` },
        });
      }
    }

    Тепер тест читається як сценарій, а не як HTTP-механіка: await orders.createOrder({ productId: 42, qty: 2 }). Базовий URL, авторизація, серіалізація живуть в одному місці — це єдине джерело правди (принципи дизайну тестового коду — канон розділу про архітектуру). Клієнт віддає «сиру» відповідь, а перевірки лишаються в тесті: змішувати асерти всередину клієнта — спокуса, яка перетворює його на god object і ховає, що саме перевіряється.

    Тест
    arrange · act · assert

    API-клієнт
    ендпоінти як методи

    HTTP-клієнт
    fetch / axios / request

    Сервер під тестом

    Тест
    arrange · act · assert

    API-клієнт
    ендпоінти як методи

    HTTP-клієнт
    fetch / axios / request

    Сервер під тестом

    Кожен шар знімає з тесту одну відповідальність: HTTP-клієнт — про транспорт, API-клієнт — про словник ендпоінтів, тест — лише про перевірку. Ту саму ідею потім перевикористовуєш і для підготовки даних в e2e-тестах.

    Ланцюжки й підготовка стану через API

    Багато перевірок не мають сенсу «на порожньому місці»: щоб перевірити GET /orders/{id}, замовлення спершу треба створити; щоб перевірити скасування — воно має існувати й бути в правильному стані. Так виникають ланцюжки (chaining): відповідь одного запиту дає вхід для наступного. Класика — витягнути id зі створеного ресурсу й підставити його в наступний виклик.

    Головне правило: готуй стан найдешевшим надійним способом, а не через UI. Якщо тесту потрібне замовлення, не клацай його майстром у браузері — створи запитом. API-сідінг на порядок швидший і стабільніший за клікання, а тому не додає флаку до сценарію, який насправді про інше. (Стратегія тестових даних і стану — окрема глава цього розділу; канон стратегії даних — у розділі про архітектуру.)

    APIТестAPIТестArrange — підготовка стануAct — те, що перевіряємоAssertTeardownPOST /orders {productId, qty}201, {id}POST /orders/{id}/cancel200, {status: "cancelled"}GET /orders/{id}200, {status: "cancelled"}DELETE /orders/{id}APIТестAPIТестArrange — підготовка стануAct — те, що перевіряємоAssertTeardownPOST /orders {productId, qty}201, {id}POST /orders/{id}/cancel200, {status: "cancelled"}GET /orders/{id}200, {status: "cancelled"}DELETE /orders/{id}

    Два підводні камені ланцюжків. Перший — прибирання (teardown): те, що тест насіяв, він і має прибрати, інакше середовище засмічується й сусідні прогони починають бачити чужі дані. Надійно вішати клінап на after-хук, щоб він відпрацював навіть коли основна перевірка впала. Другий — eventual consistency: у розподілених системах створений ресурс не завжди видно наступним запитом миттєво (репліка ще не наздогнала). Лікується це не sleep-ом навмання, а полінгом із таймаутом — до цього повернемось нижче й детально в главі про стабільність.

    Валідація схем: ajv і zod

    Перевіряти тіло відповіді пополе (expect(body.id).toBeDefined()) виснажливо й дірчасто: забув поле — пропустив регресію. Надійніше перевірити всю форму відповіді одним асертом проти схеми. Два робочі інструменти в JS/TS:

    • ajv — валідатор JSON Schema. Ти описуєш очікувану структуру мовою JSON Schema, ajv компілює її у швидку функцію-перевіряч і повертає список розбіжностей. Головна вигода — JSON Schema не залежить від мови, тож ту саму схему можна взяти прямо з OpenAPI-специфікації і фактично перетворити доку на тест (в OpenAPI 3.1 схеми — це повноцінна JSON Schema; у 3.0 — близький діалект, дрібниці на кшталт nullable доведеться конвертувати).
    • zod — TS-first бібліотека: схему описуєш кодом на TypeScript, а parse перевіряє дані в рантаймі. Бонус — з тієї ж схеми виводиться статичний тип, тож після валідації ти працюєш із типізованим об'єктом без ручних as.
    import { z } from 'zod';
    
    const Order = z.object({
      id: z.string().uuid(),
      productId: z.number().int(),
      qty: z.number().int().positive(),
      status: z.enum(['created', 'paid', 'cancelled']),
    });
    
    const body = await res.json();
    const order = Order.parse(body); // кине, якщо форма не збіглась; далі order типізований

    Груба евристика вибору: якщо схеми вже є в OpenAPI й хочеться однієї правди для доки й тестів — ajv; якщо тести пишуться на TS з нуля й потрібні статичні типи — zod. Важлива дисципліна: за замовчуванням забороняй зайві поля (в JSON Schema — additionalProperties: false, у zod — .strict(), а в Zod 4 те саме дає z.strictObject()), інакше нове несподіване поле у відповіді проскочить повз перевірку. Схема ловить структурні регресії дешево — контрактне тестування розвиває цю ідею далі в окремій главі.

    Таймаути й ретраї

    Таймаут відповідає на питання «як довго чекати на відповідь, перш ніж вважати запит провальним». Без нього завислий запит підвісить весь прогон, поки в справу не втрутиться глобальний таймаут ранера з незрозумілою помилкою. Тому таймаут ставлять свідомо — і на рівні клієнта (окремий запит), і на рівні тесту. Значення обирають з голови сервісу: занизьке породжує флак на повільному CI, зависоке ховає деградацію продуктивності.

    Ретрай (retry) — це автоматичний повтор запиту. І це найтонше місце теми, бо ретрай легко перетворюється на килим, під який замітають баги. Розрізняй три різні речі, які часто плутають:

    • Мережевий ретрай на ідемпотентний запит. Повторити GET, що впав через скидання з'єднання, — безпечно й нормально: повтор ідемпотентного запиту лишає стан сервера таким, яким його зробив перший виклик (а GET його й так не змінює). Повторювати ж POST, який щось створює, наосліп — небезпечно: ризикуєш дублем (тут рятує Idempotency-Key, якщо API його підтримує).
    • Полінг для eventual consistency. Коли даних ще немає, бо система не встигла узгодитись, — це не ретрай запиту, а очікування стану: періодично перепитувати until-умову з таймаутом. У Playwright для цього є expect.poll. Це легітимний патерн, а не маскування.
    • Ретрай усього тесту, що впав. Ось це — червоний прапорець. Якщо тест «зеленіє з другого разу», ретрай не полагодив причину, а сховав флак. Ретраї на рівні ранера доречні як тимчасовий буфер і сигнал («цей тест нестабільний»), але не як лікування (політика ретраїв і карантину — канон розділу про архітектуру; API-специфіка — глава про стабільність).

    Одним рядком: таймаут і полінг лікують чесну асинхронність; сліпий ретрай мовчки ховає баг.

    Паралельний запуск

    API-тести швидкі (немає браузера й рендеру), тож їх ганяють десятками воркерів одночасно — прогін падає з десятків хвилин до одиниць. Але паралельність безжально карає за одну річ: спільний стан. Два воркери, що правлять один ресурс, дають гонку, і тест «іноді падає» без видимої причини.

    Єдина передумова безпечної паралельності — ізоляція даних. Кожен тест працює зі своїми даними: унікальний email через генератор, окреме замовлення, а краще — окремий акаунт чи tenant на воркер. Антипатерн — один спільний тестовий юзер на всю сюїту: щойно тести стартують паралельно, вони стають у чергу за його станом і починають перетирати одне одному дані. (Концепція паралелізації й ізоляції — канон розділу про архітектуру; тут — лише API-проєкція.)

    Практична дрібниця, що рятує нерви: якщо API віддає глобальний лічильник, послідовні id чи спільний кошик — закладай це в дизайн даних одразу, бо в паралелі такі місця «стріляють» першими.

    Типові помилки

    • Виглядає як «тест ловить помилку сервера», а насправді fetch мовчки її проковтнув. fetch і APIRequestContext не кидають на 4xx/5xx — статус треба перевіряти явним асертом, а не сподіватись на catch.
    • Виглядає як стабільна перевірка тіла, а насправді дірка в покритті. Перевірка кількох полів пропускає зникнення чи зміну типу інших. Асерть всю форму проти схеми — і забороняй зайві поля.
    • Виглядає як «баг у продукті — ресурс не з'явився», а насправді eventual consistency. Наступний запит прийшов раніше, ніж репліка узгодилась. Лікується полінгом із таймаутом, а не sleep(3000).
    • Виглядає як стабілізація завдяки ретраям, а насправді замаскований флак. Тест, що зеленіє з другої спроби, досі зламаний — ти лише перестав це бачити у звіті.
    • Виглядає як незалежні тести, а насправді прихована залежність через дані. Один спільний акаунт чи послідовність тестів «А створює — Б читає» валиться, щойно вмикається паралель.
    • Виглядає як зручний API-клієнт, а насправді god object. Клієнт із асертами всередині ховає, що саме перевіряється, і плутає транспорт з перевіркою. Перевірки — у тесті.

    Підсумок

    • HTTP-клієнти діляться за поведінкою на помилку: fetch і APIRequestContext не кидають на 4xx/5xx, axios кидає за замовчуванням; supertest — про тестування свого сервера в процесі.
    • Структура API-тесту — arrange–act–assert: один головний запит на тест, стан готується заздалегідь, назва описує перевірку.
    • Патерн API-клієнта ховає ендпоінти, авторизацію й базовий URL за методами; перевірки лишаються в тесті, не в клієнті.
    • Стан готуй через API, не через UI; ланцюжки прибирай за собою в after-хуці; eventual consistency лікуй полінгом, а не паузами.
    • Валідація проти схеми (ajv або zod) ловить структурні регресії одним асертом; забороняй зайві поля.
    • Паралельність безпечна лише за ізоляції даних; сліпий ретрай усього тесту маскує флак, а не лікує його.

    Що питають на співбесіді

    • «Чим ти ходиш в API з коду і чому саме цим?» — інтерв'юер перевіряє, чи є реальний досвід за назвами бібліотек. Сильна відповідь називає 2–3 клієнти й пояснює компроміс, а не хвалить один.
    • «Чому fetch не впав на статусі 500?» — класична пастка на розуміння, що HTTP-помилка ≠ помилка виконання запиту. Хто плутає, той писав тільки happy-path.
    • «Як готуєш тестові дані для API-тесту?» — дивляться, чи згадаєш API-сідінг замість UI, ізоляцію й прибирання. Відповідь «створюю руками в базі» насторожує.
    • «Ретраї в тестах — це добре чи погано?» — перевірка зрілості. Очікують розрізнення: мережевий ретрай ідемпотентного запиту й полінг стану — ок; ретрай усього тесту, що падає, — маскування флаку.
    • «Що потрібно, щоб запускати API-тести паралельно?» — ключове слово «ізоляція даних»; згадка про спільний акаунт як антипатерн — сильний сигнал.
    • «Як перевіряєш тіло відповіді?» — очікують згадку про валідацію схеми (JSON Schema/ajv або zod) і заборону зайвих полів, а не лише expect по кількох полях.

    Джерела

    • MDN — Using the Fetch API (поведінка fetch, чому HTTP-помилки не відхиляють проміс).
    • Playwright — API testing і APIRequestContext.
    • Ajv JSON Schema validator — офіційна документація.
    • Zod — офіційна документація.
    • ISTQB Foundation Level 4.0, розділ 6 «Test Tools» — місце інструментів автоматизації в процесі (силабус загальний, не про конкретні бібліотеки).