Тестові дані та стан в API-тестах
Зміст
Тест робить POST /orders, отримує чистий 201 Created, одразу перевіряє GET /orders/42 — і бачить 404. Замовлення «зникло». Ти йдеш дивитися логи бекенду, розробник знизує плечима: у нього все створюється. За півгодини з'ясовується, що баг не в застосунку і не в тесті як такому — а в тому, що тест поставив запитання на мілісекунду раніше, ніж система встигла на нього відповісти. Це не виняток, а типова причина «плаваючих» падінь на рівні API.
Ця глава — про стан (state) і дані (test data): звідки в API-тесті береться нестабільність, коли її винен не продукт, а спосіб, у який тест готує й читає дані. Повний канон стратегії тест-даних — фабрики, фікстури, рівні ізоляції, прибирання — живе в розділі про стратегію автоматизації; тут лише API-специфічна дельта. Це глава поглиблення: при першому проході її можна пропустити й повернутися тоді, коли твої API-тести почнуть червоніти «через раз» на створенні й читанні даних. Саме тоді все нижче стане болісно актуальним.
Eventual consistency: чому створене «ще не існує»
У маленькому монолітному застосунку POST записує рядок у базу, і той самий рядок одразу видно на GET. Але щойно система масштабується, між записом і читанням з'являється затримка. Це і є кінцева узгодженість (eventual consistency): система гарантує, що дані стануть узгодженими колись потім, а не в ту саму мить.
Звідки береться лаг:
- Черги повідомлень.
POSTкладе подію в чергу (Kafka, RabbitMQ) і повертає202 Accepted— «прийняв, обробляю». Фактичний запис станеться асинхронно, за десятки-сотні мілісекунд. - Репліки на читання. Запис іде в майстер-базу, а
GETобслуговує репліка. Реплікація не миттєва — це replication lag. - Пошукові індекси. Створений об'єкт лягає в основну базу одразу, але в пошуковий індекс (Elasticsearch тощо) потрапляє з окремим циклом оновлення, часто близько секунди.
- Кеші. Список уже закешовано, і новий елемент з'явиться в ньому лише після інвалідації (механіку кешування розбирає глава Кешування).
Ключова ознака: код відповіді 202 Accepted замість 201 Created — це відкритий сигнал сервера «результат ще не готовий, не читай наосліп». Але лаг буває й за 201, якщо читання йде іншим шляхом, ніж запис.
Мораль: між «створив» і «побачив» у розподіленій системі є вікно неузгодженості. Тест мусить його пережити, а не вдавати, що його немає.
Полінг із таймаутом замість sleep
Перша реакція новачка на таку гонку — вставити паузу: «почекаю дві секунди, і дані точно будуть». Це найгірше з можливих рішень, бо фіксована пауза програє в обидва боки.
- Пауза занадто коротка — на завантаженому CI-агенті лаг цього разу склав 2.5 секунди, і тест падає. Плаваючий флак.
- Пауза занадто довга — насправді дані готові за 200 мс, але кожен тест дарма стоїть 2 секунди. Сюїта з 300 тестів втрачає десять хвилин на порожньому місці.
// Погано: фіксована пауза — або флак, або марна трата часу
await new Promise((r) => setTimeout(r, 2000));
const res = await request.get(`/api/orders/${id}`);
expect(res.status()).toBe(200);
Правильний підхід — полінг (polling): періодично перепитувати систему, доки умова не справдиться, з жорстким верхнім таймаутом. Тест іде далі рівно тоді, коли дані реально з'явилися, і падає, лише якщо їх немає дуже довго.
// Добре: чекаємо саме на умову, з таймаутом
await expect.poll(async () => {
const res = await request.get(`/api/orders/${id}`);
return res.status();
}, { timeout: 10_000, intervals: [250, 500, 1000] }).toBe(200);
Три речі роблять полінг надійним:
- Умова, а не час. Чекаємо на конкретний спостережуваний факт (статус
200, полеstatus: "processed", наявність елемента у списку), а не на абстрактні «дві секунди». - Верхній таймаут. Полінг без стелі — це вічне зависання, гірше за падіння. Таймаут перетворює «система тупить нескінченно» на чесний фейл.
- Ідемпотентна перевірка. Функція всередині полінгу має бути безпечною для багаторазового виклику — тільки читання, без побічних ефектів. Полити
POSTне можна: створиш десять замовлень.
Полінг — це API-варіант «розумного очікування», яке в браузері дає авточекання Playwright. Ширший розбір нестабільності саме API-тестів — у главі Стабільність API-тестів.
Сідінг даних: через публічний API чи напряму в БД
Майже кожен тест потребує передумови: «існує користувач з роллю admin», «є замовлення в статусі paid». Створювати цей стан руками щоразу — дорого, тож його сідять (seed) програмно. Два принципові шляхи, і вибір між ними — постійне джерело суперечок.
Сідінг через публічний API — тест викликає ті самі ендпоінти, що й реальний клієнт (POST /users, POST /orders). Сідінг напряму в БД — тест пише INSERT у таблиці в обхід застосунку (механіку SQL і підключення до бази розбирає розділ про бази даних).
| Критерій | Через API | Напряму в БД |
|---|---|---|
| Дані валідні за бізнес-правилами | Так, їх ставить сам застосунок | Легко порушити інваріанти |
| Швидкість | Повільніше (мережа, логіка) | Швидко, один запит |
| Крихкість до змін схеми | Стійкіше (контракт API) | Ламається на кожній міграції |
| Доступність із CI | Потрібен лише HTTP | Потрібен доступ до БД |
| Складний/недосяжний стан | Не завжди можливо через API | Можна поставити будь-що |
Правило за замовчуванням: сідь через API, поки можеш. Дані, створені застосунком, гарантовано узгоджені — правильні зовнішні ключі, обчислені поля, події в черзі. Прямий INSERT цього не робить: ти можеш вставити замовлення, під яке застосунок ніколи не згенерував би рахунок, і потім годину дивуватися «неможливому» стану.
Пряма БД виправдана, коли стан дорого або неможливо відтворити через API: історичні дані «за минулий рік», стан з іншого мікросервісу, спеціально «зіпсований» запис для негативного сценарію. І навіть тоді краще шукати проміжний варіант — тестовий бекдор-ендпоінт (наприклад, POST /test/seed), який доступний лише на не-продакшн-середовищах і ставить стан валідно, але швидко.
Сідінг через API природно поєднується з патерном API-клієнта й ланцюжками запитів — це тема глави API-автотести в коді.
Ізоляція: свій акаунт, свій tenant
Другий великий клас проблем — не час, а зіткнення даних. Два тести (або два паралельних воркери, або ти й колега на тому самому стенді) чіпають той самий об'єкт, і один затирає стан іншого. Тест «іноді» бачить не те, що сам створив.
Базовий принцип ізоляції: кожен тест володіє своїми даними й не спирається на чужі. Практичні рівні ізоляції в API-тестах:
-
Унікальні ідентифікатори. Ніколи не хардкодь
email: "test@test.com"— на другому паралельному запуску буде конфлікт унікальності. Генеруй унікальне значення на кожен прогін.import { faker } from '@faker-js/faker'; const email = `qa+${Date.now()}-${faker.string.alphanumeric(6)}@example.com`; -
Окремий акаунт на тест або на воркер. Якщо тести змінюють профіль, налаштування, баланс — дай кожному свого користувача. Тоді паралельні тести фізично не бачать даних одне одного. Керування токенами таких акаунтів — у главі Авторизація в API.
-
Окремий tenant. У багатоорендних (multi-tenant) SaaS-системах дані розділені за орендарем (організацією, робочим простором). Виділити тесту власний tenant — найсильніша ізоляція: не перетинаються не лише записи, а й довідники, ліміти, налаштування. Один орендар не бачить даних іншого за побудовою системи.
Ізоляція через дані майже завжди дешевша й надійніша за ізоляцію через прибирання. Прибирання (DELETE в afterEach) теж потрібне, щоб стенд не заростав сміттям, але покладатися тільки на нього небезпечно: якщо тест упав до клінапу, сміття лишиться. Тому унікальні дані — перша лінія оборони, а не після-хуки.
Детермінованість зовнішніх залежностей
Тест детермінований, якщо на незмінному вході дає незмінний результат. Ламають цю властивість недетерміновані зовнішні залежності — усе, що застосунок бере зі світу поза власною базою:
- Сторонні API. Платіжний шлюз, курс валют, геолокація, погода. Реальний виклик у тесті означає, що твій зелений/червоний залежить від чужого аптайму й чужих даних.
- Час. Логіка «підписка активна ще 3 дні», «купон діє до кінця місяця» дає різну відповідь сьогодні й завтра. Тест, зелений у липні, червоніє 1 числа наступного місяця.
- Випадковість. Промокоди, A/B-когорти, рандомні ідентифікатори роблять відповідь непередбачуваною.
Стратегії приборкання:
- Мокати залежність. Замінити реальний сторонній сервіс контрольованим дублером (стаб, mock-сервер), який завжди повертає відоме. Це основний інструмент детермінізму на рівні сервісів — механіка в главі Мокання залежностей.
- Використати офіційний sandbox. Багато платіжних і поштових провайдерів дають тестове середовище з передбачуваними «магічними» значеннями (картка, що завжди відхиляється; сума, що завжди проходить).
- Керувати часом через дані, а не через тест. З API-тесту ти зазвичай не можеш перевести годинник сервера. Тому не «підлаштовуйся під сьогодні», а став дату явно: створюй підписку з
expiresAtза рік уперед, купон — з датою в майбутньому. Тоді результат не залежить від дня прогону.
Окремий бік детермінізму — ідемпотентність повторних запитів (повторний PUT/DELETE, Idempotency-Key на POST): без неї ретрай тесту чи мережевий збій створює дублі. Це тема глави Тест-дизайн для API.
Спільне оточення і чужі дані
Ідеал — свіжа ізольована база на кожен прогін. Реальність — один staging-стенд, на якому одночасно працюють автотести, мануальники, розробники й демо для замовника. Це спільне оточення (shared environment) з усіма його «сусідами по кімнаті».
Головні пастки спільного стенду:
- Залежність від передіснуючих даних. Тест припускає, що «користувач з
id=1— це адмін Іван». Хтось перейменував Івана або видалив запис — тест падає, хоча застосунок здоровий. Не спирайся на дані, яких сам не створював. - Асерти на глобальні лічильники. Перевірка «
GET /usersповертає рівно 5 записів» приречена: на спільному стенді користувачів то 5, то 47. Перевіряй наявність свого створеного запису у відповіді, а не точну кількість усіх. - Гонки між воркерами. Два паралельних тести беруть «перший вільний промокод» — і хапають один і той самий. Вирішується виділенням даних на воркер (див. ізоляцію вище).
- Ефемерні середовища. Радикальний вихід — не ділити стенд узагалі, а піднімати чисте оточення на кожен прогін (докеризована база, ефемерний namespace). Дорожче в інфраструктурі, але прибирає весь клас «чужих даних».
Проста дисципліна: тест відповідає тільки за дані, які створив сам, і не робить припущень про решту стенду. Тоді сусіди по оточенню перестають бути джерелом фантомних падінь.
Типові помилки
- Виглядає як баг створення (
POSTнібито не записує), а насправді читання йде з репліки чи індексу з лагом — потрібен полінг, а не баг-репорт. - Виглядає як надійна пауза
sleep(2000), а насправді на завантаженому CI двох секунд бракує — плаваючий флак замість детермінованого очікування. - Виглядає як незалежний тест, а насправді він спирається на юзера
id=1, якого хтось перейменував на спільному стенді — чужі дані. - Виглядає як детермінований сценарій, а насправді відповідь залежить від реального курсу валют або поточної дати — незамокана зовнішня залежність.
- Виглядає як ізоляція «бо є
afterEachз прибиранням», а насправді тест упав до клінапу й лишив сміття, а наступний прогін наткнувся на дубль — унікальні дані надійніші за прибирання. - Виглядає як швидкий сід через прямий
INSERT, а насправді він обійшов інваріанти застосунку й створив стан, який продукт ніколи не згенерував би — «неможливий» баг у тесті.
Підсумок
- У розподіленій системі між записом і читанням є вікно неузгодженості (eventual consistency);
202 Accepted— прямий сигнал «результат ще не готовий». - Фіксований
sleepпрограє завжди: або флак, або втрачений час. Заміна — полінг на конкретну умову з верхнім таймаутом та ідемпотентною перевіркою. - Сідь стан через публічний API за замовчуванням; пряма БД — лише для дорогого чи недосяжного стану, і з ризиком порушити бізнес-інваріанти.
- Ізоляцію дає власність над даними: унікальні ідентифікатори, окремий акаунт або tenant. Це надійніше за прибирання після тесту.
- Тест відповідає лише за створене ним і не спирається на чужі дані спільного стенду; недетерміновані залежності (час, сторонні API, рандом) — мокати або фіксувати даними.
Що питають на співбесіді
- «Тест створює запис і одразу його не бачить. Що не так?» Інтерв'юер перевіряє, чи згадаєш eventual consistency (репліки, черги, індекси,
202), а не кинешся правити тест хардкодом. Сильна відповідь: спершу з'ясувати, синхронна операція чи асинхронна, і замінити гонку полінгом на умову. - «Чому
sleep— погано і чим його замінити?» Класика на розуміння флаку. Треба показати, що фіксована пауза погана в обидва боки, і описати полінг: умова замість часу, таймаут, ідемпотентність перевірки. - «Як готуєш тестові дані: через API чи прямо в базу?» Дивляться на зрілість. Очікують не догму, а компроміс: за замовчуванням API (валідність, стійкість до міграцій), пряма БД — виняток для дорогого стану, з усвідомленням ризику обійти інваріанти.
- «Як ізолюєш тести на спільному стенді?» Перевіряють, чи мислиш власністю над даними: унікальні значення, окремий акаунт/tenant, а не «почистимо базу перед прогоном». Плюс — згадка гонок між паралельними воркерами.
- «Тест залежить від сьогоднішньої дати / зовнішнього сервісу. Що зробиш?» Хочуть почути про детермінізм: мок або sandbox для сторонніх залежностей, а час контролювати через явні дати в сіді, а не через реальний годинник.
Джерела
- MDN — 202 Accepted — семантика асинхронної відповіді «прийнято, обробляю».
- MDN — 201 Created — синхронне створення ресурсу для контрасту з
202. - Playwright — Test assertions (
expect.poll,toPass) — офіційна механіка полінгу на умову з таймаутом. - Playwright — API testing —
APIRequestContextдля сідінгу стану й перевірок через API. - Силабус ISTQB CTFL 4.0 (istqb.org) — підготовка тестових даних і тестового середовища як частина тестового процесу (test implementation).
Тест робить POST, отримує 201, одразу читає GET — і бачить 404. Що сталося?
Найімовірніше це не баг застосунку, а кінцева узгодженість (eventual consistency): між записом і читанням у розподіленій системі є вікно, коли створене «ще не існує» для того шляху, яким ти його читаєш. Запис міг піти в майстер-базу, а GET обслужила репліка, яка ще не отримала оновлення; або дані лягли в основне сховище, але ще не потрапили в пошуковий індекс чи кеш. Тест просто випередив систему: спитав раніше, ніж запис доїхав до того сховища, з якого йде читання. Перше, що робить сильний кандидат, — не кидається правити тест хардкодом, а з'ясовує, синхронна операція чи асинхронна, і замінює гонку полінгом на конкретну умову. Баг-репорт «POST не записує» тут майже завжди хибний.
Що означає код 202 Accepted і чим він відрізняється від 201 Created?
201 Created — ресурс уже створено, його можна одразу читати. 202 Accepted — сервер прийняв запит, але обробка ще триває: результат буде колись потім, а не в цю мить. Тобто 202 — це відкритий сигнал сервера «не читай наосліп, я ще не готовий», типовий для операцій через чергу повідомлень. Для тесту різниця практична: побачив 202 — не став асерт на GET одразу, а чекай появи даних полінгом. Але й 201 не гарантує миттєвої видимості: лаг буває і за нього, якщо читання йде іншим шляхом, ніж запис (репліка, індекс, кеш).
Звідки взагалі береться затримка між записом і читанням?
Чотири типові джерела. Черги повідомлень: POST лише публікує подію (Kafka, RabbitMQ) і відповідає 202, а справжня обробка наздоганяє асинхронно. Репліки на читання: писали в майстер, а читає GET з репліки, яка відстає на replication lag. Пошукові індекси: у базі об'єкт уже є, а Elasticsearch перебудовує індекс власним циклом — типово порядку секунди. Кеші: відповідь для списку вже закешована, тож новинка з'явиться там лише після інвалідації. Спільна риса всіх чотирьох — читання йде не тим шляхом, що запис, тож між ними виникає вікно неузгодженості.
Чому sleep(2000) перед перевіркою — погане рішення?
Фіксована пауза програє в обидва боки. Занадто коротка: на повільному CI-агенті лаг раптом виявляється більшим за паузу — тест падає, і це плаваючий флак. Занадто довга: дані насправді готові за сотні мілісекунд, але кожен тест відстоює паузу повністю, і на сюїті в кілька сотень тестів це хвилини, викинуті в нікуди. Пауза прив'язана до часу, а не до факту готовності даних, тож вона або мало чекає, або марно тринькає час — вгадати «правильне» число неможливо, бо лаг плаває від прогону до прогону. Заміна — полінг на конкретну умову.
Що таке полінг і чим він кращий за sleep?
Полінг (polling) — це періодичне перепитування системи, доки умова не справдиться, з жорстким верхнім таймаутом. Замість чекати абстрактні «дві секунди» тест чекає на конкретний спостережуваний факт: статус 200, поле status: "processed", наявність елемента у списку. Щойно дані фактично готові — тест рушає далі; фейл настає лише після вичерпання таймаута. У Playwright це expect.poll або toPass з таймаутом та інтервалами. Полінг — API-варіант «розумного очікування», того самого, що в браузері дає авточекання Playwright: чекаємо на стан, а не на годинник.
Які властивості роблять полінг надійним?
Три. Умова, а не час: чекаємо на спостережуваний факт, а не на кількість секунд. Верхній таймаут: без стелі полінг здатен висіти вічно, а зависання діагностувати важче, ніж падіння; таймаут конвертує «система не відповідає» в чесний фейл із зрозумілим стектрейсом. Ідемпотентна перевірка: функція всередині полінгу має бути безпечною для багаторазового виклику, тобто тільки читання, без побічних ефектів. Останнє критичне: полити POST не можна — на кожній ітерації створиш нове замовлення й отримаєш десять дублів замість одного. Полінг завжди накручують на GET.
Як ти готуєш тестові дані — через публічний API чи прямо в базу?
Тут інтерв'юер чекає не догму, а компроміс. За замовчуванням — сідінг (seeding) через публічний API: тест викликає ті самі ендпоінти, що й реальний клієнт (POST /users, POST /orders). Дані, які поставив сам застосунок, узгоджені за побудовою: зовнішні ключі, обчислені поля й супутні події створює продукт, а не тест; такий сід стійкий до змін схеми, бо спирається на контракт API, а не на структуру таблиць. Прямий INSERT у базу швидший і дозволяє поставити будь-який стан, але легко порушує бізнес-інваріанти й ламається на кожній міграції. Тож правило: сідь через API, поки можеш; пряма БД — усвідомлений виняток, а не звичка.
Коли виправданий прямий запис у базу в обхід застосунку?
Коли стан дорого або неможливо відтворити через API. Приклади: історичні дані «за минулий рік», які застосунок сьогодні вже не створює; стан, що належить іншому мікросервісу; спеціально «зіпсований» запис для негативного сценарію. Але навіть тоді краще шукати проміжний варіант — тестовий бекдор-ендпоінт. Головний ризик прямого INSERT: вставлений в обхід логіки запис може не мати супутніх сутностей (рахунка, події, обчисленого поля), які продукт створює завжди, — і ти довго дебажитимеш стан, якого в реальній системі не буває. Тобто швидкість купується ціною ризику обійти інваріанти, які насправді тримають систему коректною.
Що таке тестовий бекдор-ендпоінт і навіщо він потрібен?
Це спеціальний ендпоінт (наприклад, POST /test/seed), доступний лише на не-продакшн-середовищах, який ставить потрібний стан валідно, але швидко — в обхід повільного публічного флоу, але не в обхід бізнес-правил. Він розв'язує конфлікт між двома крайнощами: сідінг через публічний API валідний, але повільний і не завжди може поставити складний стан; прямий INSERT швидкий, але ризикує порушити інваріанти. Бекдор дає золоту середину — застосунок сам будує стан коректно, але тобі не треба проганяти десяток реальних кроків. Ключова умова безпеки: такий ендпоінт ніколи не має бути доступним у проді.
Як ти ізолюєш тести, щоб вони не заважали одне одному?
Базовий принцип — власність над даними: кожен тест володіє своїми даними й не спирається на чужі. Практично це три рівні. Унікальні ідентифікатори: ніколи не хардкодь email: "test@test.com" — генеруй унікальне значення на кожен прогін (таймстемп плюс випадковий суфікс), інакше другий паралельний запуск отримає конфлікт унікальності. Окремий акаунт на тест або воркер: якщо тести змінюють профіль, баланс, налаштування — дай кожному свого користувача, тоді паралельні тести фізично не бачать даних одне одного. Окремий tenant у багатоорендних системах — найсильніша ізоляція. Ізоляція через дані майже завжди дешевша й надійніша за ізоляцію через прибирання.
Окремий акаунт і окремий tenant — у чому різниця й де ізоляція сильніша?
Окремий акаунт розділяє записи одного користувача: кожному тесту свій юзер, тож вони не затирають одне одному профіль чи баланс. Окремий tenant (орендар — організація, робочий простір у multi-tenant SaaS) розділяє значно більше: не лише записи, а й довідники, ліміти, налаштування — усе, що система тримає per-tenant. Один орендар не бачить даних іншого за побудовою системи, а не завдяки дисципліні тесту. Тому tenant — найсильніший рівень ізоляції: навіть глобальні для акаунта сутності в різних тенантів різні. Мінус — не кожна система багатоорендна, і виділити тесту чистий tenant буває дорожче, ніж просто нового юзера.
Чому унікальні дані надійніші за прибирання в afterEach?
Прибирання (DELETE в afterEach) спрацьовує, тільки якщо тест дійшов до нього. А якщо тест упав до клінапу — впала асерція, обірвалась мережа, — сміття лишається, і наступний прогін наткнеться на дубль чи конфлікт унікальності. Унікальні дані працюють інакше: навіть якщо попередній прогін лишив по собі юзера з email від старого таймстемпу, новий прогін згенерує інший email і просто його не зачепить. Тобто унікальність — це перша лінія оборони, яка не залежить від того, чи виконався клінап. Прибирання теж потрібне, щоб стенд не заростав сміттям, але покладатися тільки на нього небезпечно.
Що робить тест недетермінованим і як це лікувати?
Тест детермінований, якщо на незмінному вході дає незмінний результат; ламають це недетерміновані зовнішні залежності. Три класи: сторонні API (платіжний шлюз, курс валют, погода) — твій зелений/червоний починає залежати від чужого аптайму; час (логіка «підписка активна ще 3 дні», «купон діє до кінця місяця») — тест, зелений у липні, червоніє 1 числа наступного місяця; випадковість (промокоди, A/B-когорти, рандомні ID). Лікування: мокати залежність контрольованим дублером, використовувати офіційний sandbox провайдера з «магічними» значеннями, а час контролювати через дані — ставити expiresAt явно на рік уперед, а не підлаштовуватися під «сьогодні».
Тест залежить від сьогоднішньої дати. Як зробити його стабільним?
З API-тесту ти зазвичай не можеш перевести годинник сервера, тому не намагайся «підлаштуватися під сьогодні» — навпаки, задай дату явно в даних. Замість створювати підписку й сподіватися, що вона активна саме зараз, створюй її з expiresAt за рік уперед; замість покладатися на «поточний купон» став купон із датою дії в майбутньому. Тоді результат перевірки не залежить від дня прогону: інваріант «активна підписка видима» перевіряється на даних, які будуть активними завжди. Це і є «керувати часом через дані, а не через тест» — зсув відповідальності з непідконтрольного годинника на підконтрольне поле в сіді.
Чому асерт «GET /users повертає рівно 5 записів» приречений на спільному стенді?
Бо на спільному стенді (staging) одночасно працюють автотести, мануальники, розробники й демо для замовника — користувачів то 5, то 47, і точна кількість постійно змінюється. Асерт на глобальний лічильник ловить не поведінку застосунку, а випадковий зріз бази в момент прогону, тож він флакне без жодного бага. Правильний підхід — перевіряти наявність свого створеного запису у відповіді, а не точну кількість усіх: створив юзера з унікальним email, а потім асертиш, що він є у списку. Це загальна дисципліна спільного оточення: тест відповідає лише за дані, які створив сам, і не робить припущень про решту стенду.
Тест зелений локально, а в CI падає з 409 або дублями. Де копати?
Симптом майже завжди вказує на дизайн тестових даних і ізоляцію, а не на баг продукту. 409 Conflict на повторному прогоні означає, що дані з попереднього запуску лишилися (клінап не спрацював) або тест хардкодить неунікальне значення — лікується унікальними ідентифікаторами на кожен прогін. Дублі під паралелізмом — це гонки між воркерами: два паралельних тести беруть «перший вільний промокод» і хапають один і той самий; вирішується виділенням даних на воркер. Те, що локально зелено, а в CI ні, — типова ознака саме конкурентності й спільного стану: локально тести йдуть послідовно й по черзі, а в пайплайні паралельно гатять в одні дані. Копай ізоляцію, а не застосунок.
Ідеал — свіжа база на кожен прогін. Що робити, коли є лише один спільний staging?
Прийняти, що ти в спільному оточенні (shared environment), і жити за його правилами. Не залежати від передіснуючих даних: не припускай, що «юзер id=1 — це адмін Іван», бо хтось міг його перейменувати чи видалити. Не асертити глобальні лічильники — тільки наявність свого запису. Виділяти дані на воркер, щоб прибрати гонки. А якщо клас «чужих даних» болить надто сильно, радикальний вихід — ефемерні середовища: піднімати чисте оточення на кожен прогін (докеризована база, ефемерний namespace). Це дорожче в інфраструктурі, але прибирає весь клас проблем спільного стенду одним рішенням. Проста дисципліна на кожен день: тест відповідає лише за створене ним.
Що таке ідемпотентність запиту і чому вона важлива для стабільності тестів?
Ідемпотентність — властивість запиту давати той самий результат при повторному виклику: повторний PUT чи DELETE не змінює стан удруге, а POST роблять ідемпотентним через Idempotency-Key. Для тестів це важливо на двох рівнях. По-перше, ретрай тесту чи мережевий збій без ідемпотентності створює дублі: тест «переслав» POST, і замість одного замовлення стало два. По-друге, функція всередині полінгу мусить бути ідемпотентною (лише читання) — саме тому полять GET, а не POST. Тобто ідемпотентність — це те, що робить безпечними і повторні спроби, і саме очікування на умову.
Три кейси, де колір тесту визначає не логіка застосунку, а спосіб підготовки й читання даних: полінг замість sleep на асинхронному створенні, сідінг стану через API з унікальними даними, і детермінізм часу через явну дату в сіді замість реального годинника. Скрізь — що робити і чому саме так.
Кейс 1. POST → 202, а GET дає 404: полінг замість sleep
Сценарій із життя: тест створює замовлення, сервер відповідає 202 Accepted (обробка пішла в чергу), тест одразу читає GET /orders/{id} — і ловить 404. Розробник каже «у мене все створюється», і він має рацію: за секунду замовлення на місці. Проблема — тест поставив питання раніше, ніж репліка/черга встигли застосувати запис.
Найгірша реакція — заткнути паузою:
// Погано: фіксована пауза — або флак на завантаженому CI, або марна трата часу
const created = await request.post('/api/orders', { data: { sku: 'A-1', qty: 2 } });
const { id } = await created.json();
await new Promise((r) => setTimeout(r, 2000)); // 2с наосліп
const res = await request.get(`/api/orders/${id}`);
expect(res.status()).toBe(200);
Правильно — чекати саме на умову, з верхнім таймаутом:
import { test, expect } from '@playwright/test';
test('замовлення стає видимим після асинхронної обробки', async ({ request }) => {
const created = await request.post('/api/orders', { data: { sku: 'A-1', qty: 2 } });
expect(created.status()).toBe(202); // прийнято, обробляю
const { id } = await created.json();
// полінг на конкретний факт: GET віддав 200
await expect
.poll(async () => (await request.get(`/api/orders/${id}`)).status(), {
timeout: 10_000,
intervals: [250, 500, 1000],
})
.toBe(200);
// а тепер уже безпечно асертити вміст
const res = await request.get(`/api/orders/${id}`);
expect(await res.json()).toMatchObject({ id, status: 'processed' });
});
Що дивитися і чому:
202замість201— це не помилка, а сигнал. Сервер прямо каже «результат ще не готовий, не читай наосліп». АсертGETодразу після такогоPOST— гонка за побудовою.- Полять
GET, а неPOST. Функція всерединіexpect.pollвикликається багато разів, тож вона мусить бути ідемпотентною — тільки читання. Якби всередину поставилиPOST, кожна ітерація створювала б нове замовлення. - Умова, а не час. Чекаємо на
status() === 200(спостережуваний факт), а не на «дві секунди». Тест іде далі рівно тоді, коли дані з'явилися. - Верхній таймаут обов'язковий. Без
timeoutполінг зависне назавжди, якщо дані так і не з'являться. Таймаут перетворює «система тупить нескінченно» на чесний фейл зі стектрейсом.
Кейс 2. Сідінг передумови через API з унікальними даними
Тесту потрібен користувач у стані «є оплачене замовлення». Спокуса — прописати фіксований email і зробити прямий INSERT. Обидва рішення стріляють у ногу: фіксований email дасть 409 Conflict на другому паралельному прогоні, а прямий INSERT може створити замовлення без рахунка, якого застосунок ніколи не згенерував би.
Надійний варіант — сідити через ті самі ендпоінти, що й реальний клієнт, з унікальними даними на кожен прогін:
import { test, expect } from '@playwright/test';
import { faker } from '@faker-js/faker';
test('оплачене замовлення видиме у списку користувача', async ({ request }) => {
// унікальний email — жодних конфліктів між паралельними воркерами
const email = `qa+${Date.now()}-${faker.string.alphanumeric(6)}@example.com`;
// сід через публічний API: застосунок сам ставить валідний стан
const user = await request.post('/api/users', { data: { email, role: 'customer' } });
expect(user.status()).toBe(201);
const { id: userId } = await user.json();
const order = await request.post('/api/orders', {
data: { userId, sku: 'A-1', qty: 1, status: 'paid' },
});
expect(order.status()).toBe(201);
const { id: orderId } = await order.json();
// асерт на НАЯВНІСТЬ свого запису, а не на кількість усіх
const list = await request.get(`/api/users/${userId}/orders`);
const orders = await list.json();
expect(orders.some((o) => o.id === orderId)).toBe(true);
});
Що дивитися і чому:
emailунікальний на кожен прогін.Date.now()плюс випадковий суфікс прибирають конфлікт унікальності — перша лінія оборони проти зіткнень, надійніша за прибирання вafterEach.- Сід іде через API, а не
INSERT. Застосунок сам будує валідний стан: правильні зовнішні ключі, обчислені поля, події в черзі. Прямий запис у базу цього не гарантує. - Асерт на наявність свого запису.
orders.some(...)перевіряє, що мій ордер у списку, а не «рівно N замовлень». На спільному стенді точна кількість постійно змінюється, тож асерт на лічильник флакне без бага. - Коли API замало. Якщо стан дорогий чи недосяжний (історичні дані, «зіпсований» запис), тоді розглядають тест-бекдор
POST /test/seedна не-проді — валідно, але швидко; прямийINSERT— крайній випадок з усвідомленням ризику.
Кейс 3. Детермінізм: явна дата в сіді замість реального годинника
Тест перевіряє інваріант «активна підписка видима в кабінеті». Наївна реалізація створює підписку «на 30 днів» і сподівається, що зараз вона активна. Такий тест зелений у липні й червоніє на межі місяця чи коли CI підхопить іншу таймзону — класична недетермінована залежність від поточної дати.
З API-тесту ти зазвичай не можеш перевести годинник сервера, тож керуй часом через дані: став дату явно й із запасом.
import { test, expect } from '@playwright/test';
test('активна підписка видима незалежно від дня прогону', async ({ request }) => {
// не «сьогодні + 30 днів», а явна дата далеко в майбутньому
const expiresAt = new Date('2999-01-01T00:00:00Z').toISOString();
const sub = await request.post('/api/subscriptions', {
data: { plan: 'pro', expiresAt },
});
expect(sub.status()).toBe(201);
const { id } = await sub.json();
const res = await request.get(`/api/subscriptions/${id}`);
expect(await res.json()).toMatchObject({ id, status: 'active' });
});
Що дивитися і чому:
- Дата задана явно, а не відносно «сьогодні». Інваріант «активна підписка активна» перевіряється на даних, які будуть активними завжди, тож результат не залежить від дня прогону чи таймзони агента.
- Зсув відповідальності. Замість підлаштовуватися під непідконтрольний годинник сервера ти ставиш підконтрольне поле
expiresAtу сіді. Те саме працює для купонів, промо, тріалів. - Сторонні залежності — окрема історія. Якщо в грі платіжний шлюз чи курс валют, дату даними не полагодиш — там потрібен мок або офіційний sandbox провайдера з «магічними» значеннями (докладніше — у главі про мокання залежностей).
- Не забувай про ідемпотентність. Якщо тест ретраїться, повторний
POSTбезIdempotency-Keyможе створити дубль підписки; для критичних створень варто передбачити ключ ідемпотентності.
Eventual consistency і стан
- Розумію, що в розподіленій системі між записом і читанням є вікно неузгодженості — створене «ще не існує» для того шляху, яким його читаю.
- Знаю чотири джерела лагу: черги повідомлень, репліки на читання (replication lag), пошукові індекси, кеші.
- Можу пояснити, чому
202 Accepted— це відкритий сигнал «результат ще не готовий», на відміну від201 Created. - Знаю, що несподіваний
404післяPOSTчасто маскує не баг створення, а читання з репліки/індексу з лагом.
Полінг проти sleep
- Можу пояснити, чому фіксований
sleepпрограє в обидва боки: занадто короткий дає флак, занадто довгий тринькає час усієї сюїти. - Знаю три властивості надійного полінгу: умова замість часу, верхній таймаут, ідемпотентна перевірка.
- Розумію, чому полити
POSTне можна — на кожній ітерації створиться новий запис; полять лишеGET.
Сідінг тестових даних
- Знаю різницю між сідінгом через публічний API і прямим
INSERTу базу за п'ятьма критеріями (валідність, швидкість, крихкість до схеми, доступність із CI, досяжність складного стану). - Розумію правило за замовчуванням: сідь через API, поки можеш — дані застосунку гарантовано узгоджені.
- Розумію головний ризик прямого
INSERT: обійти бізнес-інваріанти й створити «неможливий» стан, якого продукт не генерує.
Ізоляція через власність над даними
- Розумію базовий принцип: кожен тест володіє своїми даними й не спирається на чужі.
- Знаю, чому не можна хардкодити
email— конфлікт унікальності на паралельному запуску; генерую унікальне значення на кожен прогін. - Можу пояснити різницю між окремим акаунтом (розділяє записи юзера) і окремим tenant (розділяє записи, довідники, ліміти, налаштування).
- Можу обґрунтувати, чому унікальні дані надійніші за прибирання:
afterEachне спрацює, якщо тест упав до клінапу.
Детермінізм зовнішніх залежностей
- Знаю три класи недетермінізму: сторонні API, час, випадковість.
- Можу пояснити стратегії: мокати залежність, використати офіційний sandbox, керувати часом через дані.
- Знаю, що ідемпотентність повторних запитів (
Idempotency-Key, повторнийPUT/DELETE) захищає від дублів при ретраях.
Спільне оточення і чужі дані
- Розумію пастку залежності від передіснуючих даних: «юзер
id=1— це адмін Іван» падає, коли його перейменували. - Знаю, чому асерт на глобальний лічильник («рівно 5 користувачів») приречений на спільному стенді.
- Можу пояснити гонки між воркерами («перший вільний промокод») і що лікуються вони виділенням даних на воркер.
Тест робить POST, отримує 201, одразу читає GET того самого ресурсу — і бачить 404. Найімовірніша причина?
Питання
Що таке eventual consistency (кінцева узгодженість)?