Пошук уроків, статей та іншого контенту
Перевірите роботу репозиторіїв і запитів до бази даних, включно з транзакціями та помилками.
Репозиторій відповідає за взаємодію з базою даних:
формує SQL-запити;
передає параметри;
перетворює рядки таблиці на об’єкти застосунку;
обробляє помилки;
бере участь у транзакціях.
Помилка в репозиторії може бути непомітною під час тестування бізнес-логіки. Наприклад, код може правильно викликати createUser, але сам репозиторій:
звертається не до тієї таблиці;
неправильно передає параметр;
не враховує обмеження UNIQUE;
не відкочує транзакцію після помилки.
Тому для репозиторіїв потрібні інтеграційні тести — тести, які виконують справжні SQL-запити до тестової бази даних.
Тести не повинні виконуватися на базі даних розробника або на production-базі.
Для тестів потрібно використовувати окрему базу:
app_development
app_test
app_productionПід час запуску тестів застосунок використовує DATABASE_URL для тестової бази:
DATABASE_URL=postgres://postgres:postgres@localhost:5432/app_test node --testУ реальному проєкті схему тестової бази зазвичай створюють за допомогою міграцій. Якщо тестів небагато, таблицю можна створити в before-хуку.
Розглянемо таблицю рахунків користувачів:
CREATE TABLE accounts (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
balance INTEGER NOT NULL CHECK (balance >= 0)
);Репозиторій працюватиме з об’єктом, який має метод query. Це може бути Pool або окремий клієнт PostgreSQL:
// repositories/account-repository.js
export class AccountRepository {
constructor(db) {
this.db = db;
}
async create(username, balance) {
const result = await this.db.query(
`
INSERT INTO accounts (username, balance)
VALUES ($1, $2)
RETURNING id, username, balance
`,
[username, balance]
);
return result.rows[0];
}
async findById(id) {
const result = await this.db.query(
`
SELECT id, username, balance
FROM accounts
WHERE id = $1
`,
[id]
);
return result.rows[0] ?? null;
}
async updateBalance(id, balance) {
const result = await this.db.query(
`
UPDATE accounts
SET balance = $1
WHERE id = $2
RETURNING id, username, balance
`,
[balance, id]
);
return result.rows[0] ?? null;
}
}Значення передаються через параметри $1, $2, а не вставляються безпосередньо в SQL-рядок. Це захищає запит від SQL-ін’єкцій і дозволяє драйверу коректно обробляти типи даних.
Для прикладу використаємо:
Node.js із вбудованим тестувальником node:test;
PostgreSQL;
пакет pg.
Встановлення залежності:
npm install pgУ package.json можна додати окрему команду:
{
"type": "module",
"scripts": {
"test": "node --test"
}
}Перед запуском створіть тестову базу даних і передайте рядок підключення:
DATABASE_URL=postgres://postgres:postgres@localhost:5432/app_test npm testІнтеграційний тест повинен перевіряти не лише результат методу, а й фактичний стан бази даних після його виконання.
// repositories/account-repository.test.js
import assert from 'node:assert/strict';
import { after, before, beforeEach, describe, test } from 'node:test';
import pg from 'pg';
import { AccountRepository } from './account-repository.js';
const { Pool } = pg;
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
const repository = new AccountRepository(pool);
before(async () => {
await pool.query(`
CREATE TABLE IF NOT EXISTS accounts (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
balance INTEGER NOT NULL CHECK (balance >= 0)
)
`);
});
beforeEach(async () => {
// Кожен тест починається з порожньої таблиці.
await pool.query('TRUNCATE TABLE accounts RESTART IDENTITY');
});
after(async () => {
await pool.end();
});
describe('AccountRepository', () => {
test('створює рахунок і повертає його дані', async () => {
const account = await repository.create('alice', 100);
assert.equal(account.id, 1);
assert.equal(account.username, 'alice');
assert.equal(account.balance, 100);
const savedAccount = await repository.findById(account.id);
assert.deepEqual(savedAccount, {
id: account.id,
username: 'alice',
balance: 100
});
});
test('повертає null, якщо рахунок не знайдено', async () => {
const account = await repository.findById(999);
assert.equal(account, null);
});
test('оновлює баланс рахунку', async () => {
const account = await repository.create('alice', 100);
const updatedAccount = await repository.updateBalance(account.id, 250);
assert.deepEqual(updatedAccount, {
id: account.id,
username: 'alice',
balance: 250
});
});
});Тести перевіряють одразу кілька рівнів:
SQL-запит має правильний синтаксис.
Назви таблиці та стовпців правильні.
Параметри передаються в правильному порядку.
Результат RETURNING правильно перетворюється на об’єкт.
Метод повертає null, якщо запис відсутній.
Зміни справді зберігаються в базі даних.
База даних може відхилити операцію через обмеження.
Наприклад, username має обмеження UNIQUE. Якщо створити два рахунки з однаковим ім’ям, PostgreSQL поверне помилку з кодом 23505.
Тест має перевіряти не текст помилки, а її стабільну ознаку — код PostgreSQL:
test('повертає помилку для дубльованого username', async () => {
await repository.create('alice', 100);
await assert.rejects(
() => repository.create('alice', 200),
(error) => {
assert.equal(error.code, '23505');
return true;
}
);
const result = await pool.query(
'SELECT COUNT(*)::integer AS count FROM accounts'
);
assert.equal(result.rows[0].count, 1);
});Перевірка кількості записів важлива: помилка не повинна залишати частково виконаний результат.
Так само можна перевірити обмеження CHECK:
test('не дозволяє створити рахунок із від’ємним балансом', async () => {
await assert.rejects(
() => repository.create('alice', -10),
(error) => {
assert.equal(error.code, '23514');
return true;
}
);
const result = await pool.query('SELECT COUNT(*)::integer AS count FROM accounts');
assert.equal(result.rows[0].count, 0);
});Коди помилок залежать від PostgreSQL-обмеження. У прикладі:
23505 — порушення унікальності;
23514 — порушення перевірочного обмеження.
У прикладному коді такі помилки часто перетворюють на власні помилки, наприклад UsernameAlreadyTakenError. Але інтеграційний тест репозиторію може перевіряти, що база даних справді застосовує потрібне обмеження.
Транзакція об’єднує кілька операцій у єдину атомарну дію:
якщо всі операції успішні — виконується COMMIT;
якщо будь-яка операція завершується помилкою — виконується ROLLBACK.
Для транзакції з pg потрібно використовувати один і той самий клієнт. Не можна виконувати BEGIN через один клієнт, а UPDATE — через Pool, оскільки pool може вибрати інше з’єднання.
Створимо функцію переказу коштів:
// services/transfer-funds.js
export async function transferFunds(pool, fromId, toId, amount) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const senderResult = await client.query(
`
SELECT id, balance
FROM accounts
WHERE id = $1
FOR UPDATE
`,
[fromId]
);
const recipientResult = await client.query(
`
SELECT id, balance
FROM accounts
WHERE id = $1
FOR UPDATE
`,
[toId]
);
const sender = senderResult.rows[0];
const recipient = recipientResult.rows[0];
if (!sender || !recipient) {
const error = new Error('Account not found');
error.code = 'ACCOUNT_NOT_FOUND';
throw error;
}
if (sender.balance < amount) {
const error = new Error('Insufficient funds');
error.code = 'INSUFFICIENT_FUNDS';
throw error;
}
await client.query(
`
UPDATE accounts
SET balance = balance - $1
WHERE id = $2
`,
[amount, fromId]
);
await client.query(
`
UPDATE accounts
SET balance = balance + $1
WHERE id = $2
`,
[amount, toId]
);
await client.query('COMMIT');
} catch (error) {
// Відкат потрібен для будь-якої помилки всередині транзакції.
await client.query('ROLLBACK');
throw error;
} finally {
// Клієнт повертається до pool навіть після помилки.
client.release();
}
}FOR UPDATE блокує вибрані рядки до завершення транзакції. Це не дає іншій транзакції одночасно змінити ті самі рахунки.
У тесті потрібно перевірити обидві сторони переказу:
// services/transfer-funds.test.js
import assert from 'node:assert/strict';
import { after, before, beforeEach, describe, test } from 'node:test';
import pg from 'pg';
import { AccountRepository } from '../repositories/account-repository.js';
import { transferFunds } from './transfer-funds.js';
const { Pool } = pg;
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
const repository = new AccountRepository(pool);
before(async () => {
await pool.query(`
CREATE TABLE IF NOT EXISTS accounts (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
balance INTEGER NOT NULL CHECK (balance >= 0)
)
`);
});
beforeEach(async () => {
// Очищення ізольовує тести один від одного.
await pool.query('TRUNCATE TABLE accounts RESTART IDENTITY');
});
after(async () => {
await pool.end();
});
describe('transferFunds', () => {
test('переказує кошти в межах однієї транзакції', async () => {
const sender = await repository.create('alice', 100);
const recipient = await repository.create('bob', 50);
await transferFunds(pool, sender.id, recipient.id, 30);
const savedSender = await repository.findById(sender.id);
const savedRecipient = await repository.findById(recipient.id);
assert.equal(savedSender.balance, 70);
assert.equal(savedRecipient.balance, 80);
});
});Тест проходить лише тоді, коли:
зменшився баланс відправника;
збільшився баланс отримувача;
обидві операції виконалися після COMMIT.
Найважливіший тест транзакції — перевірка помилки посеред операції.
Якщо на рахунку недостатньо коштів, жоден баланс не повинен змінитися:
test('відкочує всі зміни, якщо коштів недостатньо', async () => {
const sender = await repository.create('alice', 20);
const recipient = await repository.create('bob', 50);
await assert.rejects(
() => transferFunds(pool, sender.id, recipient.id, 30),
(error) => {
assert.equal(error.code, 'INSUFFICIENT_FUNDS');
return true;
}
);
const savedSender = await repository.findById(sender.id);
const savedRecipient = await repository.findById(recipient.id);
assert.equal(savedSender.balance, 20);
assert.equal(savedRecipient.balance, 50);
});Цей тест захищає від помилки, коли код:
зменшує баланс відправника;
отримує помилку під час наступної операції;
не виконує ROLLBACK.
Без відкату дані стали б неконсистентними: гроші зникли б із першого рахунку, але не з’явилися б на другому.
Тести не повинні залежати від порядку виконання. Якщо один тест створив користувача, наступний тест не має автоматично розраховувати на нього.
Поширений варіант очищення:
await pool.query('TRUNCATE TABLE accounts RESTART IDENTITY');RESTART IDENTITY додатково скидає лічильник SERIAL, тому перший створений запис у кожному тесті знову матиме ідентифікатор 1.
Для кількох пов’язаних таблиць потрібно очищати їх з урахуванням зовнішніх ключів:
await pool.query(`
TRUNCATE TABLE
transfers,
accounts
RESTART IDENTITY CASCADE
`);Очищення повинно виконуватися лише в тестовій базі даних.
Іноді замість TRUNCATE кожен тест запускають у транзакції:
перед тестом виконують BEGIN;
тест використовує той самий клієнт;
після тесту виконують ROLLBACK.
Цей підхід швидкий, але має важливу умову: усі SQL-запити тесту та коду повинні використовувати один клієнт. Якщо репозиторій отримує запити через Pool, частина запитів може виконатися поза тестовою транзакцією.
Для простого репозиторію TRUNCATE часто зрозуміліший і безпечніший варіант.
Інтеграційний тест повинен перевіряти поведінку запиту через публічний метод репозиторію, а не копіювати його SQL у самому тесті.
Поганий тест:
test('перевіряє SQL репозиторію', async () => {
// Тест повторює той самий SQL, тому може містити таку саму помилку.
const result = await pool.query(`
SELECT id, username, balance
FROM accounts
WHERE id = 1
`);
assert.equal(result.rows.length, 1);
});Такий тест майже не перевіряє репозиторій.
Кращий тест:
test('знаходить рахунок за ідентифікатором', async () => {
const created = await repository.create('alice', 100);
const account = await repository.findById(created.id);
assert.equal(account.username, 'alice');
assert.equal(account.balance, 100);
});У цьому випадку тест викликає метод, яким користуватиметься застосунок, і перевіряє його реальний результат.
Помилки підключення складніше відтворити стабільно, тому їх зазвичай перевіряють окремо від основних тестів репозиторію.
Наприклад, можна створити pool із неправильним портом і перевірити, що запит завершується помилкою. Такий тест залежить від налаштувань операційної системи й може працювати повільно через тайм-аут.
Надійніший підхід — винести обробку помилок підключення в окремий модуль і перевіряти його за допомогою контрольованого тестового double. Але SQL-запити й транзакції репозиторію все одно слід перевіряти на справжній тестовій базі даних.
Використовуйте окрему тестову базу даних.
Не запускайте очищення даних у production-базі.
Створюйте схему тестової бази перед виконанням тестів.
Ізолюйте тести через TRUNCATE, транзакції або окрему базу.
Закривайте pool у after.
Для транзакції використовуйте один і той самий клієнт.
Перевіряйте не лише помилку, а й стан даних після неї.
Перевіряйте коди помилок бази даних, а не повні тексти повідомлень.
Використовуйте параметризовані SQL-запити.
Не копіюйте SQL репозиторію безпосередньо в тестах.
Перевіряйте як успішний COMMIT, так і ROLLBACK.
Тести можуть очищати таблиці, змінювати баланси або створювати некоректні дані. Завжди перевіряйте, що DATABASE_URL вказує на тестову базу.
Такий код неправильний:
await client.query('BEGIN');
await pool.query('UPDATE accounts SET balance = 0 WHERE id = $1', [id]);
await client.query('COMMIT');pool.query може використати інше з’єднання, тому UPDATE не буде частиною транзакції client.
Потрібно виконувати всі запити через client:
await client.query('BEGIN');
await client.query(
'UPDATE accounts SET balance = 0 WHERE id = $1',
[id]
);
await client.query('COMMIT');ROLLBACKЯкщо в catch немає ROLLBACK, з’єднання може залишитися в стані невиконаної транзакції. Наступні запити через цей клієнт можуть завершуватися помилками.
releaseКлієнт, отриманий через pool.connect(), потрібно повернути в pool у блоці finally. Інакше тести можуть зависати, коли всі з’єднання будуть зайняті.
Тест не повинен вимагати, щоб попередній тест уже створив певний запис. Кожен тест має готувати власні дані.
Недостатньо перевірити, що transferFunds повернув помилку. Потрібно перевірити, що після помилки баланси залишилися незмінними.
Тестування бази даних для Node.js-репозиторіїв має перевіряти реальну взаємодію з базою:
створення, пошук і оновлення записів;
параметризовані SQL-запити;
обмеження UNIQUE і CHECK;
успішне завершення транзакцій;
відкат транзакцій після помилки;
ізоляцію тестів;
коректне закриття з’єднань.
Інтеграційні тести доповнюють тести бізнес-логіки: вони показують, чи справді код працює разом із таблицями, обмеженнями та транзакціями бази даних.