Пошук уроків, статей та іншого контенту
Навчитеся перевіряти взаємодію кількох компонентів NestJS без повної імітації залежностей.
Інтеграційний тест перевіряє взаємодію кількох реальних компонентів застосунку. Наприклад:
HTTP-запит потрапляє до контролера.
Контролер викликає сервіс.
Сервіс працює з репозиторієм.
Результат повертається як HTTP-відповідь.
На відміну від модульного тесту, тут не потрібно імітувати кожну залежність за допомогою jest.fn() або jest.spyOn().
Водночас інтеграційний тест не обов’язково повинен використовувати справжню базу даних. Зовнішню інфраструктуру часто замінюють легким адаптером, наприклад репозиторієм у пам’яті. При цьому контролер і сервіс залишаються справжніми.
Розглянемо простий модуль товарів:
ProductsController приймає HTTP-запити;
ProductsService містить прикладну логіку;
ProductsRepository зберігає товари;
інтеграційний тест перевіряє їхню спільну роботу.
У production-застосунку репозиторій міг би працювати з PostgreSQL або іншою базою даних. У тесті використаємо реалізацію зі сховищем у пам’яті.
Файл products.integration-spec.ts:
import {
Body,
Controller,
Get,
Inject,
Injectable,
Module,
Param,
Post,
} from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
interface Product {
id: number;
name: string;
price: number;
}
interface CreateProductDto {
name: string;
price: number;
}
interface ProductsRepository {
create(data: CreateProductDto): Product;
findAll(): Product[];
findById(id: number): Product | undefined;
}
const PRODUCTS_REPOSITORY = Symbol('PRODUCTS_REPOSITORY');
@Injectable()
class InMemoryProductsRepository implements ProductsRepository {
private readonly products: Product[] = [];
private nextId = 1;
create(data: CreateProductDto): Product {
const product: Product = {
id: this.nextId++,
name: data.name,
price: data.price,
};
this.products.push(product);
return product;
}
findAll(): Product[] {
return this.products;
}
findById(id: number): Product | undefined {
return this.products.find((product) => product.id === id);
}
}
@Injectable()
class ProductsService {
constructor(
@Inject(PRODUCTS_REPOSITORY)
private readonly productsRepository: ProductsRepository,
) {}
createProduct(data: CreateProductDto): Product {
return this.productsRepository.create(data);
}
getProducts(): Product[] {
return this.productsRepository.findAll();
}
getProduct(id: number): Product {
const product = this.productsRepository.findById(id);
if (!product) {
throw new Error('Product not found');
}
return product;
}
}
@Controller('products')
class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Post()
create(@Body() data: CreateProductDto): Product {
return this.productsService.createProduct(data);
}
@Get()
findAll(): Product[] {
return this.productsService.getProducts();
}
@Get(':id')
findOne(@Param('id') id: string): Product {
return this.productsService.getProduct(Number(id));
}
}
@Module({
controllers: [ProductsController],
providers: [
ProductsService,
{
provide: PRODUCTS_REPOSITORY,
useClass: InMemoryProductsRepository,
},
],
})
class ProductsModule {}
describe('Products integration', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [ProductsModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterEach(async () => {
await app.close();
});
it('створює товар через контролер, сервіс і репозиторій', async () => {
const response = await request(app.getHttpServer())
.post('/products')
.send({
name: 'Keyboard',
price: 1500,
})
.expect(201);
expect(response.body).toEqual({
id: 1,
name: 'Keyboard',
price: 1500,
});
});
it('повертає створені товари', async () => {
await request(app.getHttpServer())
.post('/products')
.send({
name: 'Mouse',
price: 800,
})
.expect(201);
const response = await request(app.getHttpServer())
.get('/products')
.expect(200);
expect(response.body).toEqual([
{
id: 1,
name: 'Mouse',
price: 800,
},
]);
});
it('повертає товар за ідентифікатором', async () => {
await request(app.getHttpServer())
.post('/products')
.send({
name: 'Monitor',
price: 9000,
})
.expect(201);
const response = await request(app.getHttpServer())
.get('/products/1')
.expect(200);
expect(response.body).toEqual({
id: 1,
name: 'Monitor',
price: 9000,
});
});
});Для запуску такого тесту в проєкті мають бути встановлені NestJS, Jest і supertest. Стандартний NestJS-проєкт уже містить більшість потрібних залежностей.
Тестовий модуль створюється за допомогою Test.createTestingModule():
const moduleFixture = await Test.createTestingModule({
imports: [ProductsModule],
}).compile();Цей код створює контейнер залежностей NestJS так само, як під час запуску застосунку. Nest:
знаходить контролери;
створює екземпляри сервісів;
розв’язує залежність PRODUCTS_REPOSITORY;
підставляє InMemoryProductsRepository.
Після цього створюється HTTP-застосунок:
app = moduleFixture.createNestApplication();
await app.init();Тест надсилає справжні HTTP-запити до тестового сервера:
await request(app.getHttpServer())
.post('/products')
.send({
name: 'Keyboard',
price: 1500,
})
.expect(201);Тут не викликається контролер напряму. Запит проходить через HTTP-маршрутизацію NestJS, контролер, сервіс і репозиторій.
У модульному тесті сервіс можна було б перевіряти окремо:
const repository = {
create: jest.fn(),
findAll: jest.fn(),
};
const service = new ProductsService(repository);Такий тест швидкий і корисний, але він не перевіряє:
правильність HTTP-маршруту;
зв’язок контролера із сервісом;
реєстрацію провайдерів у модулі;
коректність ін’єкції залежності;
формат HTTP-відповіді.
Інтеграційний тест використовує справжні ProductsController і ProductsService. Підмінено лише сховище, яке в реальному середовищі залежало б від зовнішньої бази даних.
У прикладі репозиторій реєструється через токен:
{
provide: PRODUCTS_REPOSITORY,
useClass: InMemoryProductsRepository,
}Це дає змогу використовувати іншу реалізацію без змін у сервісі:
@Injectable()
class ProductsService {
constructor(
@Inject(PRODUCTS_REPOSITORY)
private readonly productsRepository: ProductsRepository,
) {}
}Сервіс залежить не від конкретного класу бази даних, а від контракту репозиторію. Завдяки цьому в тесті можна підключити реалізацію в пам’яті, яка:
не потребує запуску бази даних;
працює швидко;
має передбачуваний стан;
дозволяє тестувати взаємодію компонентів.
Це відрізняється від повної імітації залежності: репозиторій справді виконує операції зі збереження та пошуку даних, а не лише повертає заздалегідь задані значення.
У прикладі тестовий модуль створюється в beforeEach:
beforeEach(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [ProductsModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});Кожен тест отримує новий екземпляр InMemoryProductsRepository і порожнє сховище. Дані з одного тесту не впливають на інший.
Після кожного тесту застосунок потрібно закрити:
afterEach(async () => {
await app.close();
});Це звільняє ресурси та запобігає ситуаціям, коли Jest не завершує процес через відкриті з’єднання або сервери.
Репозиторій у пам’яті зручний, коли потрібно перевірити взаємодію NestJS-компонентів без залежності від інфраструктури.
Реальну тестову базу даних варто використовувати, якщо тест має перевірити:
SQL-запити;
міграції;
індекси;
обмеження цілісності;
поведінку конкретної бази даних.
У такому випадку тестовий модуль підключають до окремої тестової бази, а після тестів очищають створені дані. Для перевірки саме контролера, сервісу та контракту репозиторію in-memory реалізації часто достатньо.
const controller = app.get(ProductsController);
controller.findAll();Такий підхід обходить HTTP-рівень. Він може бути корисним для окремої перевірки класу, але не є повноцінним інтеграційним тестом контролера.
Для інтеграційного тесту краще надсилати запит через app.getHttpServer().
Якщо один екземпляр репозиторію використовується всіма тестами, порядок виконання може впливати на результати. Тести стають нестабільними.
Створюйте тестовий модуль у beforeEach або очищайте стан репозиторію перед кожним тестом.
app.close()Якщо не закрити NestJS-застосунок, тестовий процес може не завершитися або залишити відкриті ресурси.
Якщо замокінити і контролер, і сервіс, і репозиторій, тест уже не перевірятиме взаємодію компонентів. Моки доречні для зовнішніх систем, але внутрішні компоненти сценарію краще залишати реальними.
Статус 200 або 201 сам по собі не гарантує правильності результату. Перевіряйте також тіло відповіді та ефект операції, наприклад наявність створеного товару у наступному запиті.
Інтеграційний тест перевіряє взаємодію кількох компонентів NestJS.
Для HTTP-сценаріїв використовуйте createNestApplication() і supertest.
Test.createTestingModule() створює тестовий контейнер залежностей NestJS.
Контролер і сервіс можна залишити реальними, а зовнішню базу даних замінити in-memory реалізацією.
Створюйте окремий тестовий модуль для кожного тесту або очищайте спільний стан.
Завжди закривайте застосунок у afterEach.
Інтеграційний тест має перевіряти не лише HTTP-статус, а й результат взаємодії компонентів.