Тестові дані і БД в автотестах
Зміст
Кожен автотест починається з припущення: «на старті в системі є ось такий стан». Користувач із бонусним рахунком, замовлення у статусі «оплачено», порожній кошик. Звідки цей стан береться і як його прибрати після тесту — не деталь реалізації, а те, що вирішує, чи буде стабільною. Погана відповідь на це питання дає один із найпоширеніших класів (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.
Чому «а якщо що — відкочу» тут працює не скрізь
Спокуслива ідея — з'єднати два підходи й обгорнути руйнівне прибирання в транзакцію: BEGIN → TRUNCATE → перевірка → ROLLBACK. Мовляв, страховка є, дані повернуться. Насправді відповідь залежить від СУБД, і різниця тут коштує даних:
- PostgreSQL.
TRUNCATEтранзакційно безпечний щодо даних у таблицях: якщо транзакція навколо не закомітилась, усічення буде коректно відкочене. Разом із ним відкочується іRESTART IDENTITY— послідовності повернуться туди, де були. - MySQL/InnoDB.
TRUNCATEспричиняє неявний commit і тому відкату не підлягає; довідник MySQL класифікує його як DDL, а не DML.ROLLBACKпісля нього не поверне нічого — і жодної помилки ви не побачите.
Тобто той самий фрагмент прибирання на PostgreSQL-стенді поводиться як задумано, а на MySQL-стенді знищує дані незворотно й мовчки. Для стратегії ізоляції висновок конкретний: «відкат транзакції» і «TRUNCATE» — це два різні підходи з таблиці нижче, і комбінувати їх, розраховуючи на страховку, можна лише свідомо перевіривши, яка СУБД під стендом. На MySQL страховки немає — там TRUNCATE дозволений тільки на виділеній базі, яку не шкода втратити цілком.
| Підхід | Прибирає за собою | Спільна база | Паралель | Головна ціна |
|---|---|---|---|---|
| Унікальні дані | Ні | Так | Так | База засмічується |
| Відкат транзакції | Так | Ні | Обмежено | Лише спільне з'єднання код+тест |
| TRUNCATE / прибирання | Так | Ні | Ні (без ізоляції баз) | Руйнівна, тільки виділена БД; на MySQL ще й невідкатна |
Паралель і спільна база
Паралельний запуск множить швидкість — і множить конфлікти за дані. Тут стикаються два бажання: чистити базу між тестами й ганяти тести одночасно. TRUNCATE, який один воркер робить посеред прогону, зносить рядки, з якими саме працює інший воркер. Спільний акаунт іще гірший: один юзер на всіх — це черга болю, бо тести починають серіалізуватися навколо його стану й ловити чужі зміни.
Виходів три, і вони не взаємовиключні:
- Унікальні дані на воркер — кожен воркер працює у власному неймспейсі (номер воркера в email, префіксі, назві). Паралель-безпечно і без ізоляції баз; це дефолт для спільних середовищ.
- База або схема на воркер — кожному воркеру своя копія бази (або своя PostgreSQL-схема). Тоді
TRUNCATEі відкат знову дозволені, бо руйнувати нема кому заважати. Коштує ресурсів і налаштування. Але «дозволені» тут означає «не заважають сусідові», а не «оборотні»: на MySQLTRUNCATEусе одно фіксується неявним , тож база воркера має бути справді одноразовою. - Серіалізація — позначити групу тестів як послідовну там, де ізолювати дані реально складно. Останній засіб: це від паралелі там, де вона найпотрібніша.
Концепція паралелізації, воркери проти й вимога незалежності тестів — канон у розділі про автоматизацію (стратегія); тут важливо тільки правило: паралель і руйнівне прибирання спільної бази несумісні — обирай або унікальні дані, або базу на воркер.
Підключення 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 — знак питання (?). Це не лише про (у тесті дані свої), а й про коректне екранування лапок, дат і 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. Антипатерн — підмінити її на чи «щоб швидше»: діалект інший, поведінка 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(тільки на виділеній базі). TRUNCATEу транзакції — не страховка: у PostgreSQL усічення відкотиться, якщо транзакція не закомітилась, а в MySQL/InnoDB воно дає неявний commit, тожROLLBACKпісля нього не поверне нічого й помилки не буде.- Паралель і руйнівне прибирання спільної бази несумісні — обирай унікальні дані на воркер або базу/схему на воркер.
- Assert у БД доречний для побічних ефектів, невидимих у UI/API (аудит-лог, soft delete, фонові джоби, денормалізовані агрегати), а не як дубль видимих перевірок.
- У CI база має бути реальною СУБД у контейнері (не SQLite/мок); testcontainers дає її ще й одноразовою. Сирі прод-дані з PII у тест не переливають — анонімізують або генерують синтетику.
Можливі питання
- «Як ти готуєш тестові дані — через UI, API чи прямо в базі?» Інтерв'юер перевіряє, чи розумієш тредоф: API дає консистентний стан і переживає зміну схеми, прямий
INSERTшвидший, але легко створює неможливий стан. Сильна відповідь — «за замовчуванням API/бекдор, база напряму — точково». - «Як ізолюєш тести, щоб не заважали одне одному?» Чекають, що назвеш кілька механізмів (унікальні дані, відкат транзакції,
TRUNCATE) і, головне, межі кожного: чому відкат не працює в e2e й чомуTRUNCATEне можна на спільній базі. Плюс бали за діалектний нюанс:TRUNCATEу транзакції відкотиться на PostgreSQL і не відкотиться на MySQL. - «Тести падають під паралеллю на спільній базі — чому і що робити?» Дивляться, чи звʼяжеш конфлікт за даними з паралеллю й запропонуєш неймспейсинг або базу на воркер, а не «додамо ретраїв».
- «Коли перевіряти результат у самій базі, а не через API?» Хороша відповідь відрізняє видимі ефекти (перевіряй у API/UI) від невидимих (аудит, soft delete, фонові джоби — ось тут БД доречна) і згадує привʼязки до схеми.
- «Можна залити прод-дамп на стейдж для реалістичності?» Червоний прапор — «так, зручно». Правильно — про PII, приватність і анонімізацію/синтетику.
Джерела
- Playwright — Test fixtures — фікстура як механізм фреймворку: готовий обʼєкт для тесту плюс setup і teardown навколо нього.
- PostgreSQL 18 — CREATE TABLE — тимчасова таблиця як ще один спосіб дати тестові власний одноразовий каркас даних.
- PostgreSQL 18 — INSERT — прямий шлях у таблицю: що саме дістануть стовпці поза переліком і чому «неможливий» стан створюється так легко.
- PostgreSQL 18 — 6.4. Returning Data from Modified Rows —
RETURNINGяк спосіб одразу забрати згенеровані базою значення після сідінгу. - PostgreSQL 18 — 5.5. Constraints — що саме обходить прямий
INSERT: лишаються, а логіка застосунку — ні. - PostgreSQL 18 — 37.1. Overview of Trigger Behavior — похідні поля й побічні ефекти, які при сідінгу легко проґавити.
Ізоляція: щоб тести не заважали одне одному
- Sadalage & Fowler — Evolutionary Database Design — чому спільна база між кількома сама собою є джерелом конфліктів, і навіщо кожному потрібен власний примірник.
- PostgreSQL 18 — 3.4. Transactions — відкат як прибирання і його жорстка умова: незакомічені зміни видимі лише всередині власного зʼєднання.
- PostgreSQL 18 — TRUNCATE —
RESTART IDENTITYіCASCADE, ексклюзивне блокування таблиці й транзакційність усічення в PostgreSQL. - MySQL 8.4 — TRUNCATE TABLE Statement — бік MySQL: неявний коміт, класифікація як DDL і, як наслідок, неможливість відкату.
- Sadalage & Fowler — Evolutionary Database Design — власний примірник бази на споживача як спосіб зняти конкуренцію за дані.
- Testcontainers — Getting started — одноразова база на прогон чи на воркер, підняте з коду тесту.
- MySQL 8.4 — TRUNCATE TABLE Statement — чому «база на воркер» має бути справді одноразовою: неявний коміт робить усічення незворотним.
Підключення pg і mysql2 поруч із Playwright (потребує: JS/TS)
- node-postgres (корінь документації) — що це за драйвер і як він підключається до PostgreSQL із Node.js.
- node-postgres — Pooling — чому пул, а не клієнт на кожен запит: ціна на новому зʼєднанні.
- node-postgres — Queries — параметризовані запити з нумерованими плейсхолдерами замість склеювання рядків.
- Playwright — Test fixtures — скоуп
workerі прибирання, у якому пул закривають. - PostgreSQL 18 — SELECT — права на читання видаються окремо, тож read-only-користувач фізично не зможе змінити дані.
- Laravel 12.x — Eloquent: Soft Deleting — канонічний невидимий зовні ефект: рядок лишається в таблиці, лише дістає позначку
deleted_at. - Sequelize v6 — Paranoid — той самий стан з іншого боку: у звичайній вибірці застосунку його не видно.
- PostgreSQL 18 — 5.5. Constraints — сирітські записи й порушення цілісності як другий клас ефектів, що не проявляються в UI.
- node-postgres — Queries — сам механізм такої перевірки з коду тесту: параметризований запит до таблиці.
Докеризована БД у CI і testcontainers (оглядово)
- Testcontainers (титульна сторінка) — призначення бібліотеки: одноразові контейнери з реальними залежностями для тестів.
- Testcontainers — Getting started — старт контейнера з коду тесту, рядок підключення на випадковому порту й автоматичне прибирання після прогону.
- Sadalage & Fowler — Evolutionary Database Design — чому тест має ганяти ту саму СУБД, що й прод, а не полегшену підміну.
- Регламент (ЄС) 2016/679 (GDPR) — межа між псевдонімізацією та анонімізацією: псевдонімізовані дані лишаються персональними й підпадають під регламент, знеособлені — ні.
- PostgreSQL 18 — 5.5. Constraints — чому маскування мусить бути узгодженим між таблицями: інакше воно ламає посилальну цілісність.
Чим відрізняються сід, фікстура і фабрика?
Це три різні речі, які часто плутають. (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 — скинути таблиці до відомого стану між тестами; швидко, але операція руйнівна й припустима тільки на виділеній базі, а на MySQL ще й невідкатна (там TRUNCATE дає неявний commit, тоді як у PostgreSQL він відкочується разом із транзакцією). Сильна відповідь на співбесіді називає не лише механізми, а й межі: чому 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 транзакцією і ROLLBACK?
Тільки якщо знаєш, яка СУБД під стендом, — і це рівно та розбіжність, на якій втрачають дані. У PostgreSQL TRUNCATE транзакційно безпечний щодо даних у таблицях: якщо транзакція навколо не закомітилась, усічення буде коректно відкочене, і разом із ним відкотиться навіть RESTART IDENTITY. У MySQL/InnoDB усе навпаки: TRUNCATE спричиняє неявний commit і відкату не підлягає, а сам довідник MySQL класифікує його як DDL, а не DML. Тому прибирання, написане як BEGIN → TRUNCATE → перевірка → ROLLBACK, на PostgreSQL-стенді відпрацює як задумано, а на MySQL-стенді знищить дані незворотно — і жодної помилки при цьому не буде, тобто скрипт виглядатиме успішним. Практичний висновок: на MySQL страховки немає взагалі, TRUNCATE там дозволений лише на базі, яку не шкода втратити цілком.
Тести падають під паралеллю на спільній базі. Чому і що робити?
Найімовірніша причина — конфлікт за дані, а не таймінги: паралель множить не лише швидкість, а й конкуренцію за одні й ті самі рядки. Два тести чіпають одного користувача; чийсь TRUNCATE посеред прогону забирає з-під сусіднього воркера його дані; спільний акаунт серіалізує всіх навколо свого стану й підкидає чужі зміни. Головне правило: паралель і руйнівне прибирання спільної бази несумісні. Виходи не взаємовиключні — унікальні дані на воркер (кожен у власному неймспейсі, дефолт для спільних середовищ), база або схема на воркер (тоді TRUNCATE і відкат знову дозволені) або, як останній засіб, серіалізація групи тестів. «Додамо ретраїв» тут не лікування, а маскування.
Як підключити pg чи mysql2 до Playwright і чому саме фікстурою зі скоупом воркера?
Щоб сідати чи перевіряти дані напряму, тесту потрібен драйвер: pg (node-postgres) для PostgreSQL, mysql2 для MySQL. Найзручніше підвісити пул з'єднань як фікстуру Playwright зі воркера (scope: 'worker'): тоді пул відкривається раз на воркер, перевикористовується всіма тестами цього воркера й закривається під час прибирання через pool.end(). Скоуп воркера, а не тесту, важливий тому, що відкривати й закривати з'єднання на кожен тест — марна витрата, а незакритий пул ще й не дасть процесу завершитися. Рядок підключення беруть з оточення (process.env.TEST_DATABASE_URL), а не хардкодять, щоб той самий код працював локально й у CI.
Чому в тесті потрібні параметризовані запити, а не склеювання рядків, якщо дані все одно свої?
Бо це не лише про . Так, у тесті дані підконтрольні, і про можна не думати, але ще й коректно екранує лапки, дати й 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 чи мок «щоб швидше»?
Бо це підриває довіру до всієї сюїти. — інший діалект: інша поведінка 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.
- Не лікуй ретраєм. Ретрай ховає конфлікт за даними, але не прибирає його: сюїта лишається крихкою, а причина — недіагностованою. Ретрай доречний для справжньої мережевої нестабільності, не для спільного стану.
- Не сподівайся, що прибирання «саме відкотиться». Обгортка
BEGIN/ROLLBACKнавколоTRUNCATEрятує лише на PostgreSQL, де усічення відкочується разом із незакоміченою . На MySQL/InnoDBTRUNCATEдає неявний commit — рядок «Дані зникають посеред тесту» з таблиці вище там стається навіть тоді, коли прибирання начебто було в транзакції.
Сіди, фікстури, фабрики
- Розрізняю (фіксований набір на все середовище), (стан під конкретний тест) і фабрику (код, що будує сутність із дефолтами).
- Знаю, що фікстура в Playwright — ще й механізм фреймворку: віддає готовий об'єкт і прибирає його після тесту.
Сідінг: через API чи напряму в БД
- Можу пояснити дефолт: готуй стан через API/бекдор, лізь у базу напряму лише там, де API станом керувати не дає.
- Знаю тредоф API проти прямого
INSERT: API дає реальну валідацію, похідні поля, тригери й консистентність і переживає зміну схеми, а прямийINSERTшвидкий і всесильний, але обходить логіку й прив'язує сід до схеми. - Розумію, що таке «неможливий стан» (замовлення без позицій, підписка без користувача) і чому це кривий сід, а не баг продукту.
Ізоляція тестів
- Знаю три підходи й межу кожного: унікальні дані, відкат ,
TRUNCATE. - Розумію, чому унікальні дані (неймспейсинг) — єдиний підхід, безпечний на спільній базі й під паралеллю, і що його ціна — засмічена база.
- Можу пояснити, чому відкат транзакції працює лише коли код і тест ділять одне з'єднання, і тому не годиться для браузерних e2e.
- Знаю, що робить
TRUNCATE(RESTART IDENTITYскидає автоінкремент,CASCADEзачищає залежні за FK) і чому він припустимий лише на виділеній базі, ніколи — на спільному стейджі чи проді. - Знаю, що
TRUNCATEу транзакції — не страховка: у PostgreSQL усічення відкотиться, якщо транзакція не закомітилась, а в MySQL/InnoDB воно дає неявний commit, іROLLBACKпісля нього не поверне нічого й помилки не покаже.
Паралель і спільна база
- Тримаю в голові головне правило й три виходи: паралель і руйнівне спільної бази несумісні — унікальні дані на , база/схема на воркер або серіалізація групи як останній засіб.
- Розумію, чому «додамо ретраїв» на конфлікт за дані — маскування, а не лікування.
З'єднання з БД у коді тесту
- Знаю драйвери й чому пул підвішують фікстурою зі воркера:
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 база має бути реальною СУБД у , а не / (інший діалект — фальшива зелень).
- Розумію, що дають testcontainers: одноразова, ізольована БД із коду тесту ціною секунд на старт.
- Можу пояснити, чому сирий прод-дамп на стейдж — витік PII (GDPR), а не «реалістичність».
- Розрізняю маскування, (стабільну за ключем), підмножину й синтетику — і чому цілісність між таблицями критична.
Квіз
Перед стартом
- Питань: 16
- Поріг «зараховано»: ≥70% правильних відповідей.
- Результат впливає на прогрес; завалені питання підуть у чергу повторення.
- Квіз впливає на компліт теми: тема стає «пройдено», лише коли прочитано теорію І квіз складено на ≥70%.
Питання
Сід (seed), фікстура (fixture), фабрика (factory) — у чому різниця?