Тестові дані і БД в автотестах
Зміст
Кожен автотест починається з припущення: «на старті в системі є ось такий стан». Користувач із бонусним рахунком, замовлення у статусі «оплачено», порожній кошик. Звідки цей стан береться і як його прибрати після тесту — не деталь реалізації, а те, що вирішує, чи буде сюїта стабільною. Погана відповідь на це питання дає один із найпоширеніших класів флаку (flakiness): тест зелений наодинці й червоний у наборі, бо сусідній тест підмінив дані; два прогони конфліктують за одного й того самого користувача; на «брудній» базі перевірка бачить чуже.
Тема стоїть на межі SQL і автоматизації, тому інтерв'юери люблять її як лакмус: людина, яка розуміє, чому дані готують через API, а прибирають транзакцією або TRUNCATE, і чому на спільній базі це ламається під паралеллю, — думала про підтримку сюїти, а не лише писала кроки. Канонічна стратегія тестових даних (де саме готувати стан, коли одноразові акаунти, чому не чіпати прод) розібрана в розділі про автоматизацію; тут — SQL-механіка: сідінг, ізоляція, з'єднання з БД у коді тесту й перевірки на рівні таблиць.
Сіди, фікстури, фабрики
Три слова, які часто плутають, означають різні речі:
- Сід (seed) — фіксований набір даних, який заливають у базу перед прогоном: довідники, кілька еталонних користувачів, каталог. Один на все середовище, живе довго.
- Фікстура (fixture) — підготовлений стан під конкретний тест або групу тестів: «є замовлення в статусі pending». У Playwright фікстура — ще й механізм фреймворку, який дає тесту готовий об'єкт (сторінку, залогіненого користувача, з'єднання з БД) і прибирає його після.
- Фабрика (factory) — код, що будує сутність із розумними дефолтами і точковими перевизначеннями:
createUser({ role: 'admin' }). Разом із генератором фейкових даних (faker) фабрика дає щоразу свіжу, унікальну сутність без ручного копіпасту.
Фабрики масштабуються найкраще: сід — це стартовий каркас, фабрика — те, чим тест наповнює базу під свою перевірку, не покладаючись на чужі рядки.
Через API чи напряму в БД
Головна розвилка сідінгу: створити стан, постукавши у власний API застосунку (публічний ендпоінт або тестовий бекдор), чи вставити рядки напряму в базу через INSERT. Обидві двері ведуть до тієї самої таблиці, але наслідки різні.
Сідінг через API проходить крізь реальну валідацію і бізнес-логіку. Створюючи користувача ендпоінтом, ти автоматично отримуєш усе, що застосунок робить сам: хешування пароля, похідні поля, записи у зв'язаних таблицях, спрацьовані тригери, коректні дефолти. Такий стан гарантовано консистентний, бо його зробив сам продукт. Бонус: коли схема зміниться, API підлаштується, а твій сід — ні. Мінуси — повільніше (мережа плюс уся логіка) і ендпоінт мусить існувати; станів, яких публічний API не дозволяє (наприклад, «протермінована підписка»), так не створити.
Сідінг напряму в БД швидкий і всесильний: одним INSERT можна виставити будь-який стан, зокрема той, який застосунок ніколи не створить сам. Ціна — ти обходиш усю логіку. Забув похідний рядок, не порахував агрегат, не проставив зовнішній ключ (foreign key) — і в базі опиняється неможливий стан, якого в проді не буває, а тест або падає на порожньому місці, або, гірше, зеленіє на фікції. Плюс такий сід намертво прив'язаний до поточної схеми: перейменували стовпець — половина фабрик червоні.
Робоче правило middle-рівня: готуй стан через API/бекдор за замовчуванням, лізь у базу напряму лише там, де API станом керувати не дає або де швидкість критична, а ти справді знаєш схему (зв'язки, тригери, похідні поля). Прямий доступ до БД — гострий інструмент, не дефолт.
Ізоляція: щоб тести не заважали одне одному
Тест має бачити рівно той стан, який сам підготував. Три класичні підходи до ізоляції — і в кожного своя ціна.
Унікальні дані (неймспейсинг). Найпростіший і найстійкіший спосіб: не прибирати за собою, а щоразу створювати дані з унікальним ключем — email user_${Date.now()}@test.local, логін із UUID, назва проєкту з номером воркера. Тести не перетинаються, бо працюють з різними рядками. Це єдиний підхід, що безпечно живе на спільному середовищі й під паралеллю. Розплата — база засмічується, тож потрібне окреме прибирання за TTL або нічний клінап.
Відкат транзакції (transaction rollback). Обгортаємо тест у BEGIN і робимо ROLLBACK наприкінці — база повертається у вихідний стан, ніби нічого не було. Швидко й чисто, але з жорсткою умовою: код застосунку і тест мають ділити одне й те саме з'єднання з базою. У класичному e2e це не так — Playwright ганяє браузер проти окремо запущеного сервера, у сервера свій пул з'єднань, і твоя транзакція з коду тесту йому невидима (він її навіть не побачить через ізоляцію). Тому відкат працює для інтеграційних/юніт-тестів, де застосунок піднято в тому ж процесі й ти інжектиш у нього те саме з'єднання, а не для браузерних e2e. Механіку транзакцій і чому незакомічене чуже з'єднання не бачить — див. Транзакції та ACID.
Прибирання через TRUNCATE. Між тестами скидаємо таблиці до відомого стану. TRUNCATE швидший за DELETE (не йде по рядках), а опції в синтаксисі PostgreSQL: RESTART IDENTITY скидає лічильники автоінкремента, щоб ідентифікатори були передбачувані; CASCADE знімає ще й залежні за зовнішнім ключем таблиці. Але це руйнівна операція: вона бере ексклюзивне блокування і стирає все, тож припустима лише на виділеній тестовій базі, ніколи — на спільному стейджі чи проді. Синтаксис TRUNCATE, DELETE і DROP — у главі Зміна даних: INSERT, UPDATE, DELETE.
| Підхід | Прибирає за собою | Спільна база | Паралель | Головна ціна |
|---|---|---|---|---|
| Унікальні дані | Ні | Так | Так | База засмічується |
| Відкат транзакції | Так | Ні | Обмежено | Лише спільне з'єднання код+тест |
| TRUNCATE / клінап | Так | Ні | Ні (без ізоляції баз) | Руйнівна, тільки виділена БД |
Паралель і спільна база
Паралельний запуск множить швидкість — і множить конфлікти за дані. Тут стикаються два бажання: чистити базу між тестами й ганяти тести одночасно. TRUNCATE, який один воркер робить посеред прогону, зносить рядки, з якими саме працює інший воркер. Спільний акаунт іще гірший: один юзер на всіх — це черга болю, бо тести починають серіалізуватися навколо його стану й ловити чужі зміни.
Виходів три, і вони не взаємовиключні:
- Унікальні дані на воркер — кожен воркер працює у власному неймспейсі (номер воркера в email, префіксі, назві). Паралель-безпечно і без ізоляції баз; це дефолт для спільних середовищ.
- База або схема на воркер — кожному воркеру своя копія бази (або своя PostgreSQL-схема). Тоді
TRUNCATEі відкат знову дозволені, бо руйнувати нема кому заважати. Коштує ресурсів і налаштування. - Серіалізація — позначити групу тестів як послідовну там, де ізолювати дані реально складно. Останній засіб: це відмова від паралелі там, де вона найпотрібніша.
Концепція паралелізації, воркери проти шардингу й вимога незалежності тестів — канон у розділі про автоматизацію (стратегія); тут важливо тільки правило: паралель і руйнівне прибирання спільної бази несумісні — обирай або унікальні дані, або базу на воркер.
Підключення pg і mysql2 поруч із Playwright (потребує: JS/TS)
Щоб сідати дані чи перевіряти стан напряму, тесту потрібен драйвер бази: pg (node-postgres) для PostgreSQL, mysql2 для MySQL. Їх зручно підвісити як фікстуру Playwright з областю видимості воркера — тоді пул з'єднань відкривається раз на воркер і закривається в тірдауні.
import { test as base } from '@playwright/test';
import { Pool } from 'pg';
type WorkerFixtures = { db: Pool };
export const test = base.extend<object, WorkerFixtures>({
db: [async ({}, use) => {
const pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
await use(pool);
await pool.end(); // тірдаун: закрити пул, інакше процес не завершиться
}, { scope: 'worker' }],
});
Ключова дисципліна — параметризовані запити, ніколи не склеювання рядків. У pg плейсхолдери нумеровані ($1), у mysql2 — знак питання (?). Це не лише про SQL-ін'єкції (у тесті дані свої), а й про коректне екранування лапок, дат і NULL:
// pg — плейсхолдер $1
const { rows } = await db.query(
'SELECT status FROM orders WHERE user_email = $1',
[email],
);
// mysql2 — плейсхолдер ?
const [rows] = await conn.execute(
'SELECT status FROM orders WHERE user_email = ?',
[email],
);
І залізне правило доступу: на спільних чи прод-подібних середовищах з'єднання тесту має бути read-only — окремий користувач БД лише з правом SELECT. Тест не повинен мати технічної можливості зіпсувати спільний стенд необережним UPDATE без WHERE. Про read-only-дисципліну й параметри підключення — у главі Бази даних у роботі QA.
Коли assert у БД доречний
Спокуса перевіряти все в базі велика, але дефолт — інший: перевіряй те, що бачить користувач, там, де він це бачить (UI або відповідь API). Перевірка на рівні таблиць прив'язує тест до схеми: перейменували стовпець, денормалізували поле — тест червоний, хоча продукт працює. Дублювати в базі те, що вже видно в API-відповіді, — зайве й крихко.
Assert у БД виправданий там, де є побічний ефект, невидимий зовні:
- запис в аудит-лог або журнал подій, якого немає в UI;
- прапорець «м'якого» видалення (soft delete): рядок не зник, а лише позначений;
- результат фонового джоба чи черги, який пише в базу асинхронно;
- денормалізований лічильник або агрегат, який має зійтися після операції;
- поле, яке застосунок зберігає, але ніде не показує (внутрішній статус, службова мітка).
Тобто база — це оракул для того, що інакше не спостерігається. Практична порада: якщо перевіряєш фоновий ефект, не роби sleep, а роби полінг (poll) із таймаутом — запитуй таблицю, доки рядок не з'явиться або не вийде час. І пам'ятай про цілісність даних: найцінніші перевірки БД — саме про сирітські записи й розсинхрон агрегатів, яких із UI не видно.
Докеризована БД у CI і testcontainers (оглядово)
Тести, що ходять у базу, потребують бази в CI. Антипатерн — підмінити її на SQLite чи мок «щоб швидше»: діалект інший, поведінка NULL, типів, транзакцій і блокувань інша, і ти тестуєш не ту СУБД, що поїде в прод. Правильно — підняти справжній рушій у контейнері: сервіс postgres у docker compose поруч із застосунком і тестами, або блок services: у GitHub Actions, який дає живу PostgreSQL на час джоба.
testcontainers — бібліотека, що йде на крок далі: піднімає одноразові контейнери з коду тесту. Замість зовні налаштованого сервісу тест сам стартує свіжу PostgreSQL на випадковому порту, отримує рядок підключення, а після прогону контейнер автоматично прибирається. Плюс — повна ізоляція й відтворюваність: кожен прогін на чистій базі, локально й у CI однаково, без ручного docker compose. Мінус — старт контейнера додає секунди, тож область життя (на сюїту чи на воркер) обирають свідомо. Механіка Docker, образи проти контейнерів і docker compose — тема розділу про CI/CD; тут достатньо орієнтира: у CI база має бути реальною, а testcontainers — зручний спосіб зробити її ще й одноразовою.
Анонімізація прод-даних
Спокуслива ідея — скопіювати дамп продакшену на тестовий стенд: «реальні дані, реальні обсяги». Проблема в тому, що прод-дамп містить персональні дані реальних людей (імена, email, телефони, платіжки), і його переливання в менш захищене тестове середовище — це витік і порушення приватності (в ЄС — під GDPR). Сирі прод-дані в тесті — заборонений дефолт.
Якщо прод-подібні дані все ж потрібні (наприклад, для реалістичних обсягів), їх анонімізують перед завантаженням:
- маскування (masking) — заміна чутливих значень на фіктивні того ж формату (email на згенерований, номер картки на тестовий);
- псевдонімізація — стабільна заміна за ключем, коли той самий користувач в усіх таблицях маскується однаково, щоб не зламати зв'язки за зовнішнім ключем;
- синтетичні дані — згенеровані з нуля фабриками й faker, взагалі без походження від реальних людей;
- підмножина (subset) — беруть не весь прод, а узгоджений зріз, зберігаючи посилальну цілісність.
Ключова технічна вимога — зберегти цілісність між таблицями: якщо user_id замаскували в одній таблиці, він має так само замаскуватися в усіх, що на нього посилаються, інакше отримаєш сирітські записи. Найпростіший і найбезпечніший шлях у більшості випадків — не морочитися з анонімізацією прод-дампа, а будувати стан синтетичними фабриками: жодних персональних даних, повний контроль, відтворюваність.
Типові помилки
Виглядає як флак браузера, а насправді конфлікт даних. Тест «іноді» падає в наборі, але зелений наодинці. Перша підозра — не таймінги, а спільний стан: два тести чіпають одного користувача або один воркер чистить базу під іншим. Лікує ізоляція, а не ретрай.
Виглядає як баг застосунку, а насправді «неможливий» сід. Прямий INSERT виставив стан, якого продукт сам не створює (замовлення без позицій, підписка без користувача), і застосунок падає на невалідних даних. Це не баг продукту — це кривий сід. Готуй такий стан через API.
Виглядає як швидка оптимізація, а насправді дірка в довірі до сюїти. Замінили базу в CI на SQLite «щоб не піднімати Postgres». Діалект інший — тести зелені там, де прод впав би, і навпаки. Реальний рушій у контейнері обов'язковий.
Виглядає як надійна перевірка, а насправді крихкий тест. Assert напряму в БД дублює те, що вже повертає API, і ламається на першій же зміні схеми. Перевіряй у базі лише невидимі зовні ефекти.
Виглядає як реалізм, а насправді витік персональних даних. Дамп продакшену на стейдж «для реалістичності» тягне PII реальних людей у менш захищене середовище. Потрібні обсяги — анонімізуй або генеруй синтетику.
Підсумок
- Стан для тесту готуй через API/бекдор за замовчуванням; прямий
INSERTу БД — лише для станів, недосяжних через API, і тільки коли знаєш схему (звʼязки, тригери, похідні поля). - Три підходи до ізоляції: унікальні дані (єдиний безпечний на спільній базі й під паралеллю), відкат транзакції (лише коли код і тест ділять зʼєднання) і
TRUNCATE(тільки на виділеній базі). - Паралель і руйнівне прибирання спільної бази несумісні — обирай унікальні дані на воркер або базу/схему на воркер.
- Assert у БД доречний для побічних ефектів, невидимих у UI/API (аудит-лог, soft delete, фонові джоби, денормалізовані агрегати), а не як дубль видимих перевірок.
- У CI база має бути реальним рушієм у контейнері (не SQLite/мок); testcontainers дає її ще й одноразовою. Сирі прод-дані з PII у тест не переливають — анонімізують або генерують синтетику.
Що питають на співбесіді
- «Як ти готуєш тестові дані — через UI, API чи прямо в базі?» Інтерв'юер перевіряє, чи розумієш тредоф: API дає консистентний стан і переживає зміну схеми, прямий
INSERTшвидший, але легко створює неможливий стан. Сильна відповідь — «за замовчуванням API/бекдор, база напряму — точково». - «Як ізолюєш тести, щоб не заважали одне одному?» Чекають, що назвеш кілька механізмів (унікальні дані, відкат транзакції,
TRUNCATE) і, головне, межі кожного: чому відкат не працює в e2e й чомуTRUNCATEне можна на спільній базі. - «Тести падають під паралеллю на спільній базі — чому і що робити?» Дивляться, чи звʼяжеш конфлікт за даними з паралеллю й запропонуєш неймспейсинг або базу на воркер, а не «додамо ретраїв».
- «Коли перевіряти результат у самій базі, а не через API?» Хороша відповідь відрізняє видимі ефекти (перевіряй у API/UI) від невидимих (аудит, soft delete, фонові джоби — ось тут БД доречна) і згадує ризик привʼязки до схеми.
- «Можна залити прод-дамп на стейдж для реалістичності?» Червоний прапор — «так, зручно». Правильно — про PII, приватність і анонімізацію/синтетику.
Джерела
- ISTQB Certified Tester Foundation Level (CTFL) 4.0 Syllabus — поняття тестового середовища й тестових даних, їх підготовки та управління (розділи про виконання тестів і тестове середовище).
- Playwright — Test fixtures — фікстури й області видимості (test/worker), setup і teardown для підготовки та прибирання стану.
- node-postgres (pg) — документація — пул з'єднань і параметризовані запити для PostgreSQL у Node.js.
- PostgreSQL — TRUNCATE — семантика
TRUNCATE,RESTART IDENTITYіCASCADE. - Testcontainers — одноразові контейнери з реальними БД для тестів, локально й у CI.
Чим відрізняються сід, фікстура і фабрика?
Це три різні речі, які часто плутають. Сід (seed) — спільний довгоживучий фундамент середовища: довідники, каталог, кілька еталонних акаунтів, залиті один раз для всіх. Фікстура (fixture) — стан, приготований під конкретний тест чи групу («є замовлення в статусі pending»); у Playwright це слово означає ще й механізм фреймворку, що видає тесту готовий об'єкт і сам прибирає його в тірдауні. Фабрика (factory) — функція-конструктор із розумними дефолтами й точковими перевизначеннями, createUser({ role: 'admin' }); у парі з faker вона щоразу видає свіжу унікальну сутність. Найкраще масштабуються саме фабрики: сід дає каркас, а все потрібне під власну перевірку тест створює собі фабрикою й не залежить від чужих рядків.
Ти готуєш тестові дані через API чи прямо в базі? Що обереш і чому?
За замовчуванням — через API/бекдор, а прямий INSERT у базу лише точково. Стан, створений через API, пройшов усю валідацію й бізнес-логіку продукту: пароль захешовано, похідні поля пораховано, зв'язані таблиці заповнено, тригери відпрацювали — консистентність гарантує сам застосунок. Додатковий виграш у підтримці: після зміни схеми ендпоінт продовжить працювати, а сирі INSERT-и доведеться переписувати. Платиш за це швидкістю (мережа плюс уся логіка) і тим, що ендпоінт має існувати. Прямий доступ до БД — гострий інструмент для станів, яких API не дозволяє, а не дефолт.
Що таке «неможливий стан» і як прямий INSERT його породжує?
Неможливий стан — це рядки в базі, яких сам застосунок ніколи б не створив: замовлення без позицій, підписка без користувача, агрегат, що не сходиться. Прямий INSERT швидкий і всесильний саме тому, що йде в обхід бізнес-логіки — а разом із нею і в обхід усіх гарантій: пропустив залежний рядок, лічильник чи зовнішній ключ (foreign key), і база містить те, чого на проді не існує. Наслідки два, і обидва погані: тест падає без реальної причини або, гірше, проходить на фікції. Це не баг продукту — це кривий сід; коли застосунок «падає» на таких даних, він поводиться правильно, бо йому підсунули те, що його інваріанти забороняють. Тому такий стан треба готувати через API, який ці інваріанти поважає.
Які є підходи до ізоляції тестів і де межа кожного?
Класичних три, і в кожного своя ціна. Унікальні дані (неймспейсинг) — щоразу створювати рядки з унікальним ключем (email із таймстемпом, логін із UUID); тести не перетинаються, бо працюють з різними рядками, і це єдиний підхід, що безпечно живе на спільній базі й під паралеллю, але база засмічується. Відкат транзакції (rollback) — обгорнути тест у BEGIN і зробити ROLLBACK наприкінці; швидко й чисто, але лише коли код застосунку й тест ділять одне з'єднання. Прибирання через TRUNCATE — скинути таблиці до відомого стану між тестами; швидко, але операція руйнівна й припустима тільки на виділеній базі. Сильна відповідь на співбесіді називає не лише механізми, а й межі: чому rollback не працює в e2e і чому TRUNCATE не можна на спільному стейджі.
Чому «унікальні дані на воркер» — єдиний підхід, що безпечний на спільній базі й під паралеллю?
Бо він нічого не руйнує й ні за що не конкурує. Замість прибирати за собою кожен тест створює дані з унікальним ключем, тож два тести фізично працюють із різними рядками й не бачать чужих змін — навіть якщо база спільна, а воркерів багато. Інші два підходи це не витримують: варто одному воркеру зачистити таблиці посеред прогону — і другий втрачає рядки, у яких саме працює; спільний акаунт серіалізує тести навколо свого стану. Розплата за унікальні дані одна — база поступово заростає сміттям, тож поруч має жити окремий клінап (за TTL чи нічним джобом), а не teardown усередині тесту. Тому для спільних середовищ це дефолт: номер воркера в email, префіксі чи назві проєкту робить кожен воркер власним неймспейсом.
Чому відкат транзакції не працює в класичному e2e з Playwright?
Бо відкат вимагає, щоб код застосунку й тест ділили одне й те саме з'єднання з базою, а в e2e це не так. У браузерному тесті застосунок — окремий процес зі своїм пулом з'єднань, а Playwright лише керує браузером; транзакція, відкрита з коду тесту, для сервера — чуже незакомічене з'єднання, змін якого він через ізоляцію транзакцій навіть не бачить. Тому все, що застосунок пише під час тесту, іде повз твій BEGIN, і ROLLBACK нічого з цього не відкотить. Відкат — прийом для інтеграційних чи юніт-тестів, де застосунок піднято в тому ж процесі й ти інжектиш у нього те саме з'єднання. Для браузерних e2e лишаються унікальні дані або виділена база на воркер.
Чим TRUNCATE відрізняється від DELETE, і що роблять RESTART IDENTITY та CASCADE?
TRUNCATE стирає всі рядки таблиці, не йдучи по них по одному, тому він помітно швидший за DELETE без WHERE і бере ексклюзивне блокування. RESTART IDENTITY додатково скидає лічильники автоінкремента, щоб після очистки ідентифікатори знову починалися з відомого значення й були передбачувані між прогонами. CASCADE поширює очистку на таблиці, що посилаються на цю через зовнішній ключ, — без нього TRUNCATE відмовиться чіпати таблицю, на яку є посилання ззовні (обидві опції — синтаксис PostgreSQL). Разом це зручний спосіб повернути тестову базу до чистого відомого стану між тестами. Але саме через руйнівність (ексклюзивне блокування, стирання всього) TRUNCATE припустимий лише на виділеній тестовій базі.
Тести падають під паралеллю на спільній базі. Чому і що робити?
Найімовірніша причина — конфлікт за дані, а не таймінги: паралель множить не лише швидкість, а й конкуренцію за одні й ті самі рядки. Два тести чіпають одного користувача; чийсь TRUNCATE посеред прогону забирає з-під сусіднього воркера його дані; спільний акаунт серіалізує всіх навколо свого стану й підкидає чужі зміни. Головне правило: паралель і руйнівне прибирання спільної бази несумісні. Виходи не взаємовиключні — унікальні дані на воркер (кожен у власному неймспейсі, дефолт для спільних середовищ), база або схема на воркер (тоді TRUNCATE і відкат знову дозволені) або, як останній засіб, серіалізація групи тестів. «Додамо ретраїв» тут не лікування, а маскування.
Як підключити pg чи mysql2 до Playwright і чому саме фікстурою з областю воркера?
Щоб сідати чи перевіряти дані напряму, тесту потрібен драйвер: pg (node-postgres) для PostgreSQL, mysql2 для MySQL. Найзручніше підвісити пул з'єднань як фікстуру Playwright з областю видимості воркера (scope: 'worker'): тоді пул відкривається раз на воркер, перевикористовується всіма тестами цього воркера й закривається в тірдауні через pool.end(). Область воркера, а не тесту, важлива тому, що відкривати й закривати з'єднання на кожен тест — марна витрата, а незакритий пул ще й не дасть процесу завершитися. Рядок підключення беруть з оточення (process.env.TEST_DATABASE_URL), а не хардкодять, щоб той самий код працював локально й у CI.
Чому в тесті потрібні параметризовані запити, а не склеювання рядків, якщо дані все одно свої?
Бо це не лише про SQL-ін'єкції. Так, у тесті дані підконтрольні, і про ін'єкцію можна не думати, але параметризований запит ще й коректно екранує лапки, дати й NULL, які склеювання рядків спокійно ламає: апостроф у прізвищі, дата в чужому форматі, NULL замість рядка "NULL". У pg плейсхолдери нумеровані ($1), у mysql2 — знак питання (?); драйвер сам подбає, щоб значення потрапило в запит коректно. Це надійніше, читабельніше й дешевше, ніж вручну ліпити рядок, тому параметризація — дефолт навіть там, де безпека не турбує.
Навіщо давати тесту read-only з'єднання на спільному стенді?
Щоб тест технічно не міг зіпсувати спільний стенд необережним запитом. На прод-подібних чи спільних середовищах з'єднання тесту роблять окремим користувачем БД лише з правом SELECT. Тоді випадковий UPDATE без WHERE чи помилковий DELETE просто не виконається — база відмовить за браком прав, замість тихо знести дані під іншими прогонами. Це дисципліна «мінімально потрібних прав»: тест, який лише читає й асертить стан, не потребує запису, тож і не повинен його мати. Права на запис лишають окремому сідеру на ізольованих базах, де руйнувати нема кому заважати.
Коли assert напряму в БД доречний, а коли це антипатерн?
Дефолт — перевіряти те, що бачить користувач, там, де він це бачить: UI або відповідь API. Assert у БД прив'язує тест до схеми — перейменували стовпець, денормалізували поле, і тест червоний, хоча продукт працює; дублювати в базі те, що вже видно у відповіді API, — зайве й крихко. Виправданий assert у БД лише там, де є побічний ефект, невидимий зовні: запис в аудит-лог, прапорець м'якого видалення (soft delete), результат фонового джоба, денормалізований лічильник, що має зійтися, або службове поле, яке застосунок зберігає, але ніде не показує. Тобто база — оракул для того, що інакше не спостерігається, а не другий канал для перевірки видимого.
Перевіряєш у базі результат фонового джоба. Чому sleep — погана ідея, а полінг — добра?
Бо фоновий джоб пише в базу асинхронно, і момент, коли рядок з'явиться, наперед невідомий. Фіксований sleep — це ставка навмання: поставив мало — тест червоніє, бо джоб ще не дописав; поставив із запасом — сюїта тупо гальмує на кожному прогоні, навіть коли джоб відпрацював миттєво. Полінг (poll) із таймаутом знімає обидві проблеми: запитуєш таблицю в циклі, доки потрібний рядок не з'явиться або не вийде час, тож тест іде далі рівно тоді, коли ефект настав, і чесно падає, якщо не настав за розумний строк. Це той самий принцип «чекай на умову, а не на годинник», що й у ретраях UI-очікувань.
Чому в CI не можна підмінити базу на SQLite чи мок «щоб швидше»?
Бо це підриває довіру до всієї сюїти. SQLite — інший діалект: інша поведінка NULL, типів, транзакцій, блокувань і функцій, тож тести зеленітимуть там, де реальний Postgres упав би, і навпаки. Ти тестуєш не ту СУБД, що поїде в прод, а її віддалену родичку, і найцікавіші баги — саме на стиках діалектів — просто не проявляться. Правильно — підняти справжній рушій у контейнері: сервіс postgres у docker compose поруч із застосунком або блок services: у GitHub Actions, який дає живу PostgreSQL на час джоба. Швидкість тут не аргумент проти реалізму: контейнер стартує за секунди, а фальшива зелень коштує дорожче.
Що таке testcontainers і що вони дають понад звичайний сервіс БД у CI?
testcontainers — бібліотека, що піднімає одноразові контейнери прямо з коду тесту. Замість зовні налаштованого сервісу тест сам стартує свіжу PostgreSQL на випадковому порту, отримує рядок підключення, а після прогону контейнер автоматично прибирається. Плюс — повна ізоляція й відтворюваність: кожен прогін на чистій базі, локально й у CI однаково, без ручного docker compose і без ризику, що попередній прогін лишив сміття. Мінус — старт контейнера додає секунди, тож область життя (на сюїту чи на воркер) обирають свідомо, щоб не піднімати базу заново на кожен тест. Коротко: у CI база має бути реальним рушієм, а testcontainers роблять її ще й одноразовою.
Можна залити дамп продакшену на стейдж «для реалістичності»? Що відповісти?
Ні, сирий прод-дамп на стейдж — заборонений дефолт, і «так, зручно» тут червоний прапор. Дамп містить персональні дані реальних людей (імена, email, телефони, платіжки), а переливання їх у менш захищене тестове середовище — це витік і порушення приватності, під GDPR зокрема. Якщо прод-подібні обсяги все ж потрібні, дані анонімізують перед завантаженням: маскування (masking) — заміна чутливих значень фіктивними того ж формату; псевдонімізація — стабільна заміна за ключем; підмножина (subset) — узгоджений зріз зі збереженою цілісністю; або взагалі синтетичні дані з фабрик і faker. Найпростіший і найбезпечніший шлях у більшості випадків — не морочитися з дампом, а будувати стан синтетикою: жодних PII, повний контроль, відтворюваність.
Чим псевдонімізація відрізняється від маскування і чому це критично для цілісності?
Маскування замінює чутливе значення фіктивним того ж формату — email на згенерований, номер картки на тестовий — і робить це незалежно в кожному місці. Псевдонімізація — стабільна заміна за ключем: той самий реальний користувач в усіх таблицях маскується однаково, тож user_id, замаскований в одній таблиці, замінюється тим самим значенням у всіх, що на нього посилаються. Різниця критична саме через посилальну цілісність: якщо просто «замаскувати» ключ по-різному в різних таблицях, зв'язки за зовнішнім ключем розваляться й отримаєш сирітські записи, яких у проді не було. Тому для реляційних даних потрібне саме стабільне, консистентне псевдо, а не незалежне маскування кожної клітинки.
Три кейси, де тестові дані вирішують, стабільна сюїта чи флак: сідінг фабрикою з унікальними даними поверх worker-scoped пулу pg, assert невидимого зовні ефекту (soft delete) через полінг, і діагностика класики «зелений наодинці, червоний у наборі». Скрізь — що робити і чому саме так.
Кейс 1. Фабрика + унікальні дані поверх worker-scoped пулу
Мета — сідати стан так, щоб два воркери на спільній базі не билися за одні рядки. Пул pg підвішуємо фікстурою з областю воркера (відкрити раз, закрити в тірдауні), а фабрика вшиває номер воркера в унікальний ключ, тож кожен воркер працює у власному неймспейсі.
import { test as base } from '@playwright/test';
import { Pool } from 'pg';
import { faker } from '@faker-js/faker';
type WorkerFixtures = { db: Pool };
export const test = base.extend<object, WorkerFixtures>({
db: [async ({}, use) => {
const pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
await use(pool);
await pool.end(); // тірдаун: без цього процес не завершиться
}, { scope: 'worker' }],
});
// фабрика: унікальний email із номером воркера + faker, дефолти з перевизначенням
async function createUser(
db: Pool,
workerIndex: number,
over: Partial<{ role: string }> = {},
) {
const email = `w${workerIndex}_${Date.now()}_${faker.string.uuid()}@test.local`;
const role = over.role ?? 'user';
const { rows } = await db.query(
'INSERT INTO users (email, role) VALUES ($1, $2) RETURNING id, email',
[email, role], // параметризовано: жодного склеювання рядків
);
return rows[0];
}
test('адмін бачить панель керування', async ({ db, page }, testInfo) => {
const admin = await createUser(db, testInfo.workerIndex, { role: 'admin' });
// ... логін під admin.email, перевірка UI
});
Що дивитися і чому:
- Область воркера, а не тесту. Пул відкривається раз на воркер і перевикористовується всіма його тестами; відкривати з'єднання на кожен тест — марно, а незакритий пул завісить процес.
pool.end()у тірдауні обов'язковий. w${workerIndex}_...у ключі — це і є неймспейсинг. Кожен воркер живе у власному просторі імен, тожTRUNCATEне потрібен і паралель безпечна навіть на спільній базі. Ціна — база засмічується, тож десь поруч має бути нічний клінап або TTL, а не teardown усередині тесту.INSERTтут виправданий, бо стан простий і схема відома. Якби створення користувача тягло хешування пароля, тригери чи записи у зв'язані таблиці — правильніше було б сідати через API/бекдор, щоб не породити неможливий стан.- Параметри
$1, $2, а не конкатенація. Це не лише про ін'єкцію (дані свої), а про коректне екранування — апостроф у ролі чиNULLне зламають запит.
Кейс 2. Assert невидимого ефекту: soft delete через полінг
Кнопка «Видалити» в UI лише ховає запис — фізично рядок лишається з прапорцем deleted_at. Цього ефекту в інтерфейсі не видно, тож саме тут assert у БД доречний. А оскільки видалення може оброблятися асинхронно, чекаємо полінгом, а не sleep.
import { expect } from '@playwright/test';
import { test } from './fixtures'; // розширений test із кейсу 1 (фікстура db)
// полінг: питаємо базу, доки умова не справдиться або не вийде час
async function pollUntil<T>(
fn: () => Promise<T | null>,
{ timeout = 5000, interval = 200 } = {},
): Promise<T> {
const deadline = Date.now() + timeout;
for (;;) {
const value = await fn();
if (value !== null) return value;
if (Date.now() > deadline) throw new Error('умова не настала за таймаут');
await new Promise((r) => setTimeout(r, interval));
}
}
test('видалення — це soft delete, рядок лишається з deleted_at', async ({ db, page }) => {
// ... створили запис (маємо orderId), натиснули «Видалити» в UI
const row = await pollUntil(async () => {
const { rows } = await db.query(
'SELECT id, deleted_at FROM orders WHERE id = $1',
[orderId],
);
// чекаємо саме на проставлений deleted_at, а не на зникнення рядка
return rows[0]?.deleted_at ? rows[0] : null;
});
expect(row.deleted_at).not.toBeNull(); // рядок є, але позначений видаленим
});
Що дивитися і чому:
- Assert у БД тут не дубль, а єдиний оракул. UI показує «видалено», API повертає порожньо — сам факт, що рядок фізично лишився й лише позначений, зовні не спостерігається. Це рівно той випадок, коли база доречна.
- Полінг, а не
sleep. Фіксована пауза — ставка навмання: мало — червоно, багато — сюїта гальмує щоразу. Полінг іде далі рівно тоді, коли ефект настав, і чесно падає за таймаутом, якщо не настав. - Обережно з тим, що саме асертиш. Перевіряй інваріант soft delete (
deleted_atпроставлено), а не «рядок зник» — інакше тест сплутає м'яке видалення з жорстким і зловить не той баг.
Кейс 3. «Зелений наодинці, червоний у наборі» — діагностика
Тест стабільно проходить сам, але «іноді» падає в повному прогоні. Спокуса — списати на таймінги браузера й накинути ретраїв. Майже завжди причина інша: конфлікт за спільний стан. Ось як звузити діагноз замість здогадів.
| Симптом | Найімовірніша причина | Лікування |
|---|---|---|
| Падає лише в наборі, зелений наодинці | Спільний стан: два тести чіпають ті самі рядки | Унікальні дані на воркер |
409 Conflict / «duplicate key» на повторному прогоні | Фіксований email/логін, попередній прогін не прибрав | Унікальний ключ (таймстемп/UUID) |
| Дані «зникають» посеред тесту | Інший воркер зробив TRUNCATE/клінап на спільній базі | База або схема на воркер |
| Результат залежить від порядку запуску | Тести діляться акаунтом чи станом, серіалізуються | Ізолювати дані; серіалізація — останній засіб |
| Падає лише під паралеллю в CI, локально послідовно — зелено | Гонка за спільний ресурс, якої немає при --workers=1 | Неймспейсинг або виділена БД на воркер |
Порядок дій, а не гадання:
- Спочатку відтвори. Прожени сюїту з
--workers=1: якщо позеленіла — це конфлікт за паралель, а не таймінг. Прожени проблемний тест ізольовано: зелений — винен сусід або спільний стан. - Знайди спільний рядок. Пошукай фіксовані email/ID/назви в сідах і фабриках — саме вони перетинаються між тестами; заміни на унікальні per-worker.
- Не лікуй ретраєм. Ретрай ховає конфлікт за даними, але не прибирає його: сюїта лишається крихкою, а причина — недіагностованою. Ретрай доречний для справжньої мережевої нестабільності, не для спільного стану.
Сіди, фікстури, фабрики
- Розрізняю сід (фіксований набір на все середовище), фікстуру (стан під конкретний тест) і фабрику (код, що будує сутність із дефолтами).
- Знаю, що фікстура в Playwright — ще й механізм фреймворку: віддає готовий об'єкт і прибирає його в тірдауні.
Сідінг: через API чи напряму в БД
- Можу пояснити дефолт: готуй стан через API/бекдор, лізь у базу напряму лише там, де API станом керувати не дає.
- Знаю тредоф API проти прямого
INSERT: API дає реальну валідацію, похідні поля, тригери й консистентність і переживає зміну схеми, а прямийINSERTшвидкий і всесильний, але обходить логіку й прив'язує сід до схеми. - Розумію, що таке «неможливий стан» (замовлення без позицій, підписка без користувача) і чому це кривий сід, а не баг продукту.
Ізоляція тестів
- Знаю три підходи й межу кожного: унікальні дані, відкат транзакції,
TRUNCATE. - Розумію, чому унікальні дані (неймспейсинг) — єдиний підхід, безпечний на спільній базі й під паралеллю, і що його ціна — засмічена база.
- Можу пояснити, чому відкат транзакції працює лише коли код і тест ділять одне з'єднання, і тому не годиться для браузерних e2e.
- Знаю, що робить
TRUNCATE(RESTART IDENTITYскидає автоінкремент,CASCADEзачищає залежні за FK) і чому він припустимий лише на виділеній базі, ніколи — на спільному стейджі чи проді.
Паралель і спільна база
- Тримаю в голові головне правило й три виходи: паралель і руйнівне прибирання спільної бази несумісні — унікальні дані на воркер, база/схема на воркер або серіалізація групи як останній засіб.
- Розумію, чому «додамо ретраїв» на конфлікт за дані — маскування, а не лікування.
З'єднання з БД у коді тесту
- Знаю драйвери й чому пул підвішують фікстурою з областю воркера:
pg(node-postgres) для PostgreSQL,mysql2для MySQL; пул відкривається раз на воркер і закривається в тірдауні черезpool.end(). - Пишу лише параметризовані запити (
$1у pg,?у mysql2) — не лише проти ін'єкції, а й заради коректного екранування лапок, дат іNULL. - Розумію дисципліну read-only: на спільних стендах з'єднання тесту — окремий користувач лише з
SELECT.
Assert у БД
- Знаю дефолт і коли виняток: перевіряй видиме в UI/API (assert у БД прив'язує до схеми), а сам assert у БД доречний для невидимих ефектів — аудит-лог, soft delete, фонові джоби, денормалізовані агрегати, службові поля.
- Розумію, чому фоновий ефект перевіряють полінгом із таймаутом, а не
sleep.
БД у CI і прод-дані
- Знаю, чому в CI база має бути реальним рушієм у контейнері, а не SQLite/моком (інший діалект — фальшива зелень).
- Розумію, що дають testcontainers: одноразова, ізольована БД із коду тесту ціною секунд на старт.
- Можу пояснити, чому сирий прод-дамп на стейдж — витік PII (GDPR), а не «реалістичність».
- Розрізняю маскування, псевдонімізацію (стабільну за ключем), підмножину й синтетику — і чому цілісність між таблицями критична.
Що таке фабрика (factory) у контексті тестових даних?
Питання
Сід (seed), фікстура (fixture), фабрика (factory) — у чому різниця?