Тестування БД: цілісність даних і міграції
Зміст
Кнопка натиснута, тост «Збережено» блимнув, на екрані гарне число. А в базі — старе значення, або дубль, або лічильник, що розійшовся з реальністю. 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-звірка на пропуски + вибіркова рядкова звірка значень + ідемпотентність; чому порожня тестова база нічого не доводить.
- «Тест зелений на стейджі, падає на проді. Де копати?» — серед версій відповіді очікують розсинхрон довідників/сідів між середовищами і різницю в даних, а не лише «баг у коді».
Джерела
- ISTQB Certified Tester Foundation Level (CTFL) v4.0 — розділ 2 (тестування впродовж SDLC) і рівні тестування як рамка, куди лягає перевірка цілісності даних та регресія після міграцій.
- PostgreSQL: DDL та обмеження цілісності — як зовнішні ключі й
CHECKзадають інваріанти, що їх перевіряє QA. - Danilo Sato (martinfowler.com): Parallel Change (expand-contract) — канонічний опис патерну зміни інтерфейсу/схеми без зупинки сумісності.
- 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.
Тост «Збережено» блимнув, число на екрані оновилось. Чому цього мало, щоб вважати дані збереженими?
Питання
Чому UI не джерело правди про збережені дані?