vyvchy
    Теми розділу

    05 · Бази даних і SQL для QA

    Тестові дані і БД в автотестах

    Зміст

    Кожен автотест починається з припущення: «на старті в системі є ось такий стан». Користувач із бонусним рахунком, замовлення у статусі «оплачено», порожній кошик. Звідки цей стан береться і як його прибрати після тесту — не деталь реалізації, а те, що вирішує, чи буде стабільною. Погана відповідь на це питання дає один із найпоширеніших класів (flakiness): тест зелений наодинці й червоний у наборі, бо сусідній тест підмінив дані; два прогони конфліктують за одного й того самого користувача; на «брудній» базі перевірка бачить чуже.

    Тема стоїть на межі SQL і автоматизації, тому вона добрий лакмус: людина, яка розуміє, чому дані готують через API, а прибирають або TRUNCATE, і чому на спільній базі це ламається під паралеллю, — думала про підтримку сюїти, а не лише писала кроки. Канонічна стратегія (де саме готувати стан, коли одноразові акаунти, чому не чіпати прод) розібрана в розділі про автоматизацію; тут — SQL-механіка: , ізоляція, з'єднання з БД у коді тесту й перевірки на рівні таблиць.

    Сіди, фікстури, фабрики

    Три слова, які часто плутають, означають різні речі:

    • (seed) — фіксований набір даних, який заливають у базу перед прогоном: довідники, кілька еталонних користувачів, каталог. Один на все середовище, живе довго.
    • (fixture) — підготовлений стан під конкретний тест або групу тестів: «є замовлення в статусі pending». У Playwright фікстура — ще й механізм фреймворку, який дає тесту готовий об'єкт (сторінку, залогіненого користувача, з'єднання з БД) і прибирає його після.
    • Фабрика (factory) — код, що будує сутність із розумними дефолтами і точковими перевизначеннями: createUser({ role: 'admin' }). Разом із генератором даних (faker) фабрика дає щоразу свіжу, унікальну сутність без ручного копіпасту.

    Фабрики масштабуються найкраще: сід — це стартовий каркас, фабрика — те, чим тест наповнює базу під свою перевірку, не покладаючись на чужі рядки.

    Через API чи напряму в БД

    Головна розвилка сідінгу: створити стан, постукавши у власний API застосунку (публічний або тестовий бекдор), чи вставити рядки напряму в базу через INSERT. Обидві двері ведуть до тієї самої таблиці, але наслідки різні.

    Так

    Стан заборонений
    публічним API

    Так

    Ні

    Потрібен стан для тесту

    Чи є API або бекдор,
    що створює цей стан?

    Сідати через API

    Впевнений у схемі:
    тригери, похідні поля, звʼязки?

    INSERT напряму в БД

    Додати тестовий
    бекдор-ендпоінт

    Повільніше, зате консистентно
    і переживає зміну схеми

    Швидко, зате крихко
    й легко зробити «неможливий» стан

    Так

    Стан заборонений
    публічним API

    Так

    Ні

    Потрібен стан для тесту

    Чи є API або бекдор,
    що створює цей стан?

    Сідати через API

    Впевнений у схемі:
    тригери, похідні поля, звʼязки?

    INSERT напряму в БД

    Додати тестовий
    бекдор-ендпоінт

    Повільніше, зате консистентно
    і переживає зміну схеми

    Швидко, зате крихко
    й легко зробити «неможливий» стан

    Сідінг через API проходить крізь реальну валідацію і бізнес-логіку. Створюючи користувача ендпоінтом, ти автоматично отримуєш усе, що застосунок робить сам: хешування пароля, похідні поля, записи у зв'язаних таблицях, спрацьовані тригери, коректні дефолти. Такий стан гарантовано консистентний, бо його зробив сам продукт. Бонус: коли схема зміниться, API підлаштується, а твій сід — ні. Мінуси — повільніше (мережа плюс уся логіка) і ендпоінт мусить існувати; станів, яких публічний API не дозволяє (наприклад, «протермінована підписка»), так не створити.

    Сідінг напряму в БД швидкий і всесильний: одним INSERT можна виставити будь-який стан, зокрема той, який застосунок ніколи не створить сам. Ціна — ти обходиш усю логіку. Забув похідний рядок, не порахував агрегат, не проставив (foreign key) — і в базі опиняється неможливий стан, якого в проді не буває, а тест або падає на порожньому місці, або, гірше, зеленіє на фікції. Плюс такий сід намертво прив'язаний до поточної схеми: перейменували стовпець — половина фабрик червоні.

    Робоче правило middle-рівня: готуй стан через API/бекдор за замовчуванням, лізь у базу напряму лише там, де API станом керувати не дає або де швидкість критична, а ти справді знаєш схему (зв'язки, тригери, похідні поля). Прямий доступ до БД — гострий інструмент, не дефолт.

    Ізоляція: щоб тести не заважали одне одному

    Тест має бачити рівно той стан, який сам підготував. Три класичні підходи до ізоляції — і в кожного своя ціна.

    Спільний стейдж

    Виділена, свій процес

    Так, інтеграційний тест

    Ні, e2e окремий процес

    Як ізолювати тести?

    База спільна
    чи виділена під прогон?

    Унікальні дані
    на кожен тест

    Код і тест ділять
    одне зʼєднання з БД?

    Відкат транзакції
    після тесту

    TRUNCATE між тестами,
    послідовно

    Спільний стейдж

    Виділена, свій процес

    Так, інтеграційний тест

    Ні, e2e окремий процес

    Як ізолювати тести?

    База спільна
    чи виділена під прогон?

    Унікальні дані
    на кожен тест

    Код і тест ділять
    одне зʼєднання з БД?

    Відкат транзакції
    після тесту

    TRUNCATE між тестами,
    послідовно

    Унікальні дані (неймспейсинг). Найпростіший і найстійкіший спосіб: не прибирати за собою, а щоразу створювати дані з унікальним ключем — 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.

    Чому «а якщо що — відкочу» тут працює не скрізь

    Спокуслива ідея — з'єднати два підходи й обгорнути руйнівне прибирання в транзакцію: BEGINTRUNCATE → перевірка → ROLLBACK. Мовляв, страховка є, дані повернуться. Насправді відповідь залежить від СУБД, і різниця тут коштує даних:

    • PostgreSQL. TRUNCATE транзакційно безпечний щодо даних у таблицях: якщо транзакція навколо не закомітилась, усічення буде коректно відкочене. Разом із ним відкочується і RESTART IDENTITY — послідовності повернуться туди, де були.
    • MySQL/InnoDB. TRUNCATE спричиняє неявний commit і тому відкату не підлягає; довідник MySQL класифікує його як DDL, а не DML. ROLLBACK після нього не поверне нічого — і жодної помилки ви не побачите.

    Тобто той самий фрагмент прибирання на PostgreSQL-стенді поводиться як задумано, а на MySQL-стенді знищує дані незворотно й мовчки. Для стратегії ізоляції висновок конкретний: «відкат транзакції» і «TRUNCATE» — це два різні підходи з таблиці нижче, і комбінувати їх, розраховуючи на страховку, можна лише свідомо перевіривши, яка СУБД під стендом. На MySQL страховки немає — там TRUNCATE дозволений тільки на виділеній базі, яку не шкода втратити цілком.

    ПідхідПрибирає за собоюСпільна базаПаралельГоловна ціна
    Унікальні даніНіТакТакБаза засмічується
    Відкат транзакціїТакНіОбмеженоЛише спільне з'єднання код+тест
    TRUNCATE / прибиранняТакНіНі (без ізоляції баз)Руйнівна, тільки виділена БД; на MySQL ще й невідкатна

    Паралель і спільна база

    Паралельний запуск множить швидкість — і множить конфлікти за дані. Тут стикаються два бажання: чистити базу між тестами й ганяти тести одночасно. TRUNCATE, який один воркер робить посеред прогону, зносить рядки, з якими саме працює інший воркер. Спільний акаунт іще гірший: один юзер на всіх — це черга болю, бо тести починають серіалізуватися навколо його стану й ловити чужі зміни.

    Виходів три, і вони не взаємовиключні:

    • Унікальні дані на воркер — кожен воркер працює у власному неймспейсі (номер воркера в email, префіксі, назві). Паралель-безпечно і без ізоляції баз; це дефолт для спільних середовищ.
    • База або схема на воркер — кожному воркеру своя копія бази (або своя PostgreSQL-схема). Тоді TRUNCATE і відкат знову дозволені, бо руйнувати нема кому заважати. Коштує ресурсів і налаштування. Але «дозволені» тут означає «не заважають сусідові», а не «оборотні»: на MySQL 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 — знак питання (?). Це не лише про (у тесті дані свої), а й про коректне екранування лапок, дат і 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 — тимчасова таблиця як ще один спосіб дати тестові власний одноразовий каркас даних.

    Через API чи напряму в БД

    Ізоляція: щоб тести не заважали одне одному

    • Sadalage & Fowler — Evolutionary Database Design — чому спільна база між кількома сама собою є джерелом конфліктів, і навіщо кожному потрібен власний примірник.
    • PostgreSQL 18 — 3.4. Transactions — відкат як прибирання і його жорстка умова: незакомічені зміни видимі лише всередині власного зʼєднання.
    • PostgreSQL 18 — TRUNCATERESTART IDENTITY і CASCADE, ексклюзивне блокування таблиці й транзакційність усічення в PostgreSQL.
    • MySQL 8.4 — TRUNCATE TABLE Statement — бік MySQL: неявний коміт, класифікація як DDL і, як наслідок, неможливість відкату.

    Паралель і спільна база

    Підключення 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-користувач фізично не зможе змінити дані.

    Коли assert у БД доречний

    • Laravel 12.x — Eloquent: Soft Deleting — канонічний невидимий зовні ефект: рядок лишається в таблиці, лише дістає позначку deleted_at.
    • Sequelize v6 — Paranoid — той самий стан з іншого боку: у звичайній вибірці застосунку його не видно.
    • PostgreSQL 18 — 5.5. Constraints — сирітські записи й порушення цілісності як другий клас ефектів, що не проявляються в UI.
    • node-postgres — Queries — сам механізм такої перевірки з коду тесту: параметризований запит до таблиці.

    Докеризована БД у CI і testcontainers (оглядово)

    Анонімізація прод-даних

    • Регламент (ЄС) 2016/679 (GDPR) — межа між псевдонімізацією та анонімізацією: псевдонімізовані дані лишаються персональними й підпадають під регламент, знеособлені — ні.
    • PostgreSQL 18 — 5.5. Constraints — чому маскування мусить бути узгодженим між таблицями: інакше воно ламає посилальну цілісність.

    Пояснення

    «Поясни» працює з власним API-ключем Anthropic: запит іде з вашого браузера прямо до Anthropic.

    Свій API-ключ (BYOK)

    Вставте власний ключ Anthropic — пояснення працюватиме на реальній моделі. Ключ зберігається лише у вашому браузері (localStorage), нікуди не надсилається, крім api.anthropic.com, і не логується.