Найти в Дзене
ДзенФорматор

Изолированные среды с помощью Testcontainers

Оглавление

Каждый сталкивался с необходимостью писать интеграционные тесты для приложения, над которым он работает, что приводит к переполнению базы данных тоннами данных, сгенерированных тестовым скриптом (скриптами). Даже после очистки могут оставаться случаи, когда наши данные во время тестирования разработки мешают тестированию, что приводит к его недетерминированности (нам это не нужно).

Один из способов отделить тестовую среду от среды разработки — использовать отдельный экземпляр базы данных, предназначенный для тестирования. Однако если задействованы другие службы, такие как кэширование, брокер сообщений и службы поисковых систем, запуск нескольких экземпляров для тестирования может привести к напрасной трате ценных системных ресурсов. Это становится ещё более сложной задачей при работе с несколькими приложениями, требующими дополнительных служб и решений на основе оперативной памяти. Накладные расходы и необходимость тестирования таких решений могут ещё больше усложнить процесс.

Что такое 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 мы должны найти следующий результат.

Результаты тестирования текущей операции CRUD
Результаты тестирования текущей операции CRUD

Сделав всё это, мы полностью изолировали среду разработки и тестовую среду баз данных. Testcontainers предоставляет контейнеры для более чем 50 распространённых сервисов, таких как Redis, Elasticsearch, KeyCloak, MinIO, Nginx и многих других.

Это даёт преимущества как:

  1. Тесты более детерминированы.
  2. Тесты можно запускать в среде, которая полностью повторяет производственную.
  3. Меньше накладных расходов на написание макетов или реализацию внешних сервисов в памяти.
  4. Не нужно беспокоиться об очистке тестовых данных, поскольку тесты выполняются во временных средах.