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

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

    Тестування БД: цілісність даних і міграції

    Зміст

    Кнопка натиснута, тост «Збережено» блимнув, на екрані гарне число. А в базі — старе значення, або дубль, або лічильник, що розійшовся з реальністю. UI охоче бреше: він показує кеш, оптимістично оновлений стан чи відповідь, зшиту з кількох джерел. Єдине місце, де записана правда, — це таблиці. Тому тестування на рівні БД — не «ще один спосіб те саме перевірити», а перевірка того, що застосунок насправді зберіг, а не намалював.

    Друга половина цієї глави — про зміни схеми. Схема живе: додаються колонки, перейменовуються поля, дані переносяться зі старого формату в новий. Кожна така зміна (міграція) — момент підвищеного ризику: старий і новий код деякий час працюють одночасно, півмільйона рядків треба заповнити не втративши жодного, а довідник статусів на проді може не збігатися з тестовим. Розуміти цей клас багів мусить кожен middle-QA — і тому, що вони дорогі в проді, і тому, що про них питають на співбесідах.

    Мапінг UI/API ↔ таблиці: де живе значення з екрана

    Перше, що треба зробити перед будь-якою перевіркою в базі, — зрозуміти, звідки береться значення на екрані. Бо одне й те саме число «Разом: 3 замовлення» може приходити чотирма різними шляхами, і перевіряти їх треба по-різному.

    Пряма колонка

    JOIN таблиць

    Агрегат на льоту

    Кешований лічильник

    Число на екрані:
    Разом 3 замовлення

    Поле у відповіді API

    Звідки значення?

    SELECT status FROM orders

    orders + customers + items

    COUNT/SUM з GROUP BY

    Денормалізована колонка

    Пряма колонка

    JOIN таблиць

    Агрегат на льоту

    Кешований лічильник

    Число на екрані:
    Разом 3 замовлення

    Поле у відповіді API

    Звідки значення?

    SELECT status FROM orders

    orders + customers + items

    COUNT/SUM з GROUP BY

    Денормалізована колонка

    • Пряма колонка. Поле форми лягає один-в-один у стовпець. Найпростіший випадок: увів email — маєш users.email. Перевірка тривіальна.
    • Зшивка через JOIN. UI показує «замовлення Ігоря з Києва», але імʼя й місто лежать у customers, а позиції — в order_items. Значення на екрані не існує в жодній окремій таблиці — воно збирається запитом (див. JOIN).
    • Агрегат на льоту. «Разом 3 замовлення» — це COUNT(*), «на 1500 грн» — SUM(amount) (див. Агрегація). У базі немає колонки «разом» — є рядки, які треба порахувати.
    • Кешований лічильник. Той самий count зберігають окремою колонкою orders_count, щоб не рахувати щоразу. Це денормалізація — і саме тут ховається найпідступніший клас багів (нижче).

    Практичний висновок: перш ніж писати SELECT, спитайте розробника або гляньте у відповідь API — це поле пряме, обчислене чи кешоване. Від відповіді залежить, з чим ви звіряєте UI. Порівнювати екранне число можна і прямо в автотесті — підключення до бази поруч із Playwright докладно розібране в главі Тестові дані і БД в автотестах; тут важлива сама ідея звірки:

    test('баланс на екрані збігається з базою', async ({ page }) => {
      await page.goto('/account');
      const onScreen = await page.getByTestId('balance').innerText();
    
      const { rows } = await db.query(
        'SELECT balance FROM accounts WHERE user_id = $1',
        [userId],
      );
      expect(onScreen).toBe(formatMoney(rows[0].balance));
    });

    Сирітські записи і розсинхрон агрегатів

    Сирітський запис (orphan) — це рядок, що посилається на батька, якого вже немає. Замовлення з customer_id = 42, тоді як клієнта 42 видалили. Формально базі мало би завадити це зовнішнім ключем (foreign key) — і завадить, якщо ключ увімкнено й налаштовано ON DELETE. Але сироти зʼявляються постійно: FK вимикають заради швидкості імпорту, використовують мʼяке видалення (soft delete — клієнту лише проставили deleted_at, а замовлення далі на нього дивляться), або дані заливають в обхід застосунку. Класичний спосіб знайти сиріт — LEFT JOIN із перевіркою на NULL:

    -- Замовлення, що посилаються на неіснуючого клієнта
    SELECT o.id, o.customer_id
    FROM orders AS o
    LEFT JOIN customers AS c ON c.id = o.customer_id
    WHERE c.id IS NULL;

    Порожній результат — інваріант цілісності виконано. Непорожній — у вас є дані, які UI не покаже (клієнта немає), а звіт порахує неправильно. Два нюанси: якщо customer_id буває NULL легально (замовлення без клієнта), додайте AND o.customer_id IS NOT NULL, інакше такі рядки прикинуться сиротами; а «мʼяких» сиріт після soft delete цей запит не зловить — батько фізично на місці, тож там шукають дітей, чий батько має deleted_at IS NOT NULL.

    Розсинхрон агрегатів — коли кешоване зведене значення розходиться з тим, що воно нібито підсумовує. posts.comments_count каже «5 коментарів», а реальний COUNT дає 4. Таке трапляється, коли лічильник оновлюють не в одній транзакції з даними, коли тригер спрацював не на всіх шляхах, або коли масову операцію (bulk update, backfill, ручний фікс у базі) провели повз код, що тримає лічильник у синхроні. Перевірка — порівняти кеш із перерахунком:

    -- Кешований лічильник проти реального підрахунку
    SELECT p.id, p.comments_count AS cached, COUNT(cm.id) AS actual
    FROM posts AS p
    LEFT JOIN comments AS cm ON cm.post_id = p.id
    GROUP BY p.id, p.comments_count
    HAVING p.comments_count <> COUNT(cm.id);

    Чому це болить у тестах: баг тихий. Число на екрані виглядає правдоподібно, ніхто не помічає, поки клієнт не поскаржиться, що бачить «5 коментарів», а відкриває — чотири. Денормалізація завжди створює цей ризик; докладніше про свідоме дублювання даних — у главі Нормалізація і проєктування схеми.

    Міграції схеми: up, down і чому rollback — напівміф

    Міграція (migration) — це версійована зміна схеми, записана в коді репозиторію: додати колонку, створити таблицю, навісити індекс. Інструменти (Flyway, Liquibase, Prisma Migrate, міграції Rails чи TypeORM) тримають список застосованих міграцій і накочують нові по черзі. Кожна міграція зазвичай має дві частини: up — застосувати зміну, down — відкотити її.

    І тут перша пастка для QA: down не повертає дані, лише схему. up, що робить DROP COLUMN phone, зносить колонку разом з усіма телефонами. Його down може відтворити колонку phone — але вона буде порожня, дані вже втрачені. Тому «ми просто відкотимо, якщо що» — небезпечна ілюзія для деструктивних міграцій. На проді rollback часто взагалі не роблять через down; замість цього накочують нову виправну міграцію вперед (roll-forward). Ваше питання розробнику на рев'ю: «а що робить down цієї міграції і чи не втратимо ми дані?»

    Друга причина, чому не всі міграції оборотні: додавання NOT NULL-колонки без дефолту на непорожню таблицю впаде або заблокує таблицю, зміна типу може не мати зворотного перетворення (з text у integer назад — не завжди). Тестувати міграцію треба на копії реальних даних, а не на порожній тестовій базі: на порожній усе завжди зелене.

    Беквард-сумісність і expand-contract

    Головна причина, чому зміну схеми не можна робити «в лоб», — rolling deploy: під час викладки новий і старий код працюють одночасно кілька хвилин. Якщо ви зробите ALTER TABLE ... RENAME COLUMN name TO full_name, то старий код, який ще читає name, миттєво почне падати. Схема мусить бути беквард-сумісною — сумісною з кодом, що вже крутиться.

    Розвʼязання — патерн expand-contract (він же parallel change, паралельна зміна). Замість однієї руйнівної зміни роблять серію сумісних, розтягнуту на кілька релізів:

    Стара схема:
    колонка name

    EXPAND
    додати full_name,
    писати в обидві

    BACKFILL
    заповнити full_name
    зі старих рядків

    Перемкнути читання
    коду на full_name

    CONTRACT
    видалити name

    Стара схема:
    колонка name

    EXPAND
    додати full_name,
    писати в обидві

    BACKFILL
    заповнити full_name
    зі старих рядків

    Перемкнути читання
    коду на full_name

    CONTRACT
    видалити name

    • Expand. Додаємо full_name, не чіпаючи name. Код пише в обидві колонки. Стара логіка досі читає name і працює.
    • Backfill. Заповнюємо full_name для старих рядків (наступний розділ — саме про це).
    • Switch. Новий реліз перемикає читання на full_name. Обидві колонки ще на місці — тож і відкотити реліз безпечно.
    • Contract. Коли жоден код більше не торкається name, окремою пізнішою міграцією її видаляють.

    Що з цього тестує QA на кожному етапі: після expand — старі сценарії не зламані (беквард-сумісність); після switch — нові дані читаються з нової колонки; після contract — ніщо не звертається до зниклої колонки. Найчастіший провал — стрибнути з expand одразу в contract в одному релізі, забувши, що під час деплою живий ще й старий код.

    Backfill: звірка кількостей і рядків

    Backfill — це заповнення нової колонки чи таблиці даними, обчисленими з уже наявних рядків. Додали full_name — треба проставити його мільйону старих користувачів як first_name || ' ' || last_name. На великих таблицях backfill роблять батчами (по кілька тисяч рядків), щоб одна довга транзакція не тримала блокування на мільйонах рядків і не гальмувала решту запису. І саме тут QA має два рівні перевірки.

    Звірка кількостей (count reconciliation) — чи не пропустив backfill рядки:

    -- Скільки рядків лишилося незаповненими
    SELECT COUNT(*) FROM users WHERE full_name IS NULL;

    Нуль — заповнено всіх. Не нуль — backfill не доїхав (упав посередині, пропустив батч, не врахував рядки, створені під час його роботи).

    Рядкова звірка (row-level) — чи правильні значення, а не лише їх наявність. Кількість може зійтися, а формула — брехати (зайвий пробіл, переплутані колонки, обрізаний рядок):

    -- Рядки, де backfill дав не те, що очікувалось
    SELECT id, full_name, first_name || ' ' || last_name AS expected
    FROM users
    WHERE full_name IS DISTINCT FROM first_name || ' ' || last_name;

    Зверніть увагу на IS DISTINCT FROM замість <>: якщо last_nameNULL, конкатенація дає NULL, і звичайне порівняння тихо пропустить такий рядок — класична пастка з глави Функції, CASE і пастки NULL.

    Ще один інваріант, який варто перевірити, — ідемпотентність: чи можна перезапустити backfill, не зіпсувавши вже заповнені рядки (не подвоївши значення, не перезатеревши коректні дані). Backfill часто перезапускають після збою, тож «двічі — той самий результат» має виконуватись.

    ETL і довідники між середовищами

    ETL (extract-transform-load) — процес, що витягує дані з одного джерела, перетворює їх і завантажує в інше: у сховище даних (data warehouse) для звітності, в аналітичну базу, в інший сервіс. Тестування ETL — це, по суті, та сама звірка source проти target, тільки між двома базами. Оглядово QA перевіряє три речі: кількості збігаються (скільки рядків/на яку суму вийшло з source — стільки прийшло в target), ключові агрегати сходяться (сума платежів за день у source == сума у target), і вибіркові рядки правильно трансформовані (взяли 20 записів і звірили руками після перетворення).

    -- source
    SELECT COUNT(*), SUM(amount) FROM payments WHERE created_at::date = '2026-07-16';
    -- target (сховище)
    SELECT COUNT(*), SUM(amount) FROM fact_payments WHERE payment_date = '2026-07-16';

    Розбіжність зазвичай означає одне з трьох: загублені рядки (фільтр відсік більше, ніж мав), задвоєні рядки (повторний прогін без дедуплікації), або спотворення в трансформації (округлення, зміна таймзони, підміна NULL на нуль). Класична пастка ETL — дані, що спізнились (late-arriving): запис за вчора приїхав сьогодні, і вчорашня звірка «недорахувала», хоча помилки в коді немає.

    Довідники (reference data) між середовищами — окрема тиха загроза. Довідник — це таблиця-словник: статуси замовлень, коди валют, ролі, типи документів. Її наповнюють сідами (seed) чи окремими data-міграціями. Небезпека в тому, що на dev, test, stage і prod ці таблиці можуть розійтися: на тесті status_id = 5 означає «Скасовано», а на проді той самий id = 5 — «Повернуто». Автотест зелений, бо на тестовому середовищі все узгоджено; на проді та сама логіка робить не те. Тому інваріант: довідники мають бути детерміновані й однакові між середовищами — ті самі рядки з тими самими id. Перевіряється це звіркою довідникових таблиць між середовищами (ті самі коди, ті самі значення) і тим, що код спирається на стабільний код/ключ (code = 'CANCELLED'), а не на випадковий автоінкрементний id.

    Типові помилки

    • Виглядає як «збережено, тост показав успіх» — а насправді застосунок оновив стан у памʼяті оптимістично, а запит у базу впав. Правда лише в таблиці: перевіряйте не тост, а рядок.
    • Виглядає як баг лічильника в UI — а насправді розсинхрон денормалізованого агрегату: кешована колонка розійшлася з реальним COUNT після масової операції повз застосунок.
    • Виглядає як «відкотимо міграцію, якщо що» — а насправді down після DROP COLUMN поверне порожню колонку: дані вже втрачено, rollback схеми ≠ rollback даних.
    • Виглядає як успішний backfill (кількість зійшлась) — а насправді значення неправильні: рахувати треба не лише скільки заповнено, а й що саме заповнено.
    • Виглядає як «на тесті все зелене, а на проді зламалось» — а насправді розійшлися довідники: один і той самий id означає різні статуси в різних середовищах.
    • Виглядає як розбіжність source і target в ETL — а насправді дані, що спізнились: вчорашній запис приїхав сьогодні, помилки в коді немає.

    Підсумок

    • Значення на екрані має джерело в базі — пряму колонку, JOIN, агрегат на льоту чи кешований лічильник. Перш ніж звіряти, знайте, яке саме.
    • Зовнішні ключі й агрегати задають інваріанти цілісності: немає сиріт (LEFT JOIN ... IS NULL порожній), кеш дорівнює перерахунку. Перевіряти їх — читати правду, а не UI.
    • down-міграція повертає схему, але не дані. Деструктивні зміни оборотні лише на папері; на проді частіше roll-forward.
    • Небезпечну зміну схеми роблять через expand-contract, розтягнувши на кілька релізів, бо під час деплою старий і новий код працюють одночасно.
    • Backfill перевіряють на двох рівнях: кількість (нічого не пропущено) і рядок (значення правильні), плюс ідемпотентність. Ту саму звірку source vs target застосовують до ETL, а довідники мають бути однакові між середовищами.

    Що питають на співбесіді

    • «Як перевірити, що дані з форми справді збереглися?» — очікують: не вірити тосту UI, а піти в базу й звірити колонки; розуміти, що частина полів обчислювана чи кешована.
    • «Що таке сирітський запис і як його знайти?» — рядок з посиланням на неіснуючого батька; LEFT JOIN із WHERE parent.id IS NULL або NOT EXISTS; чому FK не завжди рятує (soft delete, вимкнені ключі).
    • «Як безпечно перейменувати колонку в проді без даунтайму?» — тут перевіряють знання expand-contract: не RENAME в лоб, а додати нову → backfill → перемкнути → видалити стару, памʼятаючи про сумісність зі старим кодом під час rolling deploy.
    • «Як переконатися, що міграцію можна відкотити?» — інтерв'юер дивиться, чи ви розумієте, що down не повертає видалених даних і що деструктивні міграції планують інакше.
    • «Як перевірити backfill на 10 мільйонів рядків?» — count-звірка на пропуски + вибіркова рядкова звірка значень + ідемпотентність; чому порожня тестова база нічого не доводить.
    • «Тест зелений на стейджі, падає на проді. Де копати?» — серед версій відповіді очікують розсинхрон довідників/сідів між середовищами і різницю в даних, а не лише «баг у коді».

    Джерела