Тестування БД: цілісність даних і міграції
Зміст
Кнопка натиснута, тост «Збережено» блимнув, на екрані гарне число. А в базі — старе значення, або дубль, або лічильник, що розійшовся з реальністю. UI охоче бреше: він показує кеш, оптимістично оновлений стан чи відповідь, зшиту з кількох джерел. Єдине місце, де записана правда, — це таблиці. Тому тестування на рівні БД — не «ще один спосіб те саме перевірити», а перевірка того, що застосунок насправді зберіг, а не намалював.
Друга половина цієї глави — про зміни схеми. Схема живе: додаються колонки, перейменовуються поля, дані переносяться зі старого формату в новий. Кожна така зміна (міграція) — момент підвищеного : старий і новий код деякий час працюють одночасно, півмільйона рядків треба заповнити не втративши жодного, а довідник статусів на проді може не збігатися з тестовим. Розуміти цей клас багів мусить кожен middle-QA: у проді вони дорогі, а на тестовому стенді їх легко не помітити.
Мапінг UI/API ↔ таблиці: де живе значення з екрана
Перше, що треба зробити перед будь-якою перевіркою в базі, — зрозуміти, звідки береться значення на екрані. Бо одне й те саме число «Разом: 3 замовлення» може приходити чотирма різними шляхами, і перевіряти їх треба по-різному.
- Пряма колонка. Поле форми лягає один-в-один у стовпець. Найпростіший випадок: увів 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, паралельна зміна). Замість однієї руйнівної зміни роблять серію сумісних, розтягнуту на кілька релізів:
- 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_name — NULL, конкатенація дає 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-звірка на пропуски + вибіркова рядкова звірка значень + ідемпотентність; чому порожня тестова база нічого не доводить.
- «Тест зелений на стейджі, падає на проді. Де копати?» — серед версій відповіді очікують розсинхрон довідників/ між середовищами і різницю в даних, а не лише «баг у коді».
Джерела
Мапінг UI/API ↔ таблиці: де живе значення з екрана
- MySQL 8.0 — JOIN Clause — випадок, коли екранного значення немає в жодній окремій таблиці: воно збирається зʼєднанням.
- PostgreSQL 18 — 9.21. Aggregate Functions — «разом 3 замовлення на 1500» як агрегат на льоту, а не збережена колонка.
- William Kent — A Simple Guide to Five Normal Forms in Relational Database Theory (CACM 26(2), 1983) — четвертий випадок: збережений лічильник як свідома неповна нормалізація, яку джерело прямо визнає законною.
Сирітські записи і розсинхрон агрегатів
- PostgreSQL 18 — 5.5. Constraints — що саме мав би гарантувати зовнішній ключ і чому в здоровій базі повертає нуль рядків.
- MySQL 8.0 — FOREIGN KEY Constraints — де цілісність ламається: вимкнені перевірки, завантаження в обхід застосунку, каскади.
- MySQL 8.0 — JOIN Clause — сама форма перевірки:
LEFT JOINізIS NULLна боці батька. - MySQL 8.4 — MySQL Handling of GROUP BY — групування з
HAVINGяк спосіб зловити розбіжність кешованого лічильника з реальним підрахунком. - Laravel 12.x — Eloquent: Soft Deleting — чому «мʼяких» сирітських записів анти-джойн не бачить: батьківський рядок фізично на місці, у нього лише проставлено
deleted_at.
Міграції схеми: up, down і чому rollback — напівміф
- Sadalage & Fowler — Evolutionary Database Design — міграція як версійована зміна в репозиторії, що входить до неї і чому «порівняти dev і прод» замість міграцій не працює.
- PostgreSQL 18 — ALTER TABLE — що саме робить
DROP COLUMNі зміна типу, і чому зворотне перетворення існує не завжди. - MySQL 8.0 — Statements That Cause an Implicit Commit — атомарність міграції залежить від СУБД: у MySQL DDL закриває транзакцію сам, тож «відкотити пакет» там не вийде.
Беквард-сумісність і expand-contract
- Danilo Sato — ParallelChange (expand-contract) — першоджерело патерну: три фази expand → migrate → contract і вимога тримати стару й нову форму сумісними одночасно.
- Sadalage & Fowler — Evolutionary Database Design — навіщо це під час деплою: старий і новий код деякий час працюють разом, тож схема мусить лишатися беквард-сумісною.
- PostgreSQL 18 — ALTER TABLE — що саме роблять кроки патерну на рівні команд: додати стовпець, а пізніше окремою міграцією прибрати старий.
Backfill: звірка кількостей і рядків
- Danilo Sato — ParallelChange (expand-contract) — backfill як середня фаза патерну: перенести наявні дані у нову форму, поки обидві живі.
- PostgreSQL 18 — 9.21. Aggregate Functions —
countяк інструмент звірки «скільки рядків лишилося незаповненими». - PostgreSQL 18 — 9.2. Comparison Functions and Operators —
IS DISTINCT FROMякnull-безпечне порівняння, без якого рядкова звірка мовчки пропускає рядки зnull. - PostgreSQL 18 — 9.4. String Functions and Operators — чому конкатенація
||зnullдаєnullі саме тому очікуване значення в такій звірці теж стаєnull.
ETL і довідники між середовищами
- PostgreSQL 18 — 7.4. Combining Queries (UNION, INTERSECT, EXCEPT) — готовий інструмент звірки двох наборів:
EXCEPTпоказує, що є в джерелі й чого немає в . - PostgreSQL 18 — 9.21. Aggregate Functions — звірка ключових агрегатів (
count,sum) між джерелом і приймачем. - PostgreSQL 18 — 8.5. Date/Time Types — типове джерело «спотворення в трансформації»: перехід між зонами при перенесенні дат.
- Sadalage & Fowler — Evolutionary Database Design — довідники наповнюють тими самими версійованими міграціями, тож їхня розбіжність між середовищами — дефект процесу, а не даних.
Тост «Збережено» блимнув — чому цього мало, щоб вважати дані збереженими?
Бо екран показує не запис у базі, а те, що вирішив намалювати клієнтський код: кеш, оптимістичне оновлення стану, відповідь, склеєну з кількох джерел. «Збережено» — це лише рішення фронтенду показати успіх; сам запит у базу міг упасти, зберегти старе значення чи створити дубль. Джерело істини одне — таблиці, тому перевіряти треба не тост, а рядок: піти в базу й звірити колонку за ключем. Для QA в цьому й сенс тестування на рівні БД: підтвердити, що застосунок реально зберіг дані, а не лише намалював успіх.
Число «Разом: 3 замовлення» на екрані — звідки воно може братися?
Одне й те саме число може приходити чотирма різними шляхами, і перевіряти їх треба по-різному. Пряма колонка — поле форми лягло один-в-один у стовпець (users.email), перевірка тривіальна. Зшивка через JOIN — значення збирається запитом з кількох таблиць і в жодній окремій не існує (імʼя клієнта в customers, позиції в order_items). Агрегат на льоту — це COUNT(*) чи SUM(amount), у базі немає колонки «разом», є рядки, які треба порахувати. Кешований лічильник — той самий count зберігають окремою денормалізованою колонкою orders_count, щоб не рахувати щоразу. Практичний висновок: перш ніж писати SELECT, зʼясуйте у розробника або з відповіді API, поле пряме, обчислене чи кешоване — від цього залежить, з чим ви звіряєте UI.
Що таке сирітський запис (orphan) і як його знайти?
— це рядок, що посилається на батька, якого вже немає: замовлення з customer_id = 42, тоді як клієнта 42 видалили. Формально базі мав би завадити це (foreign key) з налаштованим ON DELETE, але сирітські записи зʼявляються постійно. Класичний спосіб знайти їх — LEFT JOIN дитячої таблиці з батьківською і фільтр на NULL у батьківському ключі (або NOT EXISTS). Порожній результат — інваріант цілісності виконано; непорожній — у вас дані, які UI не покаже (батька немає), а звіт порахує неправильно.
Чому зовнішній ключ не завжди рятує від сирітських записів?
Тому що на практиці FK часто або вимкнений, або обійдений. Ключі вимикають заради швидкості масового імпорту й не завжди вмикають назад. Використовують (soft delete): клієнту лише проставили deleted_at, рядок фізично на місці, а замовлення далі на нього посилаються — формально ключ цілий, а логічно це вже сирітський запис. Дані заливають в обхід застосунку — сідами, ручними скриптами, ETL, — де перевірку цілісності легко пропустити. Тому QA не покладається на «ну там же є FK», а перевіряє інваріант окремим запитом.
Що таке розсинхрон агрегатів і чому цей баг такий підступний?
Розсинхрон агрегатів — це коли кешоване зведене значення більше не збігається з тим, що воно мало б підсумовувати: posts.comments_count каже «5», а реальний COUNT дає 4. Типові причини: лічильник оновлюється окремо від самих даних (не в одній ), тригер покриває не всі шляхи запису, або масовий update, backfill чи ручний фікс пройшли повз код, який тримає кеш у синхроні. Перевірка — порівняти кеш із перерахунком: LEFT JOIN на дочірню таблицю, GROUP BY і HAVING, де кешоване значення не дорівнює COUNT. Підступність у тому, що баг тихий: число виглядає правдоподібно, ніхто не помічає, поки клієнт не поскаржиться, що бачить «5 коментарів», а відкриває — чотири.
Що таке міграція і з яких частин вона зазвичай складається?
Міграція — версійована зміна схеми, яка живе в репозиторії поруч із кодом: нова колонка, таблиця чи індекс описані окремим кроком. Інструмент (Flyway, Liquibase, Prisma Migrate, міграції Rails чи TypeORM) памʼятає, які кроки вже застосовані, і накочує нові по черзі — так схема на всіх середовищах сходиться до одного стану. Всередині кроку зазвичай дві частини: up застосовує зміну, down — скасовує. Головне для QA — розуміти, що up і down симетричні лише щодо схеми, а не щодо даних.
Чому «відкотимо міграцію, якщо що» — небезпечна ілюзія?
Тому що down відновлює структуру, а не вміст. Якщо up зробив DROP COLUMN phone, телефони зникли разом із колонкою; down створить колонку phone заново — але порожню, повертати вже нічого. Rollback схеми не дорівнює rollback даних. Тому для деструктивних міграцій «ми просто відкотимо» не працює, і на проді замість down частіше їдуть уперед: накочують наступну міграцію, яка виправляє стан (roll-forward). Правильне питання розробнику на рев'ю: «а що робить down цієї міграції і чи не втратимо ми дані?».
Чому не всі міграції взагалі оборотні?
Крім втрати даних при DROP, є ще технічні причини. Додавання NOT NULL-колонки без дефолту на непорожню таблицю впаде або надовго заблокує таблицю — бо базі нічим заповнити наявні рядки. Зміна типу може не мати зворотного перетворення: з text в integer — не завжди, бо в тексті могло лежати те, що в число не лягає. Деякі операції в проді роблять окремими кроками саме тому, що прямий відкіт неможливий. Для QA це означає: питання «чи можна відкотити?» не риторичне — на нього є конкретна відповідь для кожної міграції.
Чому міграцію треба тестувати на копії реальних даних, а не на порожній тестовій базі?
Бо на порожній базі все завжди зелене — там немає рядків, які могли б порушити нове обмеження. NOT NULL без дефолту на порожній таблиці пройде без проблем, а на проді з мільйоном рядків впаде. Backfill на нулі рядків «спрацює» миттєво й нічого не доведе. Зміна типу не наткнеться на «брудні» значення, яких на тесті просто немає. Тому міграцію ганяють на копії (чи анонімізованому знімку) реальних даних — тільки там видно реальні блокування, конфлікти обмежень і .
Що таке rolling deploy і чому через нього не можна робити RENAME COLUMN «в лоб»?
Rolling deploy — це , під час якого новий і старий код працюють одночасно кілька хвилин, поки інстанси оновлюються по черзі. Якщо в цей момент зробити ALTER TABLE ... RENAME COLUMN name TO full_name, інстанси зі старим кодом, які ще читають name, почнуть падати одразу — колонки з таким іменем уже немає. Тому схема мусить бути беквард-сумісною: сумісною з кодом, що вже крутиться. Це головна причина, чому зміну схеми не роблять однією руйнівною операцією, а розтягують на серію сумісних.
Що таке expand-contract і з яких кроків він складається?
Expand-contract (він же parallel change, паралельна зміна) — патерн безпечної зміни схеми серією сумісних кроків, розтягнутою на кілька релізів. Expand — додаємо нову колонку full_name, не чіпаючи стару name; код пише в обидві, стара логіка досі читає name і працює. Backfill — заповнюємо full_name для старих рядків. Switch — новий реліз перемикає читання на full_name; обидві колонки ще на місці, тож і відкотити реліз безпечно. Contract — коли жоден код більше не торкається name, окремою пізнішою міграцією її видаляють. Так руйнівну операцію (RENAME) замінюють на ланцюг сумісних, і в кожен момент часу схема годиться і для старого, і для нового коду.
Що QA перевіряє на кожному етапі expand-contract?
Після expand — що старі сценарії не зламані: беквард-сумісність, стара колонка й стара логіка досі працюють. Після switch — що нові дані читаються з нової колонки, а не зі старої. Після contract — що ніщо більше не звертається до зниклої колонки (жоден запит, звіт чи фоновий на неї не спирається). Найчастіший провал, який має ловити QA, — стрибок з expand одразу в contract в одному релізі: команда забуває, що під час деплою живий ще й старий код, і той падає на зниклій колонці.
Що таке backfill і на яких двох рівнях його перевіряють?
Backfill — це заповнення заднім числом: нова колонка чи таблиця отримує значення, обчислені з рядків, які вже є. Додали full_name — мільйону старих користувачів його треба проставити з first_name і last_name. На великих таблицях це роблять батчами, щоб одна довга транзакція не тримала блокування на мільйонах рядків. QA перевіряє його на двох рівнях. Звірка кількостей (count reconciliation) — чи не пропущено рядки: скільки лишилось із порожнім full_name; нуль — заповнено всіх, не нуль — backfill не доїхав. Рядкова звірка (row-level) — чи правильні самі значення: кількість може зійтися, а формула брехати (зайвий пробіл, переплутані колонки, обрізаний рядок), тому вибірково порівнюють обчислене значення з очікуваним.
Чому успішна count-звірка ще не доводить, що backfill правильний?
Бо count каже лише скільки рядків заповнено, а не що саме в них лежить. Кількість може повністю зійтися — жодного NULL, — а значення бути неправильні: формула склеїла з зайвим пробілом, переплутала first_name і last_name, обрізала довгий рядок. Тому після count-звірки роблять рядкову: беруть рядки, де збережене значення не дорівнює перерахованому за формулою очікуваному, і дивляться, що там. Плюс перевіряють — чи можна перезапустити backfill, не зіпсувавши вже заповнені рядки, бо після збою його часто запускають повторно, і «двічі — той самий результат» має виконуватись.
Що таке ETL і як QA звіряє source проти target?
ETL (extract-transform-load) — конвеєр, який забирає дані з джерела, перетворює їх і кладе в інше місце: сховище даних (data warehouse) для звітності, аналітичну базу, суміжний сервіс. Для QA тестування ETL зводиться до звірки двох баз — source і target. Рівнів три: кількості (з source вийшло стільки ж рядків і грошей, скільки прийшло в target), ключові агрегати (сума платежів за день має зійтися з обох боків) і вибіркова перевірка трансформації (кілька десятків конкретних записів звіряють руками після перетворення). Якщо цифри розійшлися, підозрюваних троє: рядки загубились (надто жорсткий фільтр), рядки задвоїлись (повторний прогін без дедуплікації) або трансформація спотворила значення — округлення, зсув таймзони, NULL, що став нулем.
Source і target в ETL розійшлися. Чому це не завжди баг у коді?
Класична пастка ETL — дані, що спізнились (late-arriving): запис за вчора фізично приїхав у джерело сьогодні, і вчорашня звірка «недорахувала», хоча помилки в коді немає — просто на момент звірки цього рядка ще не було. Тому перш ніж заводити дефект на розбіжність, перевіряють, чи не про запізнілі дані йдеться і чи звірку робили в правильному часовому вікні. Це той самий клас міркувань, що й «фейл автотесту не завжди баг застосунку»: спершу зрозумій природу розбіжності, а вже потім звинувачуй код.
Чому довідники між середовищами — тиха загроза, і чому код має спиратися на code, а не на id?
Довідник (reference data) — таблиця-словник на кшталт статусів замовлень, кодів валют, ролей чи типів документів; заповнюється сідами або окремими data-міграціями. тиха, бо нічого не падає: просто на тесті під status_id = 5 лежить «Скасовано», а на проді під тим самим id — «Повернуто». Автотест зелений — на його середовищі все узгоджено, — а прод із тією самою логікою робить не те. Звідси інваріант: довідникові рядки мають бути детерміновані й ідентичні на всіх середовищах, включно з id. А код має чіплятися за стабільний ключ (code = 'CANCELLED'), бо автоінкрементний id — випадковість конкретної бази: на різних середовищах він міг проставитися по-різному.
Тест зелений на стейджі, падає на проді. Де копати перш ніж писати «баг у коді»?
Серед версій відповіді очікують не лише «баг у коді», а розсинхрон даних між середовищами. Найперший підозрюваний — довідники/: той самий id означає різні статуси на стейджі й проді, і логіка, привʼязана до id, робить не те. Другий — реальні дані: на проді є (сирітські записи, NULL, дублі, значення, яких немає в чистій тестовій базі), і саме вони чіпляють баг, невидимий на стейджі. Третій — стан середовища й міграції: чи всі міграції доїхали на прод, чи не залишилась колонка з попереднього етапу expand-contract. Сильна відповідь показує, що кандидат розрізняє «баг застосунку» і «розбіжність середовищ/даних», а не одразу заводить дефект на розробника.
Три кейси, де база вирішує, зелений тест чи прихований баг: звірка того, що показав UI, з тим, що реально лягло в таблиці (плюс пошук і розсинхрону лічильника), рев'ю ризикової міграції за схемою expand-contract, і перевірка backfill на двох рівнях разом зі звіркою ETL source проти target. Скрізь — що дивитися і чому.
Кейс 1. UI сказав «Збережено» — питаємо базу
Тест натиснув «Оплатити», екран показав новий баланс і зелений тост. Але тост — це рішення клієнтського коду намалювати успіх; правда лише в таблиці. Звіряємо число з екрана з тим, що реально записано, прямо в автотесті.
test('баланс на екрані збігається з базою після оплати', async ({ page }) => {
await page.getByRole('button', { name: 'Оплатити' }).click();
await expect(page.getByTestId('toast')).toHaveText('Збережено');
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));
});
Далі — два інваріанти цілісності, які UI взагалі не покаже. Сирітські записи: замовлення, що посилаються на неіснуючого клієнта.
-- Порожній результат = сирітських записів немає (інваріант виконано)
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;
Розсинхрон денормалізованого лічильника: кешований comments_count проти реального підрахунку.
-- Рядки, де кеш розійшовся з реальністю
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);
Що дивитися і чому:
- Тост — не . «Збережено» означає лише, що клієнт так вирішив намалювати; запит у базу міг упасти чи зберегти старе. рядок за ключем, а не текст тоста.
- Пряме поле чи кешоване — різна перевірка. Якщо
balance— денормалізований лічильник, він може відставати від реальних ; тоді додатково звіряємо його зSUMпо руху коштів, а не лише з екраном. - Сирітські записи — це дані, яких UI не бачить. Клієнта немає, тож на екрані замовлення не зʼявиться, а звіт його порахує. Непорожній результат першого запиту — готова знахідка.
- Розсинхрон лічильника — тихий баг. Число «5 коментарів» виглядає правдоподібно, поки хтось не відкриє й не побачить чотири.
HAVING cached <> actualловить це до клієнта.
Кейс 2. Рев'ю міграції: чи справді її можна відкотити
На рев'ю прилетіла міграція, що «просто перейменовує колонку». У проді rolling deploy — старий і новий код працюватимуть одночасно кілька хвилин. Питання QA не «чи компілюється», а «що станеться зі старим кодом і чи не втратимо дані».
Небезпечний варіант, який ловимо на рев'ю:
-- up
ALTER TABLE users RENAME COLUMN name TO full_name;
-- down
ALTER TABLE users RENAME COLUMN full_name TO name;
Безпечний — expand-contract, розтягнутий на релізи. Реліз 1 (expand): додати нову колонку, код починає писати в обидві.
-- Реліз 1: expand — стара колонка недоторкана, старий код працює
ALTER TABLE users ADD COLUMN full_name text;
Далі backfill старих рядків, окремий реліз перемикає читання на full_name, і лише коли жоден код не торкається name — окрема пізніша міграція DROP COLUMN name (contract).
| Етап | Що робить | Що перевіряє QA |
|---|---|---|
| Expand | Додати full_name, писати в обидві | Старі сценарії не зламані (беквард-сумісність) |
| Backfill | Заповнити full_name зі старих рядків | Кількість і значення (кейс 3) |
| Switch | Реліз читає full_name | Нові дані читаються з нової колонки |
| Contract | DROP COLUMN name | Ніщо не звертається до зниклої колонки |
Що дивитися і чому:
RENAMEв лоб валить старий код. Під час інстанс, що ще читаєname, миттєво падає — колонки вже немає. Саме тому руйнівну зміну розбивають на сумісні кроки.downповертає схему, не дані. ЯкбиupробивDROP COLUMN phone, йогоdownвідтворив би порожню колонку — телефони вже втрачені. «Відкотимо, якщо що» для деструктивних міграцій не працює; на проді роблять roll-forward.- Найтиповіший провал — expand одразу в contract. Додали й видалили в одному релізі, забувши про живий старий код. Contract — завжди окремий пізніший реліз.
- Тестувати на копії реальних даних.
NOT NULLбез дефолту чи зміна типу на порожній тестовій базі пройдуть; на проді з мільйоном рядків — впадуть.
Кейс 3. Backfill і ETL: рахуємо не лише «скільки», а й «що саме»
Backfill проставив full_name мільйону користувачів батчами. Перша перевірка — count-звірка: чи не пропущено рядки.
-- Скільки лишилося незаповненими (0 = доїхав до всіх)
SELECT COUNT(*) FROM users WHERE full_name IS NULL;
Друга, яку часто забувають, — рядкова: чи правильні самі значення. Кількість може зійтися, а формула — брехати.
-- IS DISTINCT FROM: бо <> з NULL дасть NULL і тихо пропустить рядок
SELECT id, full_name, first_name || ' ' || last_name AS expected
FROM users
WHERE full_name IS DISTINCT FROM first_name || ' ' || last_name;
Та сама логіка звірки — між двома базами в ETL: скільки платежів вийшло з джерела, стільки й прийшло в сховище.
-- 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';
Що дивитися і чому:
- Нуль у count — необхідна, але не достатня умова. Він доводить лише повноту (немає
NULL), не коректність. Обовʼязкова друга перевірка — рядкова звірка значень. - . Backfill після збою часто перезапускають; переконайтеся, що повторний прогін не подвоює й не перезатирає вже коректні рядки — «двічі той самий результат».
- Розбіжність source/target — три причини. Загублені рядки (фільтр відсік більше, ніж мав), задвоєні (повторний прогін без дедуплікації), спотворення в трансформації (округлення, таймзона,
NULL→ 0). - Спершу перевір «дані, що спізнились». Вчорашній запис міг приїхати сьогодні — вчорашня звірка «недорахує» без жодної помилки в коді. Це не дефект, а часове вікно; заводити баг — лише переконавшись, що річ не в late-arriving.
Мапінг UI/API ↔ таблиці
- Розумію, чому UI не джерело істини: він показує кеш, оптимістичний стан чи зшиту з кількох джерел відповідь, а правда лише в таблицях.
- Можу назвати чотири шляхи, звідки береться число на екрані (пряма колонка, JOIN, агрегат на льоту, кешований лічильник), і знаю, що тип поля диктує, з чим звіряти UI.
- Можу написати звірку екранного значення з базою прямо в автотесті — витягнути число з UI й зіставити з
SELECTза тим самим ключем.
Сирітські записи й розсинхрон агрегатів
- Можу пояснити, що таке (orphan) — рядок, що посилається на неіснуючого батька, — і знайти сирітські записи через
LEFT JOIN+parent.id IS NULL(абоNOT EXISTS). - Розумію, чому не завжди рятує: FK вимикають для імпорту, soft delete лишає рядок «живим», дані ллють в обхід застосунку.
- Можу пояснити розсинхрон агрегатів (кешований
comments_countрозійшовся з реальнимCOUNTпісля операції повз код), знаю запит-перевіркуLEFT JOIN+GROUP BY+HAVING(кеш ≠COUNT) і розумію, чому цей баг тихий.
Міграції: up, down, rollback
- Знаю, що міграція — версійована зміна схеми в коді (
upзастосувати /downвідкотити), а інструмент тримає список застосованих і накочує нові по черзі. - Можу пояснити, чому
downповертає схему, але не дані (downпісляDROP COLUMNвідтворює порожню колонку), і чому на проді частіше roll-forward, а неdown. - Знаю, чому не всі міграції оборотні:
NOT NULLбез дефолту на непорожню таблицю, зміна типу без зворотного перетворення. - Розумію, чому міграцію тестують на копії реальних даних, а не на порожній базі — на порожній усе завжди зелене.
Беквард-сумісність і expand-contract
- Можу пояснити rolling deploy (старий і новий код працюють одночасно) і чому
RENAME COLUMN«в лоб» валить старий код, що ще читає стару назву. - Можу перелічити кроки expand-contract: expand (додати нову, писати в обидві) → backfill → switch (читати нову) → contract (видалити стару).
- Знаю, що QA перевіряє на кожному етапі: після expand — старе не зламане; після switch — читається нова колонка; після contract — ніщо не чіпає зниклу.
- Розумію найтиповіший провал: стрибок з expand одразу в contract в одному релізі, забувши про живий старий код.
Backfill
- Можу пояснити, що таке backfill — заповнення нової колонки даними з наявних рядків, часто батчами.
- Знаю два рівні перевірки — count-звірка (нічого не пропущено, немає
NULL) і рядкова звірка значень — і чому самої count-звірки замало (формула може брехати: пробіл, переплутані колонки, обрізаний рядок). - Можу пояснити backfill: перезапуск після збою не має псувати вже заповнені рядки.
ETL і довідники між середовищами
- Розумію, що тестування ETL — це звірка source vs target (кількості, ключові агрегати, вибіркові трансформовані рядки), і знаю три причини розбіжності: загублені рядки, задвоєні, спотворення в трансформації.
- Можу пояснити «дані, що спізнились» (late-arriving) і чому розбіжність source/target тут не баг коду.
- Знаю інваріант довідників: детерміновані й однакові між середовищами (той самий
idне має означати різні статуси на test і prod), а код спирається на стабільнийcode, а не на автоінкрементнийid.
Квіз
Перед стартом
- Питань: 12
- Поріг «зараховано»: ≥70% правильних відповідей.
- Результат впливає на прогрес; завалені питання підуть у чергу повторення.
- Квіз впливає на компліт теми: тема стає «пройдено», лише коли прочитано теорію І квіз складено на ≥70%.
Питання
Чому UI не джерело істини про збережені дані?