Стабільність 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);
Коли запит проходить через кілька сервісів, окремих логів уже мало — потрібне розподілене трасування (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— ідеальний доказ у баг-репорті: розробник відтворює його одним рухом. - Проходить, а падає лише сюїта — винен не сам запит, а контекст: порядок тестів, спільні дані, гонка між воркерами. Копай усередину тесту, а не в продукт.
Нестабільне оточення чи баг продукту
Головне питання тріажу флаку: тест хитається через нестабільне оточення (мережа, сусіди, ліміти, залежний сервіс) чи він упіймав реальний баг гонки в продукті? Помилка коштує дорого в обидва боки: списати баг продукту на «моргання мережі» — пропустити регресію; завести інфраструктурний шум як дефект — спалити час команди й довіру до себе.
Метод відсіювання — послідовно прибирати підозрюваних:
- Повтори одиничний тест ізольовано. Флак зник, коли тест біжить сам? Значить, винен контекст сюїти — дані або гонка воркерів, а не продукт.
- Прожени на свіжому оточенні (докеризована база, ефемерний namespace, окремий акаунт). Зник — це спільний стенд і чужі дані. Лишився — причина глибша.
- Відтвори
curl-ом наосліп. Стабільно падає на чистому запиті в ізоляції — це детермінований баг продукту, а не флак. - Пройди за correlation id у логи. Якщо в логах видно гонку, deadlock, тайм-аут залежного сервісу — доказ на руках.
Важлива чесність: не кожен «плаваючий» фейл — вина тесту. Іноді нестабільність на рівні 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).
Що таке флак (flaky test) і чому на рівні API він особливо підступний?
Флак — це тест із недетермінованим результатом: код не змінювався, а прогін то червоний, то зелений, і перезапуск «лікує» без жодної правки. В API-тестах це підступніше, ніж в UI: візуального шару немає, збоку кожен фейл схожий на звичайний HTTP-запит, що чомусь не вдався, — тож падінню легко не повірити, списати все на мережу й натиснути Retry. Насправді за таким падінням стоїть або справжня гонка в самому продукті, або те, що тест неправильно дочікується стану системи. Тому перший крок при флаку — не правити тест навмання, а чесно визначити причину.
Назви основні джерела флаку API-тестів.
Їх зазвичай чотири, і кожне має характерний почерк. Дані — тест читає не той стан, на який розраховував: спільний запис хтось змінив чи видалив, або передумова, на яку він спирається, вже не виконується (колізії та хибні передумови). Гонки (асинхронність) — читання випереджає асинхронний запис: щойно створене ще не видно, сервер відповідає 202 замість очікуваного 201, послідовність кроків нестабільна. Оточення — фейли прив'язані до місця, а не до коду: тільки в CI, тільки на спільному стенді, тільки під навантаженням (мережа, сусіди, залежні сервіси). Ліміти — раптові 429, обірвані з'єднання й таймаути, коли сюїта біжить паралельно. Дані й гонки — споріднена пара: обидва класи про стан, але перший — про те, чий він, а другий — про те, коли він настане.
Тест створює запис і одразу його не бачить. Поставиш sleep?
Ні — це класична пастка на розуміння асинхронності. Фіксована пауза не масштабується: на завантаженому агенті її не вистачить і флак повернеться, на швидкому стенді вона лише марно розтягне прогін. Спершу варто з'ясувати, синхронна операція чи асинхронна: 201 — ресурс уже створено, 202 — запит лише прийнято в обробку. Якщо операція асинхронна, правильний інструмент — полінг (polling): повторювані читання, що зупиняються, щойно система показала потрібний факт, але не пізніше за жорстку верхню межу часу. Орієнтир — спостережувана умова, а не відлік секунд.
Що робить полінг надійним?
Три речі. Перше — умова замість часу: ціль полінгу — конкретний факт (потрібний статус-код, значення поля, елемент у списку), а не «почекати трохи». Друге — верхній таймаут: без стелі полінг, що не дочекався умови, висітиме вічно, а завислий тест гірший за той, що чітко впав із діагнозом. Третє — ідемпотентна перевірка: код усередині циклу мусить лише читати; потрапить туди POST — кожна ітерація створюватиме новий запис. Принцип: тест рушає далі в мить, коли факт настав, а фейлиться лише тоді, коли факту немає непристойно довго.
Які запити можна безпечно ретраїти й чому?
Лише безпечні (safe) та ідемпотентні: GET, HEAD, а також PUT і DELETE — їхній повтор лишає систему в тому самому стані. Сліпий повтор POST натомість множить сутності: замовлення чи платіж з'явиться двічі. Якщо 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, тому його однаково читають різні трейсинг-системи, і саме він несе ідентифікатор трейсу через межі сервісів. Практична різниця: власний X-Request-Id легко проставити з тесту й знайти в логах конкретного сервісу, а traceparent дає наскрізну нитку, придатну для розподіленого трасування.
Що таке span і trace у розподіленому трасуванні (distributed tracing)?
Коли запит проходить крізь кілька сервісів, окремих логів уже мало. Span (проміжок) — один крок обробки: виклик сервісу, запит до бази, звернення до залежного API. Trace (трейс) — усі span-и одного запиту, зібрані в дерево викликів із таймінгами: на ньому видно, котрий крок з'їв час і звідки насправді прилетіла помилка. Відкритий стандарт тут — 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 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: 15_000, intervals: [300, 600, 1200] }).toBe('done');
Що дивитися і чому:
202проти201— це діагноз, а не дрібниця.201означає, що ресурс уже створено й наступнийGETйого побачить;202каже «прийнято, обробляю» — читати результат одразу немає сенсу. Побачив202— операція асинхронна, потрібен полінг.- Умова замість часу. Полінг перепитує систему, доки поле
statusне станеdone, і йде далі рівно в цю мить, а не через фіксовані дві секунди. На швидкому стенді тест завершиться за 300 мс, на повільному — почекає, скільки треба. - Верхній таймаут обов'язковий.
timeout: 15_000— це стеля: якщо факт не настав за 15 секунд, тест чесно падає, а не зависає назавжди. Полінг без стелі гірший за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наосліп створює дублікат. - Можу пояснити межу «на що реагуємо»: транзієнтний збій транспорту ретраїмо, осмислену помилку продукту (
500,409,400) — ні. - Розумію класичну пастку: мережевий збій кидається як виняток, а
500приходить як звичайна відповідь, тож клієнтський ретрай має ловити кинуті помилки, а не коди. - Знаю ціну ретраю всього тесту на рівні ранера: flaky pass треба рахувати й заводити в карантин, інакше сюїта бреше.
Дебаг: correlation id і трасування
- Можу пояснити, чому дебаг API-флаку — це зв'язати впалий запит із логами сервера, а не дивитися лише на відповідь.
- Знаю різницю між конвенційним
X-Request-Id/X-Correlation-Idі формальним стандартом W3C Trace Context із заголовкомtraceparent. - Вмію дістати correlation id з відповіді (або надіслати свій) і покласти його в повідомлення асерту та артефакти звіту.
- Розумію, що таке span і trace у розподіленому трасуванні (distributed tracing) і що для AQA це передусім навичка читання трейсу (OpenTelemetry, Jaeger).
Відтворення і тріаж
- Можу звести фейл до самодостатнього
curl(метод, URL, заголовки, тіло) і знаю, навіщо логувати запити саме в такій формі. - Читаю результат
curlнаосліп: стабільне падіння — детермінований баг продукту; зеленийcurlпри червоній сюїті — проблема в контексті тесту. - Знаю метод відсіювання підозрюваних (ізоляція одиничного тесту → свіже оточення → відтворення
curl→ логи за correlation id) і його мету: не «зробити тест зеленим», а чесно розділити баг продукту, баг тесту й інфраструктуру. - Розумію, що не кожен плаваючий фейл — вина тесту: гонка під навантаженням буває справжнім багом продукту, а не шумом.
Що таке флак (flaky test)?
Питання
Флак (flaky test) — що це і чому підступний на рівні API?