Пошук уроків, статей та іншого контенту
Налаштуєте ізольовану тестову базу даних із міграціями, фікстурами, очищенням і повторюваними даними.
Тести, які працюють із базою даних, не повинні використовувати базу розробки або, тим більше, production-базу.
Ізольована тестова база дає змогу:
виконувати міграції незалежно від інших середовищ;
створювати передбачувані початкові дані;
очищати базу після кожного тесту;
запускати тести повторно з однаковим результатом;
безпечно перевіряти операції INSERT, UPDATE і DELETE.
Тестове середовище повинно мати окремий рядок підключення, наприклад:
postgres://app:password@localhost:5432/app_testapp_testapp_developmentТестовий код має мати технічний захист від випадкового підключення до production-бази.
У прикладі використаємо:
Node.js;
PostgreSQL;
пакет pg;
вбудований тестовий модуль node:test;
SQL-міграції;
фікстури для створення тестових даних.
Структура проєкту:
project/
├── migrations/
│ └── 001_create_users.sql
├── src/
│ └── users.js
├── test/
│ ├── db.js
│ ├── fixtures.js
│ └── users.test.js
├── migrate-test.js
└── package.jsonВстановіть залежність:
npm install pgСтворіть окрему базу PostgreSQL:
CREATE DATABASE app_test;У змінній середовища TEST_DATABASE_URL вкажіть підключення саме до цієї бази:
export TEST_DATABASE_URL=postgres://app:password@localhost:5432/app_testДля Windows PowerShell:
$env:TEST_DATABASE_URL="postgres://app:password@localhost:5432/app_test"Не використовуйте в тестах звичайну змінну DATABASE_URL, якщо вона може вказувати на базу розробки або production.
Створіть файл migrations/001_create_users.sql:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);Міграція описує структуру бази, необхідну застосунку. Тестова база повинна створюватися тими самими міграціями, що й інші середовища.
Створіть файл migrate-test.js:
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import pg from 'pg';
const { Client } = pg;
if (process.env.NODE_ENV !== 'test') {
throw new Error('Міграції цього скрипту можна запускати лише в test-середовищі');
}
if (!process.env.TEST_DATABASE_URL) {
throw new Error('Не задано TEST_DATABASE_URL');
}
const migrationsDirectory = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'migrations',
);
const client = new Client({
connectionString: process.env.TEST_DATABASE_URL,
});
await client.connect();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
const migrationFiles = (await readdir(migrationsDirectory))
.filter((file) => file.endsWith('.sql'))
.sort();
const appliedResult = await client.query(
'SELECT name FROM schema_migrations ORDER BY name',
);
const appliedMigrations = new Set(
appliedResult.rows.map((row) => row.name),
);
for (const migrationFile of migrationFiles) {
if (appliedMigrations.has(migrationFile)) {
continue;
}
const migrationPath = path.join(migrationsDirectory, migrationFile);
const migrationSql = await readFile(migrationPath, 'utf8');
await client.query('BEGIN');
try {
await client.query(migrationSql);
await client.query(
'INSERT INTO schema_migrations (name) VALUES ($1)',
[migrationFile],
);
await client.query('COMMIT');
console.log(`Застосовано міграцію: ${migrationFile}`);
} catch (error) {
await client.query('ROLLBACK');
throw error;
}
}
} finally {
await client.end();
}Міграції виконуються в такому порядку:
файли читаються з каталогу migrations;
вони сортуються за іменем;
уже виконані міграції пропускаються;
кожна нова міграція запускається в транзакції;
назва застосованої міграції зберігається в schema_migrations.
Нумерація файлів допомагає підтримувати правильний порядок:
001_create_users.sql
002_add_status_to_users.sql
003_create_orders.sqlСтворіть файл test/db.js:
import pg from 'pg';
const { Pool } = pg;
if (process.env.NODE_ENV !== 'test') {
throw new Error('Тести повинні запускатися з NODE_ENV=test');
}
if (!process.env.TEST_DATABASE_URL) {
throw new Error('Не задано TEST_DATABASE_URL');
}
const databaseName = new URL(process.env.TEST_DATABASE_URL).pathname
.replace(/^\//, '');
if (!databaseName.includes('test')) {
throw new Error(
`Небезпечне ім'я тестової бази: ${databaseName}. Очікується база з "test" у назві`,
);
}
export const pool = new Pool({
connectionString: process.env.TEST_DATABASE_URL,
});
export async function closeDatabase() {
await pool.end();
}Перевірка імені бази не замінює правильне налаштування доступів, але зменшує ризик випадкового очищення іншої бази.
Якщо один тест залишає дані в базі, наступний тест може залежати від порядку виконання або результатів попереднього тесту.
Для простого тестового середовища зручно очищати таблиці перед кожним тестом:
// test/db.js
export async function resetDatabase() {
await pool.query(`
TRUNCATE TABLE users
RESTART IDENTITY
CASCADE
`);
}Тут використовуються такі опції:
TRUNCATE TABLE швидко видаляє всі записи;
RESTART IDENTITY скидає лічильник SERIAL;
CASCADE також очищає залежні таблиці, якщо вони з’являться пізніше.
Після очищення перший створений користувач знову матиме id = 1. Це робить дані передбачуваними.
Очищення має виконуватися лише щодо тестової бази. Команда
TRUNCATEбезповоротно видаляє дані.
Фікстура — це функція, яка створює типовий набір даних для тесту.
Створіть файл test/fixtures.js:
import { pool } from './db.js';
export async function createUser({
email = 'olena@example.com',
name = 'Олена',
} = {}) {
const result = await pool.query(
`
INSERT INTO users (email, name)
VALUES ($1, $2)
RETURNING id, email, name, created_at
`,
[email, name],
);
return result.rows[0];
}Фікстура має кілька важливих властивостей:
використовує параметризований SQL;
має стабільні значення за замовчуванням;
дозволяє перевизначити потрібні поля;
повертає створений запис.
Приклади використання:
const firstUser = await createUser();
const secondUser = await createUser({
email: 'ivan@example.com',
name: 'Іван',
});Не варто використовувати випадкові значення у фікстурах без необхідності. Випадкові email або імена ускладнюють відтворення помилок.
Створіть файл src/users.js:
export async function findUserByEmail(db, email) {
const result = await db.query(
`
SELECT id, email, name, created_at
FROM users
WHERE email = $1
`,
[email],
);
return result.rows[0] ?? null;
}
export async function renameUser(db, userId, name) {
const result = await db.query(
`
UPDATE users
SET name = $1
WHERE id = $2
RETURNING id, email, name, created_at
`,
[name, userId],
);
return result.rows[0] ?? null;
}Функції приймають об’єкт db окремим параметром. Тому під час тесту можна передати тестовий pool, не створюючи додаткове підключення.
Створіть файл test/users.test.js:
import { after, beforeEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { findUserByEmail, renameUser } from '../src/users.js';
import { closeDatabase, pool, resetDatabase } from './db.js';
import { createUser } from './fixtures.js';
describe('робота з користувачами', () => {
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await closeDatabase();
});
it('знаходить користувача за email', async () => {
const user = await createUser({
email: 'olena@example.com',
name: 'Олена',
});
const foundUser = await findUserByEmail(
pool,
'olena@example.com',
);
assert.deepEqual(foundUser, user);
});
it('повертає null, якщо користувача не знайдено', async () => {
const foundUser = await findUserByEmail(
pool,
'missing@example.com',
);
assert.equal(foundUser, null);
});
it('змінює ім’я користувача', async () => {
const user = await createUser({
email: 'ivan@example.com',
name: 'Іван',
});
const updatedUser = await renameUser(pool, user.id, 'Іван Петренко');
assert.equal(updatedUser.id, user.id);
assert.equal(updatedUser.email, 'ivan@example.com');
assert.equal(updatedUser.name, 'Іван Петренко');
});
});Перед кожним тестом виконується resetDatabase(). Тому кожен тест починає роботу з порожньої таблиці users і не залежить від інших тестів.
У package.json додайте:
{
"type": "module",
"scripts": {
"migrate:test": "NODE_ENV=test node migrate-test.js",
"test": "NODE_ENV=test node --test --test-concurrency=1"
},
"dependencies": {
"pg": "^8.13.0"
}
}Запуск:
TEST_DATABASE_URL=postgres://app:password@localhost:5432/app_test npm run migrate:testПотім:
TEST_DATABASE_URL=postgres://app:password@localhost:5432/app_test npm testДля PowerShell:
$env:NODE_ENV="test"
$env:TEST_DATABASE_URL="postgres://app:password@localhost:5432/app_test"
npm run migrate:test
npm testПараметр --test-concurrency=1 запускає тести послідовно. Це важливо, якщо всі тести використовують одну спільну базу та очищають її перед виконанням. Паралельні тести з одним станом бази можуть заважати один одному.
Тест є повторюваним, якщо його результат не залежить від:
порядку запуску тестів;
попередніх записів у базі;
локального часу;
випадкових значень;
залишків після попереднього запуску;
ручного очищення бази розробником.
Для цього:
запускайте міграції перед тестами;
очищайте таблиці перед кожним тестом;
створюйте дані через фікстури;
використовуйте фіксовані значення;
не покладайтеся на записи, створені іншим тестом;
не використовуйте production-базу;
запускайте тести з однаковими змінними середовища.
Наприклад, цей тест не має прихованої залежності від інших тестів:
it('створює користувача з очікуваним ідентифікатором', async () => {
const user = await createUser({
email: 'test@example.com',
name: 'Тестовий користувач',
});
assert.equal(user.id, 1);
});Він працює передбачувано лише тому, що resetDatabase() виконується до тесту, а RESTART IDENTITY скидає лічильник ідентифікаторів.
Очищення через TRUNCATE є простим і зрозумілим підходом для інтеграційних тестів.
Інший підхід — виконувати кожен тест у транзакції та завершувати її через ROLLBACK. Він може бути швидшим, але має обмеження:
код застосунку повинен використовувати те саме підключення;
усі запити тесту мають виконуватися в межах цієї транзакції;
фонові операції або окремі підключення можуть не потрапити під відкат.
Тому для першої версії тестового середовища часто безпечніше використовувати очищення таблиць. Воно явно показує, який стан бази готується до кожного тесту.
Якщо тест запускає TRUNCATE у базі розробки, він може видалити локальні дані.
Використовуйте окремий TEST_DATABASE_URL і перевіряйте назву бази перед очищенням.
Якщо тестова база створюється вручну окремими SQL-командами, вона з часом може відрізнятися від production-схеми.
Запускайте звичайні міграції, а не підтримуйте другу схему вручну.
Тест, який працює лише після запуску іншого тесту, має приховану залежність.
Кожен тест повинен сам створювати необхідні дані.
Два виклики фікстури з однаковим email порушать обмеження UNIQUE.
Передавайте різні значення:
const firstUser = await createUser({
email: 'first@example.com',
});
const secondUser = await createUser({
email: 'second@example.com',
});Випадкові значення можуть приховати помилку або ускладнити її відтворення.
Використовуйте фіксовані дані, якщо випадковість не є частиною поведінки, яку ви тестуєте.
Якщо один тест очищає таблицю, поки інший тест працює з нею, результати будуть нестабільними.
Для спільної тестової бази запускайте такі тести послідовно або використовуйте окрему базу для кожного паралельного процесу.
Ізольоване тестове середовище складається з кількох частин:
окремої PostgreSQL-бази;
окремого TEST_DATABASE_URL;
міграцій для створення схеми;
таблиці обліку застосованих міграцій;
очищення бази перед кожним тестом;
фікстур із передбачуваними даними;
перевірки, що тести не працюють поза NODE_ENV=test.
Такий підхід робить інтеграційні тести незалежними, повторюваними та безпечними для інших середовищ.