QA Interview Hub · База знань · Тести

SQL

SQL у роботі QA

перевірки, цілісність, дані, автотести

Як перевірити в базі результат дії в UI?

Приклад: користувач поповнив рахунок на 500 PLN. Що перевіряю:

  1. Запис створено: нова транзакція з правильними сумою, типом, валютою, рахунком.
  2. Статус відповідає етапу: pending → completed, а не застряг.
  3. Побічні ефекти: баланс змінився рівно на суму, створено запис комісії, якщо вона є.
  4. Нічого зайвого: немає дубліката, не змінилися чужі рахунки.
  5. Службові поля: created_at у правильному часовому поясі, аудит-запис, хто виконав дію.
-- остання транзакція рахунку
SELECT id, type, amount, status, created_at
FROM transactions
WHERE account_id = 10
ORDER BY created_at DESC, id DESC
LIMIT 3;

-- скільки однакових транзакцій за останню хвилину (дубль?)
SELECT COUNT(*) FROM transactions
WHERE account_id = 10 AND amount = 500 AND type = 'deposit'
  AND created_at > NOW() - INTERVAL '1 minute';
На співбесіді: Після дії в UI я перевіряю в базі, що запис створено з правильними даними й статусом, що побічні ефекти відбулися — змінився баланс, нарахувалась комісія, — і що немає нічого зайвого: дубліката чи змін у чужих записах. Окремо дивлюся службові поля, як-от дата в правильному часовому поясі.

Як знайти аномалії й порушення цілісності даних?

Набір запитів, які варто мати під рукою:

  • Осиротілі записи — дочірні без батьківського (anti-join).
  • Дублікати там, де їх бути не може (GROUP BY + HAVING COUNT > 1).
  • Неможливі значення: від'ємні суми, дати в майбутньому, невідомі статуси.
  • Неможливі комбінації: заблокований рахунок з новими транзакціями, completed-транзакція без дати завершення.
  • Застряглі стани: pending довше розумного часу.
-- статуси, яких немає в документації
SELECT status, COUNT(*) FROM transactions GROUP BY status;

-- від'ємні баланси
SELECT id, user_id, balance FROM accounts WHERE balance < 0;

-- транзакції, що "зависли" в pending довше години
SELECT id, account_id, amount, created_at FROM transactions
WHERE status = 'pending' AND created_at < NOW() - INTERVAL '1 hour';

-- транзакції на заблокованих рахунках після блокування
SELECT t.id, a.id AS account_id
FROM transactions t JOIN accounts a ON a.id = t.account_id
WHERE a.status = 'blocked' AND t.created_at > a.blocked_at;
На співбесіді: Я тримаю набір запитів на цілісність: осиротілі записи через anti-join, дублікати через GROUP BY з HAVING, неможливі значення на кшталт від'ємного балансу, неможливі комбінації, як транзакції на заблокованому рахунку, і операції, що застрягли в pending. Такі перевірки часто знаходять баги, які не видно з UI.

На що звертати увагу при роботі з датами й часовими поясами?

  • TIMESTAMP зберігає час без поясу, TIMESTAMPTZ — точку в часі (у PostgreSQL зберігається в UTC і показується в поясі сесії).
  • UI показує локальний час, база — часто UTC. Різниця в 1–2 години при звірці — не обов'язково баг, але треба знати, що де.
  • Межі дня: «транзакції за 30 вересня» — це >= '2026-09-30' AND < '2026-10-01', а не = '2026-09-30'.
  • Межі доби в різних поясах: операція о 23:30 у Варшаві — вже наступний день в UTC+3. Звіти «за день» можуть розходитися.
  • Перехід на літній/зимовий час: година, що повторюється або зникає.
SHOW timezone;                                     -- пояс сесії
SELECT NOW(), NOW() AT TIME ZONE 'Europe/Warsaw';

-- транзакції за добу за варшавським часом
SELECT * FROM transactions
WHERE created_at >= '2026-09-30 00:00:00+02'
  AND created_at <  '2026-10-01 00:00:00+02';
На співбесіді: З датами я завжди з'ясовую, у якому поясі зберігається час у базі і в якому показується в UI, — різниця на годину-дві часто не баг, а різні пояси. Діапазони днів пишу як «від початку дня включно до наступного дня не включно». І окремо тестую межі доби та перехід на літній час, бо там часто ламаються звіти й ліміти.

Як правильно зберігати гроші і які тут баги?

FLOAT і DOUBLE зберігають числа в двійковій формі з похибкою: 0.1 + 0.2 дає 0.30000000000000004. Для грошей це неприпустимо — копійки «губляться» при сумуванні тисяч операцій.

Правильно: NUMERIC(p, s) або ціле число в мінімальних одиницях (копійки, центи).

Що тестувати:

  • Суми з копійками: 0,01, 0,10, 99,99, 100,005.
  • Правила округлення комісій і конвертації — до якого знака, в який бік.
  • Сума округлених частин дорівнює округленій сумі? (розбиття 100 на три частини).
  • Валюти з різною кількістю знаків: JPY — без копійок, деякі — з трьома знаками.
SELECT 0.1::float8 + 0.2::float8;          -- 0.30000000000000004
SELECT 0.1::numeric + 0.2::numeric;        -- 0.3

SELECT ROUND(2.675::numeric, 2);           -- 2.68
SELECT ROUND(2.675::float8::numeric, 2);   -- може дати 2.67 через похибку float
На співбесіді: Гроші не можна зберігати у FLOAT, бо він має двійкову похибку: нуль один плюс нуль два не дорівнює рівно нулю три, і на тисячах операцій копійки губляться. Правильно — NUMERIC або цілі мінімальні одиниці. Як QA я тестую суми з копійками, правила округлення комісій і чи сходиться сума частин із загальною сумою.

Як підготувати й прибрати тестові дані через SQL?

  • Маркер: усі тестові записи позначені — префікс email, тег, окремий тенант. Тоді прибирання — це DELETE за маркером, без ризику зачепити чуже.
  • RETURNING: отримати id створеного запису й використати далі.
  • Ідемпотентність: скрипт можна запускати повторно — ON CONFLICT DO NOTHING або спершу прибрати старі дані.
  • Порядок: створювати від батьків до дітей, видаляти — навпаки, щоб не впасти на зовнішніх ключах.
  • Масові дані: generate_series для навантажувальних і пагінаційних тестів.
-- 1000 транзакцій для перевірки пагінації
INSERT INTO transactions (account_id, type, amount, status, created_at)
SELECT 10, 'deposit', (random() * 1000)::numeric(14,2), 'completed',
       NOW() - (g || ' minutes')::interval
FROM generate_series(1, 1000) AS g;

-- прибирання: від дітей до батьків
DELETE FROM transactions WHERE account_id IN
  (SELECT a.id FROM accounts a JOIN users u ON u.id = a.user_id
   WHERE u.email LIKE 'qa_auto_%@test.com');
DELETE FROM accounts WHERE user_id IN
  (SELECT id FROM users WHERE email LIKE 'qa_auto_%@test.com');
DELETE FROM users WHERE email LIKE 'qa_auto_%@test.com';
На співбесіді: Усі тестові дані я позначаю маркером, наприклад префіксом в email, щоб потім прибрати лише свої. Скрипти роблю ідемпотентними, створюю записи від батьків до дітей, а видаляю у зворотному порядку через зовнішні ключі. Для перевірки пагінації чи великих обсягів генерую дані через generate_series.

Що таке SQL-ін'єкція і як її тестувати?

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

-- вразливий код збирає запит рядком:
--   "SELECT * FROM users WHERE email = '" + input + "'"
-- введення:  ' OR '1'='1
-- отримаємо: SELECT * FROM users WHERE email = '' OR '1'='1'   -> усі користувачі

-- захист: параметризований запит, введення завжди лише дані
--   SELECT * FROM users WHERE email = $1
На співбесіді: SQL-ін'єкція — це коли введення користувача потрапляє в запит як код, і спеціальний рядок, наприклад з лапкою і умовою «один дорівнює один», змінює логіку запиту. Захист — параметризовані запити. Як QA я перевіряю поля пошуку, фільтри й параметри API базовими рядками з лапками й коментарями: очікую, що вони обробляться як звичайний текст, без помилок бази в відповіді.

Як перевірити, що баланс збігається з історією транзакцій?

Це звірка денормалізованих даних: збережений баланс має дорівнювати сумі всіх завершених рухів коштів по рахунку. Запит повертає лише рахунки з розбіжністю — у нормі результат порожній.

SELECT a.id, a.balance,
       COALESCE(SUM(CASE WHEN t.type = 'deposit' THEN t.amount ELSE -t.amount END), 0) AS calculated
FROM accounts a
LEFT JOIN transactions t
       ON t.account_id = a.id AND t.status = 'completed'
GROUP BY a.id, a.balance
HAVING a.balance <> COALESCE(SUM(CASE WHEN t.type = 'deposit' THEN t.amount ELSE -t.amount END), 0);
На співбесіді: Я рахую суму всіх завершених транзакцій кожного рахунку з урахуванням напрямку і порівнюю зі збереженим балансом, а запит повертає лише розбіжності — у нормі він порожній. Тут важливі деталі: LEFT JOIN, щоб не загубити рахунки без транзакцій, умова статусу в ON, а не у WHERE, і COALESCE, бо сума порожнього набору — NULL.

Як використовувати SQL в автотестах?

  • Cypress: браузерний код не може напряму підключитися до бази, тому запит виконують у Node через cy.task (наприклад, з бібліотекою pg).
  • Playwright: тести й так працюють у Node, тому клієнт бази підключається напряму, зручно — через фікстуру.

Правила: окремий користувач бази для тестів з мінімальними правами; облікові дані — у змінних оточення; тести не залежать від даних, створених іншими тестами; дані прибираються за маркером.

// cypress.config.ts
import { defineConfig } from 'cypress';
import { Client } from 'pg';

export default defineConfig({
  e2e: {
    setupNodeEvents(on, config) {
      on('task', {
        async dbQuery({ sql, params }: { sql: string; params?: unknown[] }) {
          const client = new Client({ connectionString: config.env.DB_URL });
          await client.connect();
          try {
            const res = await client.query(sql, params);
            return res.rows;
          } finally {
            await client.end();
          }
        },
      });
    },
  },
});

// у тесті
cy.task('dbQuery', {
  sql: 'SELECT status FROM transactions WHERE id = $1',
  params: [txId],
}).then((rows: any) => expect(rows[0].status).to.eq('completed'));
На співбесіді: У Cypress браузерний код не має доступу до бази, тому запит виконую в Node-процесі через cy.task з бібліотекою pg і параметризованими запитами. У Playwright підключаюся до бази напряму, зручно через фікстуру. Для тестів використовую окремого користувача бази з мінімальними правами, облікові дані тримаю в змінних оточення, а дані прибираю за маркером.

Валідація API через БД

Після запиту на створення беру ID з відповіді API й роблю SELECT по цьому ID у відповідній таблиці. Перевіряю, що запис існує і всі поля збігаються з тим, що було надіслано. Також перевіряю поля, які заповнює сервер: created\_at, статус за замовчуванням, автор.

Для оновлення порівнюю стан до і після: змінилися лише очікувані поля, а updated\_at оновився. Для видалення перевіряю, чи запис фізично видалений, чи це soft delete з прапорцем deleted\_at. Важливо перевірити й пов'язані таблиці: наприклад, чи створились позиції замовлення і чи зменшився залишок товару.

Також перевіряю, що дані зберігаються в правильному форматі: пароль захешований, дати в UTC, суми з правильною точністю. В автотестах такі перевірки роблять через Node.js-задачу cy.task з клієнтом БД або окремий API-хелпер. Доступ до БД на тестовому середовищі бажано мати лише на читання.