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.

    ПідхідПрибирає за собоюСпільна базаПаралельГоловна ціна
    Унікальні даніНіТакТакБаза засмічується
    Відкат транзакціїТакНіОбмеженоЛише спільне з'єднання код+тест
    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.