Тестові дані та стан в API-тестах
Зміст
Тест робить POST /orders, отримує 202 Accepted, одразу перевіряє 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 таких вольностей не дає: за MDN ресурс (або його опис і посилання на нього) створюється до того, як повернуто відповідь, і новостворене має бути досяжним за URL самого запиту або за URL із заголовка Location. Тому 201, після якого GET за тим самим URL віддає 404, — це порушення контракту : дефект постачальника або неправильно обраний код. Перше, що зʼясовує тестувальник у такій ситуації, — який саме код віддає .
Мораль: між «створив» і «побачив» у розподіленій системі є вікно неузгодженості. Тест мусить його пережити, а не вдавати, що його немає.
Полінг із таймаутом замість 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: брак ізоляції, асинхронність, віддалені сервіси, час і витоки ресурсів. У цій главі нас цікавить зріз недетермінованих зовнішніх залежностей — усього, що застосунок бере зі світу поза власною базою:
- Сторонні 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— прямий сигнал «результат ще не готовий», а201навпаки обіцяє, що ресурс уже досяжний, тож201із наступним404— дефект. - Фіксований
sleepпрограє завжди: або флак, або втрачений час. Заміна — полінг на конкретну умову з верхнім таймаутом та ідемпотентною перевіркою. - Сідь стан через публічний API за замовчуванням; пряма БД — лише для дорогого чи недосяжного стану, і з порушити бізнес-інваріанти.
- Ізоляцію дає власність над даними: унікальні ідентифікатори, окремий акаунт або tenant. Це зазвичай надійніше за прибирання після тесту — головно тому, що з прибиранням баг і падіння розʼїжджаються по різних тестах.
- Тест відповідає лише за створене ним і не спирається на чужі дані спільного стенду; недетерміновані залежності (час, сторонні API, рандом) — мокати або фіксувати даними.
Можливі питання
- «Тест створює запис і одразу його не бачить. Що не так?» Інтерв'юер перевіряє, чи згадаєш eventual consistency (репліки, черги, індекси,
202), а не кинешся правити тест хардкодом. Сильна відповідь: спершу з'ясувати, синхронна операція чи асинхронна, і замінити гонку полінгом на умову. - «Чому
sleep— погано і чим його замінити?» Класика на розуміння флаку. Треба показати, що фіксована пауза погана в обидва боки, і описати полінг: умова замість часу, таймаут, ідемпотентність перевірки. - «Як готуєш тестові дані: через API чи прямо в базу?» Дивляться на зрілість. Очікують не догму, а компроміс: за замовчуванням API (валідність, стійкість до міграцій), пряма БД — виняток для дорогого стану, з усвідомленням ризику обійти інваріанти.
- «Як ізолюєш тести на спільному стенді?» Перевіряють, чи мислиш власністю над даними: унікальні значення, окремий акаунт/tenant, а не «почистимо базу перед прогоном». Плюс — згадка гонок між паралельними воркерами.
- «Тест залежить від сьогоднішньої дати / зовнішнього сервісу. Що зробиш?» Хочуть почути про детермінізм: мок або sandbox для сторонніх залежностей, а час контролювати через явні дати в сіді, а не через реальний годинник.
Джерела
Eventual consistency: чому створене «ще не існує»
- MDN — 202 Accepted —
202означає, що запит прийнято, а обробка не завершена або навіть не почалася; відповідь non-committal, і каналу для пізнішого результату протокол не дає. - MDN — 201 Created — для контрасту: при
201ресурс створено до відповіді й він зобовʼязаний бути досяжним за URL запиту абоLocation. - Apache Kafka — Introduction (документація 4.0) — джерело лагу на рівні інфраструктури: реплікація виконується на рівні топіка, тож читання може обслуговувати не той брокер, що приймав запис.
- AWS — DynamoDB read consistency — «eventually consistent» — офіційна назва режиму читання за замовчуванням: відповідь може не відображати щойно завершений запис.
Полінг із таймаутом замість sleep
- Martin Fowler — Eradicating Non-Determinism in Tests — заборона сформульована прямо: «Never use bare sleeps to wait for asynchonous responses: use a callback or polling», і стеля очікування має бути навмисно високою.
- Playwright — page.waitForTimeout (Discouraged) — той самий висновок канонічно: метод паузи позначено як Discouraged, а тести, що чекають на час, названо «inherently flaky».
- Playwright — Test assertions (expect.poll, toPass) —
expect.pollперетворює синхронний асерт на полінг: дефолтний таймаут — 5 секунд, інтервали[100, 250, 500, 1000]мс,timeout: 0вимикає стелю.
Сідінг даних: через публічний API чи напряму в БД
- ISTQB CTFL Syllabus v4.0.1 — підготовка даних — планована активність процесу: вимоги до визначає тест-дизайн, а створює їх test implementation.
- Playwright — API testing — підготовка серверного стану перед відкриттям застосунку — офіційно назване застосування контексту запитів, а не обхідний шлях.
Ізоляція: свій акаунт, свій tenant
- xUnit Test Patterns — Erratic Test — взаємодія тестів зазвичай спричинена спільною фікстурою, а паралельний прогін над спільним станом (
Test Run War) можливий лише за її наявності. - Martin Fowler — Eradicating Non-Determinism in Tests — дві школи: ізоляція проти явно оголошених залежностей із заданим порядком; джерело віддає перевагу ізоляції за гнучкість.
Детермінованість зовнішніх залежностей
- Martin Fowler — Eradicating Non-Determinism in Tests — канонічний перелік джерел недетермінізму: брак ізоляції, асинхронність, віддалені сервіси, час і витоки ресурсів.
- Mailtrap — офіційний сайт продукту (Email Sandbox) — приклад офіційної замість реальної залежності: пошту перехоплює тестове середовище, а не справжній поштовий сервер.
- Testcontainers — Getting started — спільні для кількох користувачів чи ресурси роблять результати недетермінованими через псування даних і дрейф конфігурації.
- Testcontainers (титульна сторінка) — ефемерна альтернатива: одноразові інстанси залежностей, які створюються й видаляються разом із прогоном, дають «known state» на старті.
- xUnit Test Patterns — Erratic Test — маркер чужого впливу: тест сам по собі зелений, а в сюїті падає від додавання чи вилучення іншого.
- MDN — 201 Created —
201обіцяє досяжність новоствореного ресурсу, тож201із наступним404— дефект контракту, а не привід для полінгу. - Playwright — page.waitForTimeout (Discouraged) — пауза дозволена лише для дебагу; заміна — не довша пауза, а сигнал або умова.
- xUnit Test Patterns — Erratic Test — «перший запуск інакший» — окремий патерн
Unrepeatable Testіз тією самою причиною: спільна фікстура. - Martin Fowler — Eradicating Non-Determinism in Tests — час і віддалені сервіси стоять у переліку джерел недетермінізму поряд із браком ізоляції.
Тест робить POST, отримує 202, одразу читає GET — і бачить 404. Що сталося?
Найімовірніше це не баг застосунку, а (eventual consistency): між записом і читанням у розподіленій системі є вікно, коли створене «ще не існує» для того шляху, яким ти його читаєш. Запис міг піти в майстер-базу, а GET обслужила репліка, яка ще не отримала оновлення; або дані лягли в основне сховище, але ще не потрапили в пошуковий індекс чи кеш. Тест просто випередив систему: спитав раніше, ніж запис доїхав до того сховища, з якого йде читання. Перше, що робить сильний кандидат, — не кидається правити тест хардкодом, а з'ясовує, який код віддає і синхронна операція чи асинхронна, і замінює гонку на конкретну умову. Баг-репорт «POST не записує» тут майже завжди хибний — але тільки поки код відповіді 202: якби POST віддав 201, той самий симптом був би вже порушенням контракту .
Що означає код 202 Accepted і чим він відрізняється від 201 Created?
201 Created — ресурс уже створено, його можна одразу читати. 202 Accepted — сервер прийняв запит, але обробка ще триває: результат буде колись потім, а не в цю мить. Тобто 202 — це відкритий сигнал сервера «не читай наосліп, я ще не готовий», типовий для операцій через чергу повідомлень. Для тесту різниця практична: побачив 202 — не став на GET одразу, а чекай появи даних полінгом. А 201 навпаки гарантує видимість: ресурс створюється до повернення відповіді й має бути досяжним за URL запиту або за Location, тож 201 із наступним 404 — це дефект постачальника або неправильно обраний код, а не привід ставити полінг.
Звідки взагалі береться затримка між записом і читанням?
Чотири типові джерела. Черги повідомлень: 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?
Головна вада стратегії прибирання — не залишки, а діагностованість: баг сидить в одному тесті, а червоніє інший, і знайти справжню причину важко. Якщо ж тест сам не побудував собі початковий стан, видно одразу, який саме тест зламаний. Унікальні дані працюють у цю ж користь: навіть якщо попередній прогін лишив по собі юзера з email від старого таймстемпу, новий прогін згенерує інший email і просто його не зачепить. Тобто унікальність — це перша лінія оборони. Прибирання теж потрібне, щоб стенд не заростав сміттям, але покладатися тільки на нього небезпечно.
Що робить тест недетермінованим і як це лікувати?
Тест детермінований, якщо на незмінному вході дає незмінний результат. Канонічний перелік джерел недетермінізму — брак ізоляції, асинхронність, віддалені сервіси, час і витоки ресурсів; на рівні API найчастіше вилазять недетерміновані зовнішні залежності. Три класи: сторонні API (платіжний , курс валют, погода) — твій зелений/червоний починає залежати від чужого аптайму; час (логіка «підписка активна ще 3 дні», «купон діє до кінця місяця») — тест, зелений у липні, червоніє 1 числа наступного місяця; випадковість (промокоди, A/B-когорти, рандомні ID). Лікування: залежність контрольованим дублером, використовувати офіційний sandbox провайдера з «магічними» значеннями, а час контролювати через дані — ставити expiresAt явно на рік уперед, а не підлаштовуватися під «сьогодні».
Тест залежить від сьогоднішньої дати. Як зробити його стабільним?
З API-тесту ти зазвичай не можеш перевести годинник сервера, тому не намагайся «підлаштуватися під сьогодні» — навпаки, задай дату явно в даних. Замість створювати підписку й сподіватися, що вона активна саме зараз, створюй її з expiresAt за рік уперед; замість покладатися на «поточний купон» став купон із датою дії в майбутньому. Тоді результат перевірки не залежить від дня прогону: інваріант «активна підписка видима» перевіряється на даних, які будуть активними завжди. Це і є «керувати часом через дані, а не через тест» — зсув відповідальності з непідконтрольного годинника на підконтрольне поле в сіді.
Чому асерт «GET /users повертає рівно 5 записів» приречений на спільному стенді?
Бо на спільному стенді (staging) одночасно працюють автотести, мануальники, розробники й демо для замовника — користувачів то 5, то 47, і точна кількість постійно змінюється. Асерт на глобальний лічильник ловить не поведінку застосунку, а випадковий зріз бази в момент прогону, тож він без жодного бага. Правильний підхід — перевіряти наявність свого створеного запису у відповіді, а не точну кількість усіх: створив юзера з унікальним email, а потім асертиш, що він є у списку. Це загальна дисципліна спільного оточення: тест відповідає лише за дані, які створив сам, і не робить припущень про решту стенду.
Тест зелений локально, а в CI падає з 409 або дублями. Де копати?
Симптом майже завжди вказує на дизайн і ізоляцію, а не на баг продукту. 409 Conflict на повторному прогоні означає, що дані з попереднього запуску лишилися (прибирання не спрацювало) або тест хардкодить неунікальне значення — лікується унікальними ідентифікаторами на кожен прогін. Дублі під паралелізмом — це гонки між воркерами: два паралельних тести беруть «перший вільний промокод» і хапають один і той самий; вирішується виділенням даних на воркер. Те, що локально зелено, а в CI ні, — типова ознака саме конкурентності (concurrency) — перекриття операцій у часі — і спільного стану: локально тести йдуть послідовно й по черзі, а в паралельно гатять в одні дані. Копай ізоляцію, а не застосунок.
Ідеал — свіжа база на кожен прогін. Що робити, коли є лише один спільний 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з202часто маскує не баг створення, а читання з репліки/індексу з лагом; після201той самий симптом — це вже порушення контракту .
Полінг проти sleep
- Можу пояснити, чому фіксований
sleepпрограє в обидва боки: занадто короткий дає , занадто довгий тринькає час усієї . - Знаю три властивості надійного : умова замість часу, верхній , ідемпотентна перевірка.
- Розумію, чому полити
POSTне можна — на кожній ітерації створиться новий запис; полять лишеGET.
Сідінг тестових даних
- Знаю різницю між через публічний API і прямим
INSERTу базу за п'ятьма критеріями (валідність, швидкість, крихкість до схеми, доступність із CI, досяжність складного стану). - Розумію правило за замовчуванням: через API, поки можеш — дані застосунку гарантовано узгоджені.
- Розумію головний прямого
INSERT: обійти бізнес-інваріанти й створити «неможливий» стан, якого продукт не генерує.
Ізоляція через власність над даними
- Розумію базовий принцип: кожен тест володіє своїми даними й не спирається на чужі.
- Знаю, чому не можна хардкодити
email— конфлікт унікальності на паралельному запуску; генерую унікальне значення на кожен прогін. - Можу пояснити різницю між окремим акаунтом (розділяє записи юзера) і окремим tenant (розділяє записи, довідники, ліміти, налаштування).
- Можу обґрунтувати, чому унікальні дані зазвичай надійніші за : із прибиранням баг сидить в одному тесті, а падає інший, тож справжню причину важко знайти (а перебудова бази на кожен прогін коштує часу — це аргумент у зворотний бік).
Детермінізм зовнішніх залежностей
- Знаю канонічний перелік джерел недетермінізму (брак ізоляції, асинхронність, віддалені сервіси, час, витоки ресурсів) і його API-зріз: сторонні API, час, випадковість у даних.
- Можу пояснити стратегії: залежність, використати офіційний sandbox, керувати часом через дані.
- Знаю, що повторних запитів (
Idempotency-Key, повторнийPUT/DELETE) захищає від дублів при .
Спільне оточення і чужі дані
- Розумію пастку залежності від передіснуючих даних: «юзер
id=1— це адмін Іван» падає, коли його перейменували. - Знаю, чому на глобальний лічильник («рівно 5 користувачів») приречений на спільному стенді.
- Можу пояснити гонки між («перший вільний промокод») і що лікуються вони виділенням даних на воркер.
Квіз
Перед стартом
- Питань: 15
- Поріг «зараховано»: ≥70% правильних відповідей.
- Результат впливає на прогрес; завалені питання підуть у чергу повторення.
- Квіз впливає на компліт теми: тема стає «пройдено», лише коли прочитано теорію І квіз складено на ≥70%.
Питання
Що таке eventual consistency (кінцева узгодженість)?