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): періодично перепитувати систему, доки справдиться умова, з жорстким верхнім таймаутом. Чекаємо не на час, а на конкретний спостережуваний факт.

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

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

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

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

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

    Практика для тесту проста: дістань 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)створити (traceparent з r-42)списати кошти (той самий trace)помилка500500 (X-Request-Id: r-42)Сервіс оплатСервіс замовленьAPI-gatewayТестСервіс оплатСервіс замовленьAPI-gatewayТестодин id пронизує всі сервіси — grep по r-42POST /orders (X-Request-Id: r-42)створити (traceparent з r-42)списати кошти (той самий trace)помилка500500 (X-Request-Id: r-42)

    Коли запит проходить через кілька сервісів, окремих логів уже мало — потрібне розподілене трасування (distributed tracing). Ідея: кожен крок обробки — це span (проміжок), а всі 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 програє завжди; заміна — полінг на конкретну спостережувану умову з верхнім таймаутом та ідемпотентною перевіркою.
    • Ретрай рятує лише транзієнтні збої транспорту й лише для безпечних/ідемпотентних запитів; ретрай поверх осмисленої помилки продукту маскує баг, а неврахований flaky pass робить сюїту брехливою.
    • Дебаг API-фейлу — це зв'язати запит із логами через correlation id (X-Request-Id, traceparent); за кількох сервісів нитку тримає розподілене трасування.
    • Відтвори фейл як самодостатній curl: стабільне падіння наосліп — баг продукту; зелений curl при червоній сюїті — проблема в контексті тесту. Мета тріажу — чесно розділити баг продукту, баг тесту й інфраструктуру.

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

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

    Джерела

    • MDN — 429 Too Many Requests — код rate limiting, який спливає під час паралельних прогонів.
    • MDN — Retry-After — заголовок, що каже, коли безпечно повторити запит.
    • W3C Trace Context — стандарт заголовка traceparent для перенесення id трейсу між сервісами.
    • Playwright — Test retries — механіка ретраїв ранера й позначення flaky-тестів.
    • Martin Fowler — Eradicating Non-Determinism in Tests — канонічний розбір флакі-тестів: полінг замість sleep, ізоляція, чисте оточення, спільний стенд.
    • Силабус ISTQB CTFL 4.0 (istqb.org) — підготовка тестового середовища й тестових даних як частина тестового процесу (test implementation).