Стабільність 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);
Коли запит проходить через кілька сервісів, окремих логів уже мало — потрібен розподілений трейс (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— ідеальний доказ у баг-репорті: розробник відтворює його одним рухом. - Проходить, а падає лише сюїта — винен не сам запит, а контекст: порядок тестів, спільні дані, гонка між . Копай усередину тесту, а не в продукт.
Нестабільне оточення чи баг продукту
Головне питання флаку: тест хитається через нестабільне оточення (мережа, сусіди, ліміти, залежний сервіс) чи він упіймав реальний баг гонки в продукті? Помилка коштує дорого в обидва боки: списати баг продукту на «моргання мережі» — пропустити ; завести інфраструктурний шум як дефект — спалити час команди й довіру до себе.
Метод відсіювання — послідовно прибирати підозрюваних:
- Повтори одиничний тест ізольовано. Флак зник, коли тест біжить сам? Значить, винен контекст сюїти — дані або гонка воркерів, а не продукт.
- Прожени на свіжому оточенні (докеризована база, ефемерний namespace, окремий акаунт). Зник — це спільний стенд і чужі дані. Лишився — причина глибша.
- Відтвори
curl-ом наосліп. Стабільно падає на чистому запиті в ізоляції — це детермінований баг продукту, а не флак. - Пройди за correlation id у логи. Якщо в логах видно гонку, deadlock, таймаут залежного сервісу — доказ на руках.
Важлива чесність: не кожен «плаваючий» фейл — вина тесту. Іноді нестабільність на рівні 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
- Martin Fowler — Eradicating Non-Determinism in Tests — дві тактики й ранжування: колбек — найкраще, полінг — другий варіант, і таймаут потрібен навіть для колбека; стелю варто ставити дуже високою.
- Playwright — page.waitForTimeout (Discouraged) — метод паузи позначено як Discouraged: тести, що чекають на час, «inherently flaky», а пауза дозволена лише для дебагу.
- Playwright — Test assertions (expect.poll, toPass) —
expect.pollіз таймаутом і інтервалами; канонічний приклад у доці — полінг до HTTP200.
- 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» за побудовою.
Що таке флак (flaky test) і чому на рівні API він особливо підступний?
— це тест із недетермінованим результатом: код не змінювався, а прогін то червоний, то зелений, і перезапуск «лікує» без жодної правки. В API-тестах це підступніше, ніж в UI: візуального шару немає, збоку кожен фейл схожий на звичайний HTTP-запит, що чомусь не вдався, — тож падінню легко не повірити, списати все на мережу й натиснути Retry. Насправді за таким падінням стоїть або справжня гонка в самому продукті, або те, що тест неправильно дочікується стану системи. Тому перший крок при — не правити тест навмання, а чесно визначити причину.
Назви основні джерела флаку API-тестів.
Канонічний перелік джерел недетермінізму — брак ізоляції, асинхронність, віддалені сервіси, час і витоки ресурсів; на рівні API вони збираються у чотири практичні групи, кожна з характерним почерком. Дані — тест читає не той стан, на який розраховував: спільний запис хтось змінив чи видалив, або , на яку він спирається, вже не виконується (колізії та хибні передумови). Гонки (асинхронність) — читання випереджає асинхронний запис: щойно створене ще не видно, сервер відповідає 202 замість очікуваного 201, послідовність кроків нестабільна. Оточення — фейли прив'язані до місця, а не до коду: тільки в CI, тільки на спільному стенді, тільки під навантаженням (мережа, сусіди, залежні сервіси). Ліміти — раптові 429, обірвані з'єднання й , коли біжить паралельно. Дані й гонки — споріднена пара: обидва класи про стан, але перший — про те, чий він, а другий — про те, коли він настане. І варто памʼятати, що чотирма групами перелік не вичерпується: час і витоки ресурсів у них не вкладаються, хоч у канонічному переліку вони є.
Тест створює запис і одразу його не бачить. Поставиш sleep?
Ні — це класична пастка на розуміння асинхронності. Фіксована пауза не масштабується: на завантаженому агенті її не вистачить і флак повернеться, на швидкому стенді вона лише марно розтягне прогін. Спершу варто з'ясувати, синхронна операція чи асинхронна: 201 — ресурс уже створено, 202 — запит лише прийнято в обробку. Якщо операція асинхронна, тактик дві: краща — , коли асинхронний сервіс сам повідомляє про завершення (для цього потрібні і відповідне оточення, і готовність постачальника сервісу), а доступніша — (polling): повторювані читання, що зупиняються, щойно система показала потрібний факт, але не пізніше за верхню межу часу. Орієнтир — спостережувана умова, а не відлік секунд.
Що робить полінг надійним?
Три речі. Перше — умова замість часу: ціль полінгу — конкретний факт (потрібний , значення поля, елемент у списку), а не «почекати трохи». Друге — верхній таймаут: без стелі полінг, що не дочекався умови, висітиме вічно, а завислий тест гірший за той, що чітко впав із діагнозом. Стелю при цьому ставлять дуже високою — це запобіжник на випадок серйозної поломки, а не бюджет часу; і часові значення не лишають літералами в коді, а виносять у константи чи налаштування оточення. Третє — ідемпотентна перевірка: код усередині циклу мусить лише читати; потрапить туди POST — кожна ітерація створюватиме новий запис. Принцип: тест рушає далі в мить, коли факт настав, а фейлиться лише тоді, коли факту немає непристойно довго.
Які запити можна безпечно ретраїти й чому?
Лише безпечні (safe) та ідемпотентні: GET, HEAD, а також PUT і DELETE — їхній повтор за наміром семантики лишає систему в тому самому стані (а дотримання цього наміру лежить на сервері, тож у конкретному API це перевіряють). Сліпий повтор POST натомість множить сутності: замовлення чи платіж з'явиться двічі; PATCH ідемпотентним теж не гарантований, хоч про нього забувають частіше. Якщо POST треба вберегти від повтору, це відповідальність продукту (механізм Idempotency-Key), а не тесту. Правило просте: не має змінити стан системи інакше, ніж змінив би єдиний успішний запит.
На які збої ретрай легітимний, а на які — ні?
Легітимно повторювати запит після транзієнтного збою: розірвалося TCP-з'єднання, не відповів DNS, балансувальник віддав 503 посеред — це минуща інфраструктурна негода, а не рішення застосунку. А от повтор запиту, на який продукт свідомо відповів помилкою — 500 від незловленого винятку, 409 від реальної гонки, 400 від невалідних даних — просто ховає дефект від очей. Межа проходить не по бажанню бачити зелене, а по природі збою: шум транспорту ретраїмо, осмислену застосунку — фіксуємо як результат.
У чому класична пастка ретраю на рівні клієнта?
У тому, що мережевий збій і бізнес-помилка приходять різними каналами. Обрив з'єднання чи DNS-таймаут кидається як виняток, а 500 від застосунку — це коректно доставлена відповідь, на яку HTTP-клієнт винятку не кине. Наївний ретрай «повторюємо на будь-якій проблемі» почне мовчки ретраїти й 500, і 409, ховаючи справжній баг. Тому клієнтський ретрай будують на try/catch навколо запиту — реагують на кинуті збої транспорту, а статус-коди відмови продукту лишають тесту як результат, який він зобов'язаний побачити.
Чим небезпечний ретрай усього тесту на рівні ранера?
Ретрай тесту (у Playwright — retries у конфізі) — прийнятна тимчасова , але в нього є ціна: тест, що позеленів лише з другої спроби, — це сигнал нестабільності, який щойно заглушили. Якщо такі flaky pass не рахувати окремо й не відправляти в , прогін виглядає здоровим, хоча всередині живе гонка або хитке оточення — третина тестів проходить із другого разу, і ніхто цього не бачить. Тож ретрай — спосіб пережити те, що ти вже дослідив і свідомо визнав транзієнтним, а не спосіб не розбиратися.
Що таке correlation id і навіщо він у дебагу API-флаку?
Після падіння UI-тесту лишаються й відео; в API-тесту з доказів — сама лише відповідь, а справжня причина зазвичай на боці сервера. Correlation id (він же request id) — унікальний ідентифікатор, що супроводжує один запит через усі сервіси й потрапляє в кожен рядок логу. Робочий прийом у тесті: взяти id з відповіді або самому проставити його заголовком, а тоді вписати в повідомлення й у звіт. Після фейлу не доводиться гадати — у логах шукаєш за точним ключем, і grep за одним id віддає весь слід запиту. Без цієї нитки дебаг деградує до «перезапущу й подивлюся».
Чим X-Request-Id відрізняється від traceparent (W3C Trace Context)?
Це два рівні однієї ідеї. X-Request-Id чи X-Correlation-Id — домовленість усередині команди: заголовок не стандартизований, імена в різних проєктах різні, id сервіс призначає сам і кладе у власні логи. traceparent описаний специфікацією W3C Trace Context, тому його однаково читають різні -системи, і саме він несе ідентифікатор трейсу через межі сервісів. Формат у нього жорсткий — чотири поля: версія, trace-id (32 шістнадцяткові символи нижнього регістру), parent-id (16) і trace-flags; власний ідентифікатор команди туди не вкласти, а якщо всунути — отримувач зобовʼязаний проігнорувати весь заголовок. Практична різниця: власний X-Request-Id легко проставити з тесту й знайти в логах конкретного сервісу, а traceparent дає наскрізну нитку, придатну для розподіленого трейсу.
Що таке спан і як спани складаються в розподілений трейс (distributed tracing)?
Коли запит проходить крізь кілька сервісів, окремих логів уже мало. (span) — один крок обробки: виклик сервісу, запит до бази, звернення до залежного API. Трейс (trace) — усі спани одного запиту, зібрані в дерево викликів із таймінгами: на ньому видно, котрий крок з'їв час і звідки насправді прилетіла помилка. Відкритий стандарт тут — OpenTelemetry, візуалізатори — системи на кшталт Jaeger. Для AQA це передусім навичка читання: взяв trace-id з відповіді, відкрив трейс — і бачиш, що 500 народився не в сервісі замовлень, а в платіжному, який вперся в таймаут.
Навіщо зводити фейл до самодостатнього curl?
Невідтворюваний флак неможливо ні полагодити, ні довести розробнику. curl — спільна мова: один рядок із методом, повним URL, заголовками й тілом однаково зрозумілий бекендеру, девопсу й тобі самому за тиждень. Звідси дисципліна: логувати важливі запити в такому вигляді, щоб фейл відтворювався вставленням рядка в термінал, без реконструкції з пам'яті. Бонус — такий рядок ідеально лягає в баг-репорт: розробник відтворює проблему одним рухом.
Ти звів фейл до curl і ганяєш його наосліп. Як читати результат?
Двома . curl падає щоразу в тому самому оточенні — фейл детермінований: майже напевно дефект продукту (або крива перевірка в самому тесті); голий запит без обгортки фреймворку знімає підозру з . curl зелений, а червоніє лише сюїта — проблема не в запиті, а в контексті прогону: черговість тестів, спільні дані, змагання . Це головна розвилка : за хвилину відокремлює «щось не так із продуктом» від «щось не так із моїм прогоном», не чіпаючи код застосунку.
Тест то падає, то проходить на тому самому коді. Опиши свій метод, а не готову відповідь.
Сильна відповідь — не «додам ретрай», а послідовне зняття підозр. Крок один: запустити тест поодинці — флак зник, отже, винен контекст сюїти (дані чи змагання воркерів), не продукт. Крок два: свіже оточення (докеризована база, ефемерний namespace, окремий акаунт) — зник там, значить, справа у спільному стенді й чужих даних; лишився — причина глибша. Крок три: curl наосліп — стабільне падіння чистого запиту в ізоляції означає детермінований дефект продукту. Крок чотири: логи за correlation id — якщо видно гонку, deadlock чи таймаут залежного сервісу, доказ на руках. Фінал — не зелений колір, а чесна класифікація: баг продукту, баг тесту чи інфраструктура.
Чи кожен плаваючий фейл — це вина тесту?
Ні, і це важлива чесність тріажу. Буває, що хиткість на рівні API — справжня поведінка продукту під навантаженням: гонка на конкурентних записах, яку ніхто не захистив, з'єднання, що витікають лише під паралеллю, рідкісний 500 на одному запиті із сотні. Такий флак — улов, а не шум. Ціна помилки висока в обидва боки: назвеш продуктовий баг «морганням мережі» — випустиш ; оформиш інфраструктурний шум як дефект — змарнуєш час команди й підточиш довіру до своїх репортів. Тому мета — не заглушити падіння, а правильно його класифікувати й полагодити те, що зламане насправді.
Чому 429 часто спливає лише в CI під час паралельного прогону?
429 Too Many Requests — відповідь rate limiting-а: клієнт перевищив дозволену частоту звернень. Один тест до ліміту зазвичай не дотягується, а от у CI десятки воркерів разом гатять в один — і сюїта колективно пробиває поріг. Симптом упізнаваний: локально зелено, у паралельному — нестабільні падіння з 429. Це не випадковість, а закономірність, і лікується вона системно: поважати Retry-After, знизити паралелізм на гарячих ендпоінтах або замокати ліміт там, де тест перевіряє не його. Головне — бачити в лімітах окреме джерело флаку, а не лише сюжет для одного негативного тесту.
Три кейси, де вирішується, зелений API-тест чи полювання за примарою: заміна sleep на для асинхронної операції, , що ловить лише транзієнтний збій транспорту й тягне correlation id у звіт, і через curl наосліп. Скрізь — що робити і чому саме так.
Кейс 1. Полінг на умову замість sleep
Тест створює завдання (POST /api/jobs) і одразу читає результат — а сервер відповів 202 Accepted, тобто запит лише прийнято, обробка триває асинхронно. Наївний фікс — вставити паузу «щоб точно встигло». На завантаженому CI-агенті лаг більший за паузу, і тест починає плавати.
// ХИБНО: фіксована пауза програє в обидва боки
const created = await request.post('/api/jobs', { data: job });
await new Promise((r) => setTimeout(r, 2000)); // 2s мало на CI, багато на порожньому стенді
const res = await request.get(`/api/jobs/${id}`);
expect((await res.json()).status).toBe('done');
Заміна — полінг на бізнес-умову з верхнім . Чекаємо не на секунди, а на конкретний спостережуваний факт: поле status стало done.
// значення часу — з налаштувань, а не літералами в тесті
const WAIT_LIMIT_MS = Number(process.env.WAIT_LIMIT_MS ?? 60_000);
const POLL_INTERVALS_MS = [300, 600, 1200];
const id = (await (await request.post('/api/jobs', { data: job })).json()).id;
// ПРАВИЛЬНО: чекаємо на факт, а не на час
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');
Що дивитися і чому:
202проти201— це діагноз, а не дрібниця.201означає, що ресурс уже створено й наступнийGETйого побачить;202каже «прийнято, обробляю» — читати результат одразу немає сенсу. Побачив202— операція асинхронна, потрібен полінг.- Умова замість часу. Полінг перепитує систему, доки поле
statusне станеdone, і йде далі рівно в цю мить, а не через фіксовані дві секунди. На швидкому стенді тест завершиться за 300 мс, на повільному — почекає, скільки треба. - Верхній таймаут обов'язковий.
WAIT_LIMIT_MS— це стеля: якщо факт не настав, тест чесно падає, а не зависає назавжди. Полінг без стелі гірший заsleep. Дві поправки: стелю ставлять високою, щоб вона спрацьовувала лише на серйозній поломці, а часові значення тримають у константах чи налаштуваннях оточення, а не літералами в коді. - Функція всередині лише читає. У полінгу — тільки
GET. Поклади тудиPOST— і за десять ітерацій отримаєш десять завдань замість перевірки одного.
Кейс 2. Ретрай лише на транзієнтному збої плюс correlation id у звіті
Мережа між CI-агентом і API іноді рветься — це транзієнтний шум транспорту, який сам не повториться. Його ретраїти можна, але лише для безпечного GET і лише на кинутому винятку. Важлива пастка: 500 від застосунку сюди не прилетить — це звичайна відповідь, а не виняток, тож наш ретрай його не проковтне.
// Ретраїмо лише 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))); // лінійний бекоф
}
}
}
Коли запит усе-таки впав осмислено, потрібен ключ у логи. Дістаємо correlation 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 у месиджі асерту — щоб після падіння одразу було що grep-ати в логах сервісу
expect(res.status(), `request-id=${requestId}`).toBe(201);
Що дивитися і чому:
- Ретрай ловить транспорт, а не коди.
try/catchспрацьовує на обриві з'єднання чи DNS-таймауті. Якби ми замість цього аналізували статус і повторювали5xx, ретрай почав би мовчки ховати500через реальний баг гонки. - Тільки
GET.POSTчерезgetWithRetryстворив би дублікат замовлення. ЗахистPOSTвід повтору — робота продукту черезIdempotency-Key, а не тесту. - Correlation id у повідомленні асерту, а не в
console.log. Повідомлення асерту потрапляє в репорт поруч із фейлом.request-id=...перетворює дебаг із «перезапущу й подивлюся» на точковий пошук у логах сервісу. - Вичерпав спроби — кидай.
if (i === tries - 1) throw errдає чесний фейл. Проковтнути останню помилку й повернутиundefined— це замаскувати проблему ще глибше за flaky pass.
Кейс 3. Тріаж: curl наосліп і таблиця рішень
Тест червоніє через раз. Перш ніж заводити баг чи правити , треба відповісти на одне питання: винен продукт, тест чи оточення. Зводимо фейл до самодостатнього curl — його зрозуміє і бекендер, і девопс, і ти сам через тиждень.
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 кілька разів наосліп у тому самому оточенні, потім ізолювати одиничний тест, потім свіже оточення, потім логи за X-Request-Id.
| Спостереження | Висновок | Куди копати |
|---|---|---|
curl падає стабільно | Детермінований фейл: баг продукту або хибний асерт | Логи сервісу за X-Request-Id, баг-репорт із цим curl |
curl зелений, падає лише повна сюїта | Контекст тесту: дані, порядок, гонка воркерів | Ізолюй тест, унікальні дані, teardown |
| Одиничний тест хитається, зникає на свіжому оточенні | Нестабільне оточення | Спільний стенд, чужі дані, мережа, 429 під паралеллю |
| Одиничний хитається і на свіжому оточенні | Гонка в продукті | Лови correlation id у логах: deadlock, таймаут залежного сервісу |
Що дивитися і чому:
curl— це спільна мова доказу. Стабільне падіння на чистому запиті в ізоляції знімає підозру з і фреймворку: розробник відтворює його одним рухом, і суперечка «це баг чи флак» закінчується.- Зелений
curlпри червоній вказує вбік від продукту. Винен не сам запит, а те, як влаштований прогін: порядок тестів, спільні дані, гонка між . Копай усередину тесту, а не в застосунок. 429під паралеллю — не шум, а передбачуваність. Якщо зникає при зменшенні кількості воркерів — сюїта сама заганяла себе в rate limit. Це оточення, а не баг продукту.- Мета — класифікація, а не зелений колір. Тріаж закінчується чесним вироком: баг продукту, баг тесту чи інфраструктура. Гонка під навантаженням, яку зловив тест, — це знахідка, а не привід заглушити падіння ретраєм.
Джерела флаку
- Можу пояснити, що (flaky test) — це падіння на незмінному коді, і що перший крок не «додати ретрай», а назвати причину.
- Знаю чотири практичні групи джерел API-тестів: дані, гонки (асинхронність), оточення, ліміти — і характерний симптом кожної; памʼятаю, що канонічний перелік ширший і додає час і витоки ресурсів.
- Розумію, чому дані й гонки — брати: обидва про стан, але дані це колізії й хибні , а гонки це час (читання випереджає асинхронний запис).
- Знаю, що
429, обриви з'єднань і під паралеллю — це клас «ліміти», а не «моргнула мережа».
Полінг замість sleep
- Можу пояснити, чому фіксований
sleepпрограє завжди: коротка пауза дає флак, довга — марно гальмує . - Розрізняю
201(створено синхронно) і202(прийнято в обробку, асинхронно) і знаю, що від цього залежить, чи треба чекати. - Знаю, що краща тактика очікування — (коли його дозволяє оточення й дає постачальник сервісу), а — друга; і три опори надійного полінгу: умова замість часу, висока стеля очікування без літеральних значень, ідемпотентна (тільки читання) перевірка всередині.
- Розумію принцип: тест іде далі рівно тоді, коли факт настав, і падає, лише якщо факту немає дуже довго.
Ретраї: рятують чи маскують
- Знаю, що безпечно ретраїти лише safe/ідемпотентні запити (
GET,HEAD,PUT,DELETE), аPOSTіPATCHідемпотентними не гарантовані; памʼятаю, що дотримання семантики лежить на сервері й перевіряється. - Можу пояснити межу «на що реагуємо»: транзієнтний збій транспорту ретраїмо, осмислену помилку продукту (
500,409,400) — ні. - Розумію класичну пастку: мережевий збій кидається як виняток, а
500приходить як звичайна відповідь, тож клієнтський має ловити кинуті помилки, а не коди. - Знаю ціну ретраю всього тесту на рівні : flaky pass треба рахувати й заводити в , інакше сюїта бреше.
Дебаг: correlation id і трейсинг
- Можу пояснити, чому дебаг API-флаку — це зв'язати впалий запит із логами сервера, а не дивитися лише на відповідь.
- Знаю різницю між конвенційним
X-Request-Id/X-Correlation-Idі формальним стандартом W3C Trace Context із заголовкомtraceparent, і щоtraceparentмає жорсткий формат із чотирьох полів (версія,trace-id,parent-id,trace-flags) — власний id команди туди не всунеш, бо отримувач мусить проігнорувати весь заголовок. - Вмію дістати correlation id з відповіді (або надіслати свій) і покласти його в повідомлення та артефакти звіту.
- Розумію, що таке (span) і як спани складаються в розподілений (distributed tracing), і що для AQA це передусім навичка читання трейсу (OpenTelemetry, Jaeger).
Відтворення і тріаж
- Можу звести фейл до самодостатнього
curl(метод, URL, заголовки, тіло) і знаю, навіщо логувати запити саме в такій формі. - Читаю результат
curlнаосліп: стабільне падіння — детермінований баг продукту; зеленийcurlпри червоній сюїті — проблема в контексті тесту. - Знаю метод відсіювання підозрюваних (ізоляція одиничного тесту → свіже оточення → відтворення
curl→ логи за correlation id) і його мету: не «зробити тест зеленим», а чесно розділити баг продукту, баг тесту й інфраструктуру. - Розумію, що не кожен плаваючий фейл — вина тесту: гонка під навантаженням буває справжнім багом продукту, а не шумом.
Квіз
Перед стартом
- Питань: 15
- Поріг «зараховано»: ≥70% правильних відповідей.
- Результат впливає на прогрес; завалені питання підуть у чергу повторення.
- Квіз впливає на компліт теми: тема стає «пройдено», лише коли прочитано теорію І квіз складено на ≥70%.
Питання
Флак (flaky test) — що це і чому підступний на рівні API?