Пошук уроків, статей та іншого контенту
Перевірите роботу NestJS із PostgreSQL, запитами, репозиторіями та транзакціями в тестовому середовищі.
Під час тестування NestJS із PostgreSQL потрібно перевірити не лише виклик методів сервісу, а й реальну взаємодію з базою даних:
виконання SQL-запитів;
роботу репозиторіїв TypeORM;
обмеження PostgreSQL;
коміти транзакцій;
відкат транзакцій у разі помилки;
блокування рядків під час конкурентних операцій.
Для цього використовують окрему тестову базу PostgreSQL. Мок репозиторію не може показати, чи справді запит сумісний із PostgreSQL і чи коректно працює транзакція.
Зручно запускати PostgreSQL для тестів у Docker Compose.
services:
postgres-test:
image: postgres:16
environment:
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
POSTGRES_DB: nest_test
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U test_user -d nest_test"]
interval: 2s
timeout: 5s
retries: 10Запуск контейнера:
docker compose up -d postgres-testУ тестовому середовищі використаємо такі змінні:
DB_HOST=localhost
DB_PORT=5433
DB_USERNAME=test_user
DB_PASSWORD=test_password
DB_NAME=nest_testТестова база має бути окремою від бази розробки або production. Опція dropSchema: true, яку часто використовують у тестах, видаляє всі таблиці під час запуску TypeORM.
Розглянемо сервіс переказу коштів між рахунками. Такий приклад добре демонструє необхідність транзакції: якщо списання виконалося, а зарахування завершилося помилкою, база не повинна залишитися в непослідовному стані.
// account.entity.ts
import {
Column,
Entity,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity('accounts')
export class Account {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 100 })
owner: string;
@Column({ type: 'integer' })
balance: number;
}// account.service.ts
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { Account } from './account.entity';
@Injectable()
export class AccountService {
constructor(
@InjectRepository(Account)
private readonly accountRepository: Repository<Account>,
private readonly dataSource: DataSource,
) {}
async findWithBalanceAtLeast(minimum: number): Promise<Account[]> {
return this.accountRepository
.createQueryBuilder('account')
.where('account.balance >= :minimum', { minimum })
.orderBy('account.balance', 'DESC')
.getMany();
}
async transfer(
fromId: number,
toId: number,
amount: number,
): Promise<void> {
if (amount <= 0) {
throw new BadRequestException(
'Сума переказу повинна бути більшою за нуль',
);
}
await this.dataSource.transaction(async (manager) => {
const source = await manager.findOne(Account, {
where: { id: fromId },
lock: { mode: 'pessimistic_write' },
});
const destination = await manager.findOne(Account, {
where: { id: toId },
lock: { mode: 'pessimistic_write' },
});
if (!source || !destination) {
throw new NotFoundException('Рахунок не знайдено');
}
if (source.balance < amount) {
throw new BadRequestException('Недостатньо коштів');
}
source.balance -= amount;
destination.balance += amount;
await manager.save(source);
await manager.save(destination);
});
}
}dataSource.transaction() автоматично:
відкриває транзакцію;
передає транзакційний EntityManager у callback;
виконує COMMIT, якщо callback завершився успішно;
виконує ROLLBACK, якщо callback викинув помилку.
Усі операції всередині транзакції повинні виконуватися через manager. Якщо використати звичайний репозиторій сервісу замість manager, частина запитів може виконатися поза транзакцією.
Для інтеграційного тесту створимо NestJS-модуль із реальною конфігурацією TypeORM.
// account.test.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Account } from './account.entity';
import { AccountService } from './account.service';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.DB_HOST ?? 'localhost',
port: Number(process.env.DB_PORT ?? 5433),
username: process.env.DB_USERNAME ?? 'test_user',
password: process.env.DB_PASSWORD ?? 'test_password',
database: process.env.DB_NAME ?? 'nest_test',
autoLoadEntities: true,
synchronize: true,
dropSchema: true,
}),
TypeOrmModule.forFeature([Account]),
],
providers: [AccountService],
exports: [AccountService],
})
export class AccountTestModule {}synchronize і dropSchemaДля тестової бази допустимо використати:
synchronize: true,
dropSchema: true,Це спрощує підготовку схеми. Під час кожного запуску TypeORM створює таблиці заново.
У production такі параметри використовувати не можна:
synchronize: true може змінити або видалити структуру таблиць;
dropSchema: true видаляє всі таблиці бази.
У production-середовищі схему змінюють за допомогою міграцій.
Файл тесту може мати розширення .e2e-spec.ts або .int-spec.ts. Важливо, що це не HTTP-тест контролера, а тест сервісу з реальною базою PostgreSQL.
// account.int-spec.ts
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Account } from './account.entity';
import { AccountService } from './account.service';
import { AccountTestModule } from './account.test.module';
describe('AccountService з PostgreSQL', () => {
let app: INestApplication;
let service: AccountService;
let repository: Repository<Account>;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AccountTestModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
service = moduleFixture.get(AccountService);
repository = moduleFixture.get<Repository<Account>>(
getRepositoryToken(Account),
);
});
beforeEach(async () => {
await repository.clear();
});
afterAll(async () => {
await app.close();
});
it('зберігає та читає рахунок через PostgreSQL', async () => {
const account = await repository.save({
owner: 'Олена',
balance: 1500,
});
const found = await repository.findOneByOrFail({
id: account.id,
});
expect(found.owner).toBe('Олена');
expect(found.balance).toBe(1500);
});
it('виконує запит через QueryBuilder', async () => {
await repository.save([
{
owner: 'Олена',
balance: 1500,
},
{
owner: 'Андрій',
balance: 500,
},
{
owner: 'Марія',
balance: 2500,
},
]);
const accounts = await service.findWithBalanceAtLeast(1000);
expect(accounts).toHaveLength(2);
expect(accounts.map((account) => account.owner)).toEqual([
'Марія',
'Олена',
]);
});
it('виконує переказ у межах транзакції', async () => {
const source = await repository.save({
owner: 'Олена',
balance: 1500,
});
const destination = await repository.save({
owner: 'Андрій',
balance: 500,
});
await service.transfer(source.id, destination.id, 300);
const updatedSource = await repository.findOneByOrFail({
id: source.id,
});
const updatedDestination = await repository.findOneByOrFail({
id: destination.id,
});
expect(updatedSource.balance).toBe(1200);
expect(updatedDestination.balance).toBe(800);
});
it('відхиляє переказ і не змінює дані при недостатньому балансі', async () => {
const source = await repository.save({
owner: 'Олена',
balance: 100,
});
const destination = await repository.save({
owner: 'Андрій',
balance: 500,
});
await expect(
service.transfer(source.id, destination.id, 300),
).rejects.toThrow('Недостатньо коштів');
const unchangedSource = await repository.findOneByOrFail({
id: source.id,
});
const unchangedDestination = await repository.findOneByOrFail({
id: destination.id,
});
expect(unchangedSource.balance).toBe(100);
expect(unchangedDestination.balance).toBe(500);
});
});Ці тести перевіряють різні рівні роботи:
repository.save() та findOneByOrFail() перевіряють взаємодію репозиторію з PostgreSQL;
QueryBuilder перевіряє фактичний SQL-запит;
успішний переказ перевіряє коміт транзакції;
помилка через недостатній баланс перевіряє відкат транзакції.
Кожен тест повинен починатися в передбачуваному стані.
У прикладі для цього використовується:
beforeEach(async () => {
await repository.clear();
});clear() видаляє всі записи таблиці. Це підходить для простої сутності Account, яка не має зовнішніх ключів на інші таблиці.
Якщо в тесті використовуються кілька пов'язаних таблиць, очищати їх потрібно з урахуванням зовнішніх ключів. Наприклад, дочірні записи зазвичай видаляють перед батьківськими.
Інший підхід — створювати окрему транзакцію для кожного тесту й виконувати rollback після нього. Проте код, який тестується, повинен використовувати той самий QueryRunner, інакше його запити можуть виконуватися в іншій транзакції. Для звичайних інтеграційних тестів очищення таблиць часто є простішим і зрозумілішим рішенням.
Важливо перевіряти не лише те, що сервіс викинув помилку. Потрібно перевірити стан бази після помилки.
Наприклад, у тесті недостатнього балансу:
створюються два рахунки;
виконується переказ на суму, якої недостатньо;
очікується помилка;
повторно читаються обидва рахунки;
перевіряється, що жоден баланс не змінився.
Якби операції виконувалися без транзакції, сервіс міг би спочатку зменшити баланс першого рахунку, а потім завершитися з помилкою до поповнення другого рахунку.
Для простих операцій достатньо перевіряти результат через репозиторій:
const account = await repository.findOneByOrFail({
id: accountId,
});
expect(account.balance).toBe(1000);Для складніших запитів варто перевіряти:
кількість отриманих записів;
порядок записів;
фільтрацію;
обчислені значення;
поведінку на порожньому результаті.
Тест має перевіряти поведінку, а не внутрішній текст SQL. Наприклад, замість перевірки того, що QueryBuilder викликав where, краще перевірити, які записи фактично повернув PostgreSQL.
Переконайтеся, що PostgreSQL запущений:
docker compose up -d postgres-testПісля цього запустіть Jest:
npm test -- account.int-spec.tsЯкщо тести використовують змінні середовища з окремого файлу, перед запуском потрібно завантажити їх у процес Node.js. Також можна задати значення безпосередньо в команді:
DB_HOST=localhost \
DB_PORT=5433 \
DB_USERNAME=test_user \
DB_PASSWORD=test_password \
DB_NAME=nest_test \
npm test -- account.int-spec.tsМок репозиторію корисний для unit-тестів сервісу, але він не замінює інтеграційний тест.
Unit-тест із моком може перевірити:
чи викликав сервіс потрібний метод;
чи правильно обробляється помилка;
чи формується потрібна відповідь.
Інтеграційний тест із PostgreSQL додатково перевіряє:
коректність типів колонок;
реальне виконання SQL;
обмеження бази даних;
поведінку транзакцій;
підтримку PostgreSQL-специфічних можливостей;
правильність роботи блокувань.
Для запитів, транзакцій і репозиторіїв бази даних потрібні саме інтеграційні тести. Мок може випадково дозволити неправильний запит, тому що він не виконує його в PostgreSQL.
Тести з dropSchema: true можуть видалити таблиці робочої бази. Використовуйте окрему базу та окремі облікові дані для тестів.
synchronize: true у productionЦя опція призначена для локальної розробки та тестової бази. Для production використовуйте міграції.
Усі операції, які повинні бути атомарними, мають використовувати manager, отриманий у callback dataSource.transaction().
Перевірка rejects.toThrow() не доводить, що транзакція виконала rollback. Після очікуваної помилки потрібно повторно прочитати дані й перевірити їхній стан.
Якщо не очищати таблиці, один тест може впливати на інший. Це призводить до нестабільних результатів залежно від порядку виконання.
Контейнер може бути запущений, але база ще не приймати підключення. Перед тестами дочекайтеся готовності PostgreSQL або використовуйте health check і запуск тестів після його успішного проходження.
Інтеграційні тести NestJS можна запускати проти окремої реальної бази PostgreSQL.
Тестовий модуль створюється через TypeOrmModule.forRoot() і TypeOrmModule.forFeature().
Репозиторії потрібно перевіряти реальними операціями збереження та читання.
Запити через QueryBuilder слід перевіряти за фактичним результатом.
Успішна транзакція повинна зберігати всі зміни.
Помилка всередині транзакції повинна повертати базу до попереднього стану.
Дані між тестами потрібно ізолювати.
Тестова база повинна бути повністю відокремлена від production.