Каждый сталкивался с необходимостью писать интеграционные тесты для приложения, над которым он работает, что приводит к переполнению базы данных тоннами данных, сгенерированных тестовым скриптом (скриптами). Даже после очистки могут оставаться случаи, когда наши данные во время тестирования разработки мешают тестированию, что приводит к его недетерминированности (нам это не нужно).
Один из способов отделить тестовую среду от среды разработки — использовать отдельный экземпляр базы данных, предназначенный для тестирования. Однако если задействованы другие службы, такие как кэширование, брокер сообщений и службы поисковых систем, запуск нескольких экземпляров для тестирования может привести к напрасной трате ценных системных ресурсов. Это становится ещё более сложной задачей при работе с несколькими приложениями, требующими дополнительных служб и решений на основе оперативной памяти. Накладные расходы и необходимость тестирования таких решений могут ещё больше усложнить процесс.
Что такое Testcontainers?
Testcontainers — это инструмент, созданный для запуска сервисов в облегченных докер-контейнерах для ваших тестов. Помимо изоляции тестовой среды, он также позволяет имитировать тестовую среду, максимально приближенную к производственной. Кроме того, снижается нагрузка при написании имитаций сервисов. Теперь давайте рассмотрим, как использовать Testcontainers в вашем проекте Node.js. Мы будем использовать Node, NestJs, Jest, Prisma, Postgresql и Testcontainers.
Настройка среды
Давайте создадим демонстрационное приложение для задач и напишем несколько тестов. Выполним следующие команды для установки необходимого ПО.
Команды для установки и настройки проекта
pnpm i -g @nestjs/cli
nest new todo-app && cs todo-app
pnpm i -D prisma @testcontainers/posgresql
pnpm prisma init
nest g service prisma --no-spec --flat
nest g mo todo && nest g s todo
Настройка базы данных
Теперь давайте определим схему нашей базы данных в prisma/schema.prisma. Просто добавьте в файл следующее содержимое.
Модель Prisma для повседневных дел
model Todo {
id String @id @default(uuid()) @db.Uuid
title String
done Boolean @default(false)
createdAt DateTime @default(now()) @db.Timestamp(6)
updatedAt DateTime @updatedAt @db.Timestamp(6)
}
Давайте теперь настроим нашу базу данных Postgres. Для этого я буду использовать Docker. Создайте новый файл в корневом каталоге проекта и назовите его docker-compose.yml. Затем скопируйте следующий текст.
Docker-составить файл.yml
version: '3.9'
services:
todo-db:
image: "postgres:15"
env_file:
- .env
ports:
- "${POSTGRES_PORT}:5432"
volumes:
- db_data:/var/lib/postgresql/data
volumes:
db_data:
Теперь откройте .env файл и обновите его содержимое следующим образом.
Содержимое файла .env
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DB=todo_db
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}?schema=public"
Запустите docker compose up -d, и ваша база данных должна быть запущена.
Чтобы синхронизировать базу данных с нашей схемой, выполните команду :
pnpm prisma migrate dev и назовите миграцию так, как вы хотите, когда вам будет предложено.
Теперь давайте настроим нашу службу баз данных. Откройте файл src/prisma.service.ts и обновите исходный код следующим образом:
Служебный файл базы данных
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}
Письменные услуги
Теперь давайте напишем простой CRUD-сервис для приложения «Список дел», которое мы собираемся создать. Обновите src/todo/todo.module.ts и добавьте PrismaService в список провайдеров. Затем откройте файл src/todo/todo.service.ts и обновите его следующим образом:
Служебный файл для выполнения текущих задач
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma.service';
@Injectable()
export class TodoService {
constructor(private readonly prisma: PrismaService) {}
async create(data: any) {
return await this.prisma.todo.create({ data });
}
async findAll() {
return await this.prisma.todo.findMany();
}
async findOne(id: string) {
return await this.prisma.todo.findUnique({ where: { id } });
}
async update(id: string, data: any) {
return await this.prisma.todo.update({ where: { id }, data });
}
async remove(id: string) {
return await this.prisma.todo.delete({ where: { id } });
}
}
Написание вспомогательных функций
Теперь давайте напишем несколько вспомогательных функций для настройки testcontainers. Создайте новый файл utils/test.utils.ts и заполните его следующим образом:
Содержимое test.utils.ts
import { exec } from 'child_process';
import { promisify } from 'util';
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { PrismaService } from '../prisma.service';
const execAsync = promisify(exec);
async function setupTestContainer() {
const container = await new PostgreSqlContainer().start();
const connectionConfig = {
host: container.getHost(),
port: container.getMappedPort(5432),
database: container.getDatabase(),
user: container.getUsername(),
password: container.getPassword(),
};
return connectionConfig;
}
export async function setupPrismaService() {
const connectionConfig = await setupTestContainer();
const databaseUrl = `postgresql://${connectionConfig.user}:${connectionConfig.password}@${connectionConfig.host}:${connectionConfig.port}/${connectionConfig.database}`;
const result = await execAsync(
`DATABASE_URL=${databaseUrl} npx prisma migrate deploy --preview-feature`,
);
const prisma = new PrismaService({
datasources: {
db: {
url: databaseUrl,
},
},
});
return prisma;
}
Вышеупомянутые функции создадут контейнер базы данных и сервис Prisma, который будет подключаться к контейнеру во время тестирования. Он также применит миграции к базе данных тестового контейнера.
Теперь вернёмся к todo.service.spec.ts и заменим службу Prisma нашей собственной службой Prisma, которая подключается к тестовому контейнеру. Замените функцию beforeAll следующим фрагментом кода.
beforeAll функция для todo.service.spec.ts
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [TodoService, PrismaService],
})
.overrideProvider(PrismaService)
.useValue(await setupPrismaService())
.compile();
service = module.get<TodoService>(TodoService);
});
Приведённый выше фрагмент кода заменяет PrismaService на экземпляр PrismaService, созданный setupPrismaService и подключённый к базе данных в тестовом контейнере.
Написание Реальных тестов
Написание тестов — несложная задача. Как уже говорилось ранее, здесь не потребуется много имитаций. Давайте напишем простой тест для вставки задачи в базу данных.
Тест для вставки задачи в базу данных
it('should create a todo', async () => {
const todo = {
title: 'Test',
};
const result = await service.create(todo);
expect(result).toBeDefined();
expect(result.id).toBeDefined();
expect(result.title).toBe(todo.title);
expect(result.done).toBe(false);
});
Теперь запустите тест с помощью команды pnpm test и убедитесь, что тест пройден.
Теперь давайте напишем ещё несколько тестов для оставшихся операций CRUD
Тесты на CRUD объекта to-do
it('should find all todos', async () => {
const result = await service.findAll();
expect(result).toBeDefined();
expect(result.length).toBe(1);
});
it('should find a todo by id', async () => {
const todo = await service.create({
title: 'Test2',
});
const result = await service.findOne(todo.id);
expect(result).toBeDefined();
expect(result.id).toBe(todo.id);
});
it('should update a todo', async () => {
const todo = await service.create({
title: 'Test3',
});
const updatedValue = {
title: 'Test4',
};
const result = await service.update(todo.id, updatedValue);
expect(result).toBeDefined();
expect(result.id).toBe(todo.id);
expect(result.title).toBe(updatedValue.title);
});
it('should delete a todo', async () => {
const todo = await service.create({
title: 'Test5',
});
const result = await service.remove(todo.id);
expect(result).toBeDefined();
expect(result.id).toBe(todo.id);
const found = await service.findOne(todo.id);
expect(found).toBeNull();
});
После выполнения команды pnpm test мы должны найти следующий результат.
Сделав всё это, мы полностью изолировали среду разработки и тестовую среду баз данных. Testcontainers предоставляет контейнеры для более чем 50 распространённых сервисов, таких как Redis, Elasticsearch, KeyCloak, MinIO, Nginx и многих других.
Это даёт преимущества как:
- Тесты более детерминированы.
- Тесты можно запускать в среде, которая полностью повторяет производственную.
- Меньше накладных расходов на написание макетов или реализацию внешних сервисов в памяти.
- Не нужно беспокоиться об очистке тестовых данных, поскольку тесты выполняются во временных средах.