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

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

    Стабільність API-тестів: флак, асинхронність, дебаг

    Зміст

    Нічний прогон API- впав на трьох тестах. Вранці ти перезапускаєш — усі зелені. Код продукту той самий, ти нічого не міняв. Це (flaky test): тест, що на незмінному коді то падає, то проходить. На рівні API він особливо підступний, бо тут немає ні браузера, ні анімацій, ні рендерингу — зовні все виглядає як «простий HTTP-запит». Саме тому падінню часто не вірять: списують на «моргнула мережа» й тиснуть Retry. А під сподом ховається або реальний баг гонки в продукті, або хиба в тому, як тест синхронізується зі станом системи.

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

    П'ять хвилин на карту місцевості: джерела флаку

    Перш ніж лікувати, назви ворога. Канонічний перелік джерел недетермінізму — брак ізоляції, асинхронність, віддалені сервіси, час і витоки ресурсів. На рівні API вони проявляються здебільшого через чотири практичні групи, кожна зі своїм характерним почерком (і перелік цей не закритий: час і витоки ресурсів у нього не вкладаються).

    ДжерелоТиповий симптомКуди копати
    ДаніТест «іноді» бачить не те, що створив, або спирається на запис, якого вже немаІзоляція, унікальні дані, власність над станом
    Гонки (асинхронність)Створене одразу не читається; 202 замість 201; порядок кроків «плаває»Полінг на умову замість sleep
    ОточенняПадає лише в CI, лише на спільному стенді, лише під навантаженнямМережа, сусіди по стенду, залежні сервіси
    ЛімітиРаптові 429, обриви з'єднань, таймаути під час паралельного прогонуRate limiting, квоти, пули з'єднань

    Дані й гонки — брати: обидва про стан, але з різних боків. Дані — це колізії (два тести чіпають той самий об'єкт) і хибні (тест припускає стан, якого нема). Гонки — це час: читання випереджає асинхронний запис. Повний розбір обох з боку стану — механіка кінцевої узгодженості, , ізоляція окремим акаунтом чи tenant — у главі Тестові дані та стан в API-тестах. Тут я не повторюю її, а спираюся на неї.

    Оточення — це все, що поза кодом продукту й поза кодом тесту: спільний staging, на якому мануальники правлять твої дані; мережа між CI-агентом і API; залежний сервіс, який ліг на 200 мілісекунд. Сюди ж варто свідомо додати два джерела з канонічного переліку, яких у чотирьох групах немає: час (логіка, привʼязана до дати або таймзони) і витоки ресурсів — незакриті зʼєднання чи дескриптори, які накопичуються за прогін; у цій главі витік зʼєднань згадується нижче ще й як баг продукту, але він так само є джерелом флаку. Ліміти — окремий, часто недооцінений клас: під час паралельного прогону сотні тестів б'ють по API одночасно й ловлять 429 Too Many Requests, якого поодинці ніколи не побачиш. Функціонально rate limiting і заголовок Retry-After розбирає глава Негативні перевірки й обробка помилок; тут важливий сам факт, що ліміти — джерело флаку, а не лише окрема перевірка.

    Полінг із таймаутом замість sleep

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

    Тактик тут дві. Перша й краща — : асинхронний сервіс сам повідомляє про завершення, і чекати доводиться рівно стільки, скільки треба. Але вона доступна не завжди: колбек має вміти й оточення, і сам постачальник сервісу. Тому на практиці частіше беруть другу — (polling): періодично перепитувати систему, доки справдиться умова, з верхнім . Чекаємо не на час, а на конкретний спостережуваний факт.

    // значення часу — з налаштувань, а не літералами в тесті
    const WAIT_LIMIT_MS = Number(process.env.WAIT_LIMIT_MS ?? 60_000);
    const POLL_INTERVALS_MS = [300, 600, 1200];
    
    // Чекаємо на бізнес-умову, а не на абстрактні секунди
    await expect.poll(async () => {
      const res = await request.get(`/api/jobs/${id}`);
      return (await res.json()).status;
    }, { timeout: WAIT_LIMIT_MS, intervals: POLL_INTERVALS_MS }).toBe('done');

    Три речі роблять полінг надійним: умова замість часу (статус 200, поле status: "done", поява елемента у списку), верхній таймаут (полінг без стелі — це вічне зависання, гірше за падіння) і ідемпотентна перевірка (функція всередині — тільки читання, жодних POST, інакше створиш десять замовлень за десять ітерацій). Дві поправки до самої стелі, які легко проґавити. По-перше, її призначення — не бюджет часу, а запобіжник: ліміт очікування варто ставити дуже високим, щоб він спрацьовував лише тоді, коли сталося щось справді серйозне. По-друге, часові значення — і насамперед цю стелю — ніколи не варто лишати літералами в коді: їх виносять у константи або в налаштування оточення, щоб міняти гуртом. Детальний розбір полінгу проти sleep з боку eventual consistency — у главі Тестові дані та стан; запам'ятай принцип: тест іде далі рівно тоді, коли факт настав, і падає, лише якщо його немає дуже довго.

    Ретраї: рятують чи маскують

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

    Що ретраїмо. Безпечно повторювати лише безпечні (safe) й ідемпотентні запити: GET, HEAD, а також PUT і DELETE, повтор яких за наміром семантики лишає систему в тому самому стані (сама відповідь може відрізнятися: повторний DELETE часто поверне 404). Дві поправки. Перша: POST і PATCH ідемпотентними не гарантовані — про PATCH забувають, хоча в API-тестах він трапляється не рідше за PUT. Друга: дотримання семантики лежить на сервері, тож конкретного — це те, що перевіряють, а не те, на що спираються наперед. Повторити POST наосліп — це створити дублікат: два замовлення, два платежі. Якщо POST треба захистити від повтору, це робить сам продукт через Idempotency-Key — тему ідемпотентності й конкурентності (concurrency) — коли запити перекриваються в часі й борються за спільний стан — розбирає глава Тест-дизайн для API.

    На що реагуємо. Ось де ретрай перетворюється з ліків на отруту. Легітимно повторювати запит на транзієнтному збої: обрив TCP-з'єднання, DNS-таймаут, 503 від балансувальника під час . Це збої інфраструктури, які не повторяться. Але ретраїти запит, що повернув осмислену помилку продукту500 через незловлений виняток, 409 через реальну гонку, 400 через невалідні дані — означає замести баг під килим. Класична пастка: запит на мережевому рівні кидає виняток, а бізнес-помилка 500 повертається як звичайна відповідь. Тому ретрай на рівні клієнта має ловити саме кинуті помилки транспорту, а не коди 4xx/5xx; якщо свідомо ретраїш і 503, перевіряй явно саме цей код, а не все сімейство 5xx:

    // Ретраїмо лише GET і лише на кинутих мережевих збоях
    async function getWithRetry(request, url, tries = 3) {
      for (let i = 0; i < tries; i++) {
        try {
          return await request.get(url); // 500 сюди НЕ кине — це відповідь
        } catch (err) {
          if (i === tries - 1) throw err; // спроби вичерпано — чесний фейл
          await new Promise((r) => setTimeout(r, 200 * (i + 1)));
        }
      }
    }

    Окремо стоїть ретрай усього тесту на рівні (у Playwright — retries у конфізі). Він доречний як тимчасова , але має власну ціну: зелений із другої спроби тест — це замаскований сигнал. Якщо не рахувати такі «flaky pass» окремо й не заводити їх у карантин, сюїта поступово бреше — показує зелене там, де живе реальна нестабільність або баг гонки. Політика ретраїв, карантин і метрики стабільності — канон розділу про стратегію автоматизації; клієнтські таймаути й ретраї в коді тесту — глава API-автотести в коді. Правило одне: ретрай — це спосіб пережити те, що ти вже зрозумів і свідомо визнав транзієнтним, а не спосіб не розбиратися.

    Дебаг: логи сервісу, correlation id, трейсинг

    UI-тест лишає по собі і відео. API-тест бачить лише відповідь — а вся правда про фейл живе на сервері. Тому дебаг API-флаку — це передусім уміння зв'язати конкретний впалий запит із конкретним слідом у логах сервісу.

    Ключ до цього зв'язку — correlation id (він же request id): унікальний ідентифікатор, який пронизує всю обробку одного запиту через усі сервіси. Умовних стандартів два рівні. Конвенційні заголовки на кшталт X-Request-Id чи X-Correlation-Id (не стандартизовані, назви залежать від команди) — сервіс генерує id на вхідний запит і пише його в кожен рядок логу. Формальний стандарт — W3C Trace Context із заголовком traceparent: єдиний формат, який розуміють різні системи , він і переносить ідентифікатор трейсу між сервісами. Формат жорсткий: чотири поля — версія, trace-id (32 шістнадцяткові символи нижнього регістру, самі нулі заборонені), parent-id (16 таких самих символів) і trace-flags; приклад — 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01. Тому власний ідентифікатор команди на кшталт r-42 у traceparent не вміщається за побудовою — він їде окремим заголовком (як X-Request-Id у прикладі вище), а якщо всунути його всередину, отримувач зобовʼязаний проігнорувати весь заголовок.

    Практика для тесту проста: дістань id з відповіді (або згенеруй свій і надішли), поклади його в повідомлення та в артефакти звіту. Тоді після падіння ти не гадаєш, а йдеш у логи з точним ключем пошуку.

    const res = await request.post('/api/orders', {
      data: order,
      headers: { 'X-Request-Id': `qa-${testInfo.testId}` },
    });
    const requestId = res.headers()['x-request-id'] ?? `qa-${testInfo.testId}`;
    // id у повідомленні асерту — щоб він потрапив у звіт про фейл
    expect(res.status(), `request-id=${requestId}`).toBe(201);
    Сервіс оплатСервіс замовленьAPI-gatewayТестСервіс оплатСервіс замовленьAPI-gatewayТестодин id пронизує всі сервіси — grep по r-42POST /orders (X-Request-Id: r-42)створити (X-Request-Id: r-42 + власний traceparent)списати кошти (той самий trace)помилка500500 (X-Request-Id: r-42)Сервіс оплатСервіс замовленьAPI-gatewayТестСервіс оплатСервіс замовленьAPI-gatewayТестодин id пронизує всі сервіси — grep по r-42POST /orders (X-Request-Id: r-42)створити (X-Request-Id: r-42 + власний traceparent)списати кошти (той самий trace)помилка500500 (X-Request-Id: r-42)

    Коли запит проходить через кілька сервісів, окремих логів уже мало — потрібен розподілений трейс (distributed tracing). Ідея: кожен крок обробки — це (span), а всі спани одного запиту складаються в трейс (trace) — дерево з таймінгами, яке показує, де саме час згорів і який сервіс повернув помилку. Відкритий стандарт для цього — OpenTelemetry, а візуалізують трейси системи на кшталт Jaeger. Для AQA це переважно навичка читання: за trace-id з відповіді відкрити трейс і побачити, що 500 прийшов не з сервісу замовлень, а з платіжного, який таймаутнув. Оглядово і трейсинг у розбирає розділ про Git і CI/CD; тут достатньо розуміти, що correlation id — це нитка, за якою розплутується будь-який розподілений фейл.

    Відтворення фейла: curl як спільна мова

    Флак, який ти не можеш відтворити, ти не можеш ні полагодити, ні довести розробнику. Тому перший крок після падіння — звести фейл до одного самодостатнього запиту, який запускається поза тестом. Універсальна для цього форма — curl: її розуміє і бекендер, і девопс, і ти сам через тиждень.

    Дисципліна проста: логуй кожен важливий запит так, щоб його можна було скопіювати як готовий curl — метод, повний URL, заголовки, тіло. Тоді відтворення фейла — це вставити рядок у термінал, а не реконструювати його з пам'яті.

    curl -i -X POST 'https://api.staging.example.com/v1/orders' \
      -H 'Authorization: Bearer <token>' \
      -H 'Content-Type: application/json' \
      -H 'X-Request-Id: repro-2026-07-17-001' \
      -d '{"sku":"A-100","qty":2}'

    Далі curl стає діагностичним інструментом. Запусти його кілька разів наосліп, у тому самому оточенні:

    • Падає стабільно — фейл детермінований. Це майже напевно баг продукту (або хибний асерт у тесті). Такий curl — ідеальний доказ у баг-репорті: розробник відтворює його одним рухом.
    • Проходить, а падає лише сюїта — винен не сам запит, а контекст: порядок тестів, спільні дані, гонка між . Копай усередину тесту, а не в продукт.

    Нестабільне оточення чи баг продукту

    Головне питання флаку: тест хитається через нестабільне оточення (мережа, сусіди, ліміти, залежний сервіс) чи він упіймав реальний баг гонки в продукті? Помилка коштує дорого в обидва боки: списати баг продукту на «моргання мережі» — пропустити ; завести інфраструктурний шум як дефект — спалити час команди й довіру до себе.

    Метод відсіювання — послідовно прибирати підозрюваних:

    1. Повтори одиничний тест ізольовано. Флак зник, коли тест біжить сам? Значить, винен контекст сюїти — дані або гонка воркерів, а не продукт.
    2. Прожени на свіжому оточенні (докеризована база, ефемерний namespace, окремий акаунт). Зник — це спільний стенд і чужі дані. Лишився — причина глибша.
    3. Відтвори curl-ом наосліп. Стабільно падає на чистому запиті в ізоляції — це детермінований баг продукту, а не флак.
    4. Пройди за correlation id у логи. Якщо в логах видно гонку, deadlock, таймаут залежного сервісу — доказ на руках.

    Так, стабільно

    Ні, curl зелений

    Так

    Ні, і одиничний хитається

    Так

    Ні

    API-тест впав

    Відтворюється
    curl-ом наосліп?

    Детермінований фейл:
    баг продукту або хибний асерт

    Падає лише
    в повній сюїті?

    Дані / порядок / гонка воркерів:
    ізолюй і повтори одинично

    Зникає на
    свіжому оточенні?

    Нестабільне оточення:
    мережа, сусіди, ліміти

    Гонка в продукті:
    лови correlation id у логах

    Так, стабільно

    Ні, curl зелений

    Так

    Ні, і одиничний хитається

    Так

    Ні

    API-тест впав

    Відтворюється
    curl-ом наосліп?

    Детермінований фейл:
    баг продукту або хибний асерт

    Падає лише
    в повній сюїті?

    Дані / порядок / гонка воркерів:
    ізолюй і повтори одинично

    Зникає на
    свіжому оточенні?

    Нестабільне оточення:
    мережа, сусіди, ліміти

    Гонка в продукті:
    лови correlation id у логах

    Важлива чесність: не кожен «плаваючий» фейл — вина тесту. Іноді нестабільність на рівні API — це і є справжня поведінка продукту під навантаженням: незахищена гонка на конкурентному POST, витік з'єднань, який спливає лише під паралеллю, 500 раз на сотню запитів. Такий флак — не шум, а знахідка. Мета тріажу — не «зробити тест зеленим», а чесно віднести падіння до одного з трьох: баг продукту, баг тесту, інфраструктура — і полагодити те, що справді зламане.

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

    • Виглядає як «моргнула мережа», а насправді продукт стабільно повертає 500 на конкурентному записі — ретрай замаскував реальну гонку.
    • Виглядає як надійна пауза sleep(2000), а насправді на завантаженому CI лаг більший — плаваючий флак замість полінгу на умову.
    • Виглядає як безпечний ретрай POST, а насправді він створив дублікат замовлення — повторювати можна лише ідемпотентні запити.
    • Виглядає як здоровий зелений прогон, а насправді третина тестів проходить із другої спроби — ретраї ранера ховають нестабільність, бо flaky pass не рахують.
    • Виглядає як баг продукту, а насправді тест спирається на дані сусіда по спільному стенду — фейл зникає в ізоляції.
    • Виглядає як інфраструктурний шум 429, а насправді під паралеллю сюїта сама себе заганяє в rate limit — це передбачувано, а не випадково.
    • Виглядає як невідтворюваний фейл, а насправді запит не залогований як curl, тож ніхто не пробував його повторити наосліп.

    Підсумок

    • Флак API-тестів найчастіше зводиться до чотирьох груп: дані, гонки, оточення, ліміти — але канонічний перелік ширший і додає час і витоки ресурсів. Перший крок — не правити тест, а віднести падіння до джерела.
    • Фіксований sleep програє завжди; заміна — колбек, коли він доступний, а інакше полінг на конкретну спостережувану умову з високою стелею очікування (не літеральною) та ідемпотентною перевіркою.
    • Ретрай рятує лише транзієнтні збої транспорту й лише для безпечних/ідемпотентних запитів (POST і PATCH не гарантовані, а сама ідемпотентність — предмет перевірки); ретрай поверх осмисленої помилки продукту маскує баг, а неврахований flaky pass робить сюїту брехливою.
    • Дебаг API-фейлу — це зв'язати запит із логами через correlation id (X-Request-Id, traceparent); за кількох сервісів нитку тримає розподілений трейс.
    • Відтвори фейл як самодостатній curl: стабільне падіння наосліп — баг продукту; зелений curl при червоній сюїті — проблема в контексті тесту. Мета тріажу — чесно розділити баг продукту, баг тесту й інфраструктуру.

    Можливі питання

    • «Тест то падає, то проходить на тому самому коді. Твої дії?» Перевіряють метод, а не готову відповідь. Сильний кандидат не каже «додам ретрай», а називає джерела флаку й описує тріаж: ізолювати тест, прогнати на свіжому оточенні, відтворити curl-ом, глянути логи за correlation id.
    • «Коли ретрай доречний, а коли шкідливий?» Хочуть почути межу: ретраїти можна транзієнтні збої транспорту й лише ідемпотентні запити (POST і PATCH — ні); ретраїти 500/409 від продукту — маскувати баг. Плюс — що flaky pass треба рахувати й заводити в карантин, інакше сюїта бреше.
    • «Тест створює запис і одразу його не бачить. Sleep поставиш?» Класика на розуміння асинхронності. Правильно: не sleep, а полінг на умову з таймаутом; спершу з'ясувати, синхронна операція чи асинхронна (202 проти 201).
    • «API-тест упав у CI. З чого починаєш дебаг?» Дивляться, чи мислиш серверним боком: дістати correlation id з відповіді, знайти слід у логах сервісу, за потреби — трейс; звести фейл до curl для відтворення. Не «перезапущу й подивлюся».
    • «Як зрозуміти, це баг продукту чи нестабільне оточення?» Перевіряють чесність тріажу. Очікують послідовне відсіювання: ізоляція, свіже оточення, відтворення curl-ом; і визнання, що нестабільність під навантаженням теж буває справжнім багом продукту, а не шумом.

    Джерела

    П'ять хвилин на карту місцевості: джерела флаку

    • xUnit Test Patterns — Erratic Test — канонічна таксономія: Interacting Tests, Unrepeatable Test, Test Run War (можливий лише при спільній ), Nondeterministic Test і Resource Leakage.
    • Martin Fowler — Eradicating Non-Determinism in Tests — пʼятичленна рубрика того самого явища — брак ізоляції, асинхронність, віддалені сервіси, час і витоки ресурсів.
    • MDN — 429 Too Many Requests — ліміти бувають на весь сервер або на ресурс і рахуються зазвичай за IP — звідси передбачувані 429 у паралельному прогоні.
    • Apache Kafka — Introduction (документація 4.0) — інфраструктурне джерело гонки: реплікація виконується на рівні топіка, тож читання може обслуговувати не той брокер, що приймав запис.

    Полінг із таймаутом замість sleep

    Ретраї: рятують чи маскують

    • MDN — Idempotent (Glossary) — що безпечно повторювати: ідемпотентні — плюс PUT і DELETE; POST і PATCH — ні, а дотримання семантики лежить на сервері.
    • MDN — 403 Forbidden — ретрай осмисленої безглуздий за визначенням коду: повторення того самого запиту дасть ту саму помилку.
    • gRPC — Status codes and their use in gRPC — чому сліпий повтор небезпечний і поза HTTP: DEADLINE_EXCEEDED не означає, що операція не сталася.
    • MDN — Retry-After header — коли повторювати: заголовок має два формати — HTTP-дата або кількість секунд — і приходить не лише при 429, а й при 503.
    • Playwright — Test retries — ретрай ранера — повторний прогін тесту цілком; він лише позначає тест як нестабільний.

    Дебаг: логи сервісу, correlation id, трейсинг

    • W3C — Trace Context (Recommendation)traceparent — W3C Recommendation із жорстким форматом: чотири поля, trace-id — 32 шістнадцяткові символи в нижньому регістрі, і невалідне значення постачальники МУСЯТЬ ігнорувати разом із усім заголовком.

    Відтворення фейла: curl як спільна мова

    • MDN — An overview of HTTP — технічна підстава: протокол не має стану, тож фейл зводиться до одного самодостатнього запиту, який відтворюється поза сюїтою.
    • xUnit Test Patterns — Erratic Test — перше питання тріажу — чи падає тест наодинці: воно відрізняє взаємодію тестів від решти причин за один прогін.

    Нестабільне оточення чи баг продукту

    • xUnit Test Patterns — Erratic Test — канон вимагає систематичного збору даних за чотирма осями (середовище, повна сюїта чи підмножина, кілька прогонів поспіль, кілька ранерів), а для витоків дає тактику «розмір усіх пулів = 1».
    • Martin Fowler — Eradicating Non-Determinism in Tests — лікування через ізоляцію й чисте оточення, а не через приховування падінь.

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

    • xUnit Test Patterns — Erratic Test — прибрати хиткий тест — свідомо втратити його, а лишити червоним — затулити наступні падіння.
    • MDN — Idempotent (Glossary) — повторювати наосліп можна лише ідемпотентні запити: для POST протокол нічого не гарантує.
    • Playwright — Test retries — зелений із другої спроби тест лишається сигналом про нестабільність, а не доказом справності.
    • MDN — 429 Too Many Requests — ліміт за IP робить 429 у паралельній сюїті передбачуваним наслідком, а не випадковим шумом.
    • Playwright — page.waitForTimeout (Discouraged) — фіксована пауза не стабілізує: очікування часу зроблено «inherently flaky» за побудовою.

    Пояснення

    «Поясни» працює з власним API-ключем Anthropic: запит іде з вашого браузера прямо до Anthropic.

    Свій API-ключ (BYOK)

    Вставте власний ключ Anthropic — пояснення працюватиме на реальній моделі. Ключ зберігається лише у вашому браузері (localStorage), нікуди не надсилається, крім api.anthropic.com, і не логується.