Пошук уроків, статей та іншого контенту
Налаштуєте окрему базу даних для тестів, керування схемою та очищення даних між сценаріями.
Інтеграційні та end-to-end тести працюють із реальною базою даних. Якщо використовувати ту саму базу, що й для розробки, тести можуть:
змінити або видалити дані розробника;
залежати від випадкового стану бази;
проходити на одних даних і падати на інших;
конфліктувати між собою.
Для тестів створюють окрему базу, наприклад:
app_development
app_test
app_productionТестова база повинна мати:
окремі параметри підключення;
власну схему, створену міграціями;
очищення даних між тестовими сценаріями.
У прикладах використовується PostgreSQL та TypeORM.
Створіть окремий файл .env.test:
NODE_ENV=test
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USER=postgres
DATABASE_PASSWORD=postgres
DATABASE_NAME=app_testБаза app_test повинна існувати до запуску тестів. Наприклад, її можна створити командою PostgreSQL:
createdb -h localhost -U postgres app_testАбо виконати SQL-команду:
CREATE DATABASE app_test;Файл .env.test не повинен містити облікові дані production-бази. Також його не варто додавати до репозиторію, якщо він містить реальний пароль.
Винесіть параметри підключення до окремого файлу:
// src/database/database.options.ts
import { DataSourceOptions } from 'typeorm';
import { User } from '../users/user.entity';
export function getDatabaseOptions(): DataSourceOptions {
const isTest = process.env.NODE_ENV === 'test';
return {
type: 'postgres',
host: process.env.DATABASE_HOST,
port: Number(process.env.DATABASE_PORT),
username: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME,
entities: [User],
migrations: [
isTest
? 'src/database/migrations/*.ts'
: 'dist/database/migrations/*.js',
],
// Схема створюється міграціями, а не автоматично.
synchronize: false,
// Міграції запускаються явно в тестовому setup-файлі.
migrationsRun: false,
};
}Підключіть ці параметри в AppModule:
// src/app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { getDatabaseOptions } from './database/database.options';
import { User } from './users/user.entity';
@Module({
imports: [
TypeOrmModule.forRoot(getDatabaseOptions()),
TypeOrmModule.forFeature([User]),
],
})
export class AppModule {}Зверніть увагу на два параметри:
synchronize: false — TypeORM не змінює структуру бази автоматично;
migrationsRun: false — міграції запускаються контрольовано, а не під час кожного створення застосунку.
Для тестів це важливо: схема повинна бути передбачуваною та відповідати міграціям у проєкті.
Тестова база не повинна отримувати схему через synchronize: true. Замість цього застосовуйте ті самі міграції, що й для інших середовищ.
Наприклад, міграція створення таблиці користувачів:
// src/database/migrations/1710000000000-create-users.ts
import {
MigrationInterface,
QueryRunner,
Table,
} from 'typeorm';
export class CreateUsers1710000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'users',
columns: [
{
name: 'id',
type: 'integer',
isPrimary: true,
isGenerated: true,
generationStrategy: 'increment',
},
{
name: 'email',
type: 'varchar',
isUnique: true,
},
{
name: 'name',
type: 'varchar',
},
],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('users');
}
}Відповідна сутність:
// src/users/user.entity.ts
import {
Column,
Entity,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
email: string;
@Column()
name: string;
}Міграції змінюють схему, а тести змінюють лише дані. Не потрібно видаляти та повторно створювати таблиці перед кожним тестом.
У Jest можна отримати DataSource із Nest-застосунку та виконати міграції один раз перед усіма тестами.
Створіть setup-файл:
// test/setup.ts
import * as dotenv from 'dotenv';
import { DataSource } from 'typeorm';
dotenv.config({ path: '.env.test' });
let dataSource: DataSource;
beforeAll(async () => {
const { AppModule } = await import('../src/app.module');
const { Test } = await import('@nestjs/testing');
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
const app = moduleRef.createNestApplication();
await app.init();
dataSource = app.get(DataSource);
// Створюємо або оновлюємо схему тестової бази міграціями.
await dataSource.runMigrations();
await app.close();
});
afterAll(async () => {
if (dataSource?.isInitialized) {
await dataSource.destroy();
}
});Однак у реальному e2e-тесті застосунок зазвичай потрібно зберігати до завершення тесту. Зручніше винести створення застосунку та очищення даних у сам тестовий файл або спільний helper.
Схема повинна створюватися один раз, а дані — очищатися після кожного сценарію.
Для PostgreSQL можна використати TRUNCATE:
// test/test-database.ts
import { DataSource } from 'typeorm';
export async function clearTestDatabase(
dataSource: DataSource,
): Promise<void> {
await dataSource.query(
'TRUNCATE TABLE "users" RESTART IDENTITY CASCADE',
);
}Команда:
TRUNCATE TABLE швидко видаляє всі записи;
RESTART IDENTITY скидає лічильник автоінкрементного id;
CASCADE очищає залежні таблиці, якщо вони посилаються на users.
CASCADE потрібно застосовувати лише в тестовій базі. Запуск такої команди в production може видалити всі дані.
Нижче наведений повний приклад тесту, який:
завантажує .env.test;
створює Nest-застосунок;
запускає міграції;
очищає таблицю після кожного тесту;
перевіряє роботу репозиторію.
// test/users.integration.spec.ts
import * as dotenv from 'dotenv';
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { DataSource, Repository } from 'typeorm';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AppModule } from '../src/app.module';
import { User } from '../src/users/user.entity';
dotenv.config({ path: '.env.test' });
describe('Users integration', () => {
let app: INestApplication;
let dataSource: DataSource;
let usersRepository: Repository<User>;
beforeAll(async () => {
const moduleFixture: TestingModule =
await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
dataSource = app.get(DataSource);
usersRepository = app.get<Repository<User>>(
getRepositoryToken(User),
);
// Схема створюється міграціями один раз перед тестами.
await dataSource.runMigrations();
});
afterEach(async () => {
// Дані кожного сценарію не впливають на наступний сценарій.
await dataSource.query(
'TRUNCATE TABLE "users" RESTART IDENTITY CASCADE',
);
});
afterAll(async () => {
await app.close();
});
it('створює користувача в тестовій базі', async () => {
const user = usersRepository.create({
email: 'anna@example.com',
name: 'Anna',
});
const savedUser = await usersRepository.save(user);
expect(savedUser.id).toBe(1);
expect(savedUser.email).toBe('anna@example.com');
});
it('починає роботу з порожньою таблицею', async () => {
const users = await usersRepository.find();
expect(users).toEqual([]);
});
});Другий тест не бачить користувача, створеного в першому тесті. Після першого сценарію afterEach видаляє його, а RESTART IDENTITY повертає лічильник ідентифікаторів до початкового стану.
Додайте окрему команду для інтеграційних тестів:
{
"scripts": {
"test:integration": "NODE_ENV=test jest --runInBand --config ./test/jest-integration.json"
}
}Приклад конфігурації Jest:
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "..",
"testEnvironment": "node",
"testRegex": ".*\\.integration\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/src/$1"
}
}Параметр --runInBand запускає тести послідовно в одному процесі. Це корисно, коли всі тести використовують одну тестову базу.
Якщо запускати тести паралельно, два сценарії можуть одночасно:
додавати записи;
очищати таблиці;
змінювати одну й ту саму схему.
Для паралельного запуску кожному worker-процесу потрібна окрема база або інший механізм ізоляції.
Важливо не змішувати відповідальність цих операцій.
Міграції:
створюють таблиці;
додають або змінюють колонки;
створюють індекси та обмеження;
змінюють структуру бази під час версіювання застосунку.
Їх зазвичай запускають один раз перед тестовим набором.
Очищення:
видаляє записи, створені тестом;
не змінює структуру таблиць;
виконується після кожного тесту або тестового набору.
Не слід викликати runMigrations() після кожного тесту. Це повільно та не вирішує проблему залишкових даних.
Для невеликих проєктів можна очищати репозиторії через delete:
await usersRepository.delete({});Це видалить усі записи з таблиці users, але не обов’язково скине sequence для автоінкрементного ідентифікатора.
Для PostgreSQL TRUNCATE ... RESTART IDENTITY зазвичай краще підходить для повного скидання тестових даних.
Якщо в базі багато таблиць, створіть окрему функцію очищення:
// test/test-database.ts
import { DataSource } from 'typeorm';
export async function clearTestDatabase(
dataSource: DataSource,
): Promise<void> {
await dataSource.query(`
TRUNCATE TABLE
"users"
RESTART IDENTITY CASCADE
`);
}Список таблиць потрібно підтримувати відповідно до схеми проєкту. Не передавайте в цю функцію назву бази або таблицю, отриману з ненадійного зовнішнього вводу.
Для інтеграційного набору з тестовою базою використовуйте такий порядок:
Завантажити .env.test.
Створити Nest-застосунок.
Підключитися саме до app_test.
Запустити міграції.
Виконати тест.
Очистити дані.
Виконати наступний тест.
Закрити застосунок і з’єднання з базою.
Перевірити, що тести використовують правильну базу, можна через логування конфігурації без виведення пароля:
console.log({
environment: process.env.NODE_ENV,
database: process.env.DATABASE_NAME,
});Очікуваним результатом має бути:
{
environment: 'test',
database: 'app_test'
}Якщо .env.test не завантажився, застосунок може використати значення з іншого файлу або системного оточення.
Перевіряйте NODE_ENV і DATABASE_NAME перед запуском тестів.
synchronize: true у тестахSynchronize може автоматично змінювати схему на основі сутностей. Це приховує помилки в міграціях і робить структуру бази залежною від поточного коду.
Для тестів, як і для production, використовуйте:
synchronize: falseЯкщо таблиці мають зовнішні ключі, записи в інших таблицях можуть залишитися та впливати на наступний сценарій.
Очищайте всі таблиці, які змінюють тести, або використовуйте PostgreSQL CASCADE у тестовій базі.
Порожня тестова база не означає, що Nest автоматично створить таблиці. Перед тестами потрібно виконати:
await dataSource.runMigrations();Паралельні тести з однією базою можуть очищати дані один одного. Для простого варіанта використовуйте --runInBand.
Відкрите з’єднання TypeORM може залишити Jest активним після завершення тестів. Закривайте Nest-застосунок через app.close(). Якщо створювали окремий DataSource, додатково викликайте dataSource.destroy().
Для тестів створюйте окрему базу даних, наприклад app_test.
Зберігайте її параметри в .env.test.
Не використовуйте synchronize: true для керування тестовою схемою.
Створюйте схему через міграції.
Запускайте міграції один раз перед тестовим набором.
Очищайте дані після кожного тесту.
Для PostgreSQL зручно використовувати TRUNCATE ... RESTART IDENTITY CASCADE.
Не запускайте очищення тестової бази паралельно без додаткової ізоляції.
Завжди закривайте Nest-застосунок і з’єднання з базою після завершення тестів.