Пошук уроків, статей та іншого контенту
Створите наскрізні тести, які перевіряють застосунок через реальні HTTP-запити від маршруту до бази даних.
E2E-тест у NestJS перевіряє застосунок як зовнішній клієнт:
формує HTTP-запит;
проходить через middleware, guards, pipes та interceptors;
потрапляє до controller і service;
виконує реальну операцію в базі даних;
повертає HTTP-відповідь клієнту.
На відміну від unit-тесту, E2E-тест не підміняє service або repository mock-об’єктами. Він перевіряє взаємодію компонентів у зібраному застосунку.
Такий тест відповідає на запитання:
Чи працює функціональність так, як її бачить реальний HTTP-клієнт?
Для E2E-тестів у NestJS зазвичай використовують:
@nestjs/testing для створення тестового застосунку;
supertest для HTTP-запитів;
Jest як тестовий runner;
окрему тестову базу даних.
Тестова база даних має бути ізольованою від development і production-баз. Наприклад, для PostgreSQL можна використовувати окрему базу:
DATABASE_URL=postgresql://app:password@localhost:5432/app_testТест не повинен видаляти або змінювати дані в реальній базі застосунку.
Якщо Supertest ще не встановлений:
npm install --save-dev supertest @types/supertestУ типовому NestJS-проєкті Jest і @nestjs/testing вже налаштовані.
У E2E-тесті потрібно створити TestingModule, імпортувати основний AppModule, створити Nest-застосунок і викликати init().
Виклик app.init() важливий: саме він запускає модулі, залежності, middleware та інші частини NestJS-застосунку.
Тест не викликає app.listen(). Supertest надсилає запити безпосередньо до HTTP-сервера, який належить Nest-застосунку.
Нижче наведено приклад для застосунку, у якому є:
маршрут POST /users;
маршрут GET /users/:id;
UserRepository, підключений до реальної бази даних;
унікальне поле email.
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { DataSource, Repository } from 'typeorm';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { User } from '../src/users/entities/user.entity';
describe('UsersController (e2e)', () => {
let app: INestApplication;
let dataSource: DataSource;
let usersRepository: Repository<User>;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
// Глобальні налаштування мають відповідати production-конфігурації застосунку.
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
}),
);
await app.init();
dataSource = app.get(DataSource);
usersRepository = dataSource.getRepository(User);
});
beforeEach(async () => {
// Кожен тест починається з відомого стану бази даних.
await usersRepository.clear();
});
afterAll(async () => {
await app.close();
});
it('створює користувача через HTTP і зберігає його в базі даних', async () => {
const createUserDto = {
email: 'olena@example.com',
name: 'Olena',
password: 'strong-password',
};
const response = await request(app.getHttpServer())
.post('/users')
.send(createUserDto)
.expect(201);
expect(response.body).toEqual(
expect.objectContaining({
email: createUserDto.email,
name: createUserDto.name,
}),
);
expect(response.body.password).toBeUndefined();
const savedUser = await usersRepository.findOneBy({
email: createUserDto.email,
});
expect(savedUser).not.toBeNull();
expect(savedUser?.name).toBe(createUserDto.name);
});
it('повертає створеного користувача за його ідентифікатором', async () => {
const user = await usersRepository.save(
usersRepository.create({
email: 'kateryna@example.com',
name: 'Kateryna',
password: 'hashed-password',
}),
);
const response = await request(app.getHttpServer())
.get(`/users/${user.id}`)
.expect(200);
expect(response.body).toEqual(
expect.objectContaining({
id: user.id,
email: user.email,
name: user.name,
}),
);
expect(response.body.password).toBeUndefined();
});
it('повертає 400 для некоректного тіла запиту', async () => {
await request(app.getHttpServer())
.post('/users')
.send({
email: 'invalid-email',
name: '',
password: 'short',
})
.expect(400);
});
it('не дозволяє створити двох користувачів з однаковим email', async () => {
const user = {
email: 'duplicate@example.com',
name: 'First user',
password: 'strong-password',
};
await request(app.getHttpServer()).post('/users').send(user).expect(201);
await request(app.getHttpServer())
.post('/users')
.send({
...user,
name: 'Second user',
})
.expect(409);
});
});Цей тест проходить повний шлях:
Supertest
→ HTTP server NestJS
→ ValidationPipe
→ UsersController
→ UsersService
→ UserRepository
→ тестова база даних
→ HTTP-відповідьШляхи імпортів, назви сутностей і формат DTO потрібно адаптувати до конкретної структури проєкту.
E2E-тест має запускати ті самі глобальні налаштування, що й production-застосунок. Наприклад:
ValidationPipe;
глобальний префікс маршрутів;
middleware;
exception filters;
interceptors;
guards.
Якщо у main.ts застосунок налаштований так:
app.setGlobalPrefix('api');
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
}),
);то E2E-тест також повинен це враховувати:
app.setGlobalPrefix('api');
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
}),
);У такому випадку запит до користувачів буде виконуватися за адресою /api/users, а не /users.
Щоб не дублювати конфігурацію, зручно винести її в окрему функцію:
import { INestApplication, ValidationPipe } from '@nestjs/common';
export function configureApp(app: INestApplication): INestApplication {
app.setGlobalPrefix('api');
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
}),
);
return app;
}У production-коді:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { configureApp } from './configure-app';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
configureApp(app);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();У E2E-тесті:
const app = moduleFixture.createNestApplication();
configureApp(app);
await app.init();Так тест не перевіряє конфігурацію, яка відрізняється від конфігурації реального запуску.
Тести повинні бути незалежними. Результат одного тесту не може впливати на результат іншого.
Найпростіший підхід — очищати потрібні таблиці перед кожним тестом:
beforeEach(async () => {
await usersRepository.clear();
});Однак clear() може бути непридатним у складнішій схемі з foreign key або кількома пов’язаними таблицями. У такому разі потрібно:
очищати таблиці в правильному порядку;
використовувати транзакції;
створювати окрему базу для кожного тестового запуску;
виконувати міграції перед запуском тестів.
Важливо, щоб схема тестової бази відповідала схемі застосунку. Якщо застосунок використовує міграції, E2E-процес має виконувати ці міграції перед тестами.
Для невеликого набору тестів достатньо очищення таблиць. Для великих тестових наборів очищення може бути повільним.
Інший підхід — виконувати кожен тест у транзакції та відкотити її після завершення. Але цей спосіб потребує, щоб усі операції застосунку використовували те саме транзакційне з’єднання. Простого створення окремої транзакції в тесті недостатньо: repository всередині NestJS може працювати через інше з’єднання.
Тому транзакції слід застосовувати лише тоді, коли тестова інфраструктура явно підтримує спільний transaction context. Інакше безпечніше використовувати очищення таблиць або окрему базу.
E2E-тест має перевіряти не тільки статус-код, а й важливі властивості відповіді:
статус;
тіло відповіді;
заголовки;
структуру помилки;
відсутність конфіденційних полів.
Наприклад, для операції створення користувача недостатньо перевірити лише 201. Потрібно також переконатися, що пароль не повертається клієнту:
const response = await request(app.getHttpServer())
.post('/users')
.send({
email: 'user@example.com',
name: 'Test user',
password: 'strong-password',
})
.expect(201);
expect(response.body.email).toBe('user@example.com');
expect(response.body.password).toBeUndefined();Не варто порівнювати весь об’єкт відповіді через toEqual, якщо він містить поля, які можуть змінюватися, наприклад id, createdAt або updatedAt. Для цього краще використовувати expect.objectContaining.
E2E-тести повинні перевіряти основні негативні сценарії:
відсутнє обов’язкове поле;
неправильний формат значення;
ресурс не знайдено;
порушено унікальність;
користувач не має доступу;
запит не проходить авторизацію.
Наприклад:
it('повертає 404, якщо користувача не існує', async () => {
await request(app.getHttpServer())
.get('/users/999999')
.expect(404);
});Для помилки валідації можна перевірити структуру тіла відповіді:
it('повертає помилки валідації для порожнього тіла', async () => {
const response = await request(app.getHttpServer())
.post('/users')
.send({})
.expect(400);
expect(response.body).toEqual(
expect.objectContaining({
statusCode: 400,
}),
);
expect(response.body.message).toEqual(expect.any(Array));
});Точна структура помилки залежить від глобального exception filter і налаштувань NestJS.
У NestJS E2E-тести зазвичай зберігають у каталозі test і запускають окремою командою.
Приклад секції scripts у package.json:
{
"scripts": {
"test:e2e": "jest --config ./test/jest-e2e.json --runInBand"
}
}Опція --runInBand запускає тести послідовно в одному процесі. Це корисно, коли всі тести використовують одну тестову базу даних і можуть конфліктувати через спільний стан.
Приклад конфігурації test/jest-e2e.json:
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "..",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}Перед запуском потрібно переконатися, що:
тестова база даних доступна;
встановлені потрібні змінні середовища;
схема бази даних оновлена;
застосунок не підключається до production-бази.
Команда запуску:
npm run test:e2eE2E-тести не повинні дублювати кожен рядок unit-тестів. Їхнє завдання — перевірити важливі сценарії користувача.
Для одного HTTP-маршруту зазвичай варто покрити:
успішний сценарій;
невалідні вхідні дані;
відсутній ресурс;
конфлікт або порушення бізнес-правила;
перевірку збереження результату в базі даних.
Наприклад, для POST /users важливі такі перевірки:
користувач створюється з коректними даними;
DTO проходить валідацію;
дубльований email відхиляється;
пароль не потрапляє у відповідь;
запис справді з’являється в базі даних.
Тестуйте зовнішню поведінку системи, а не внутрішню реалізацію. E2E-тесту не потрібно знати, який саме service або repository викликається всередині.
Найнебезпечніша помилка — запуск E2E-тестів із production-змінними середовища.
Перевіряйте:
назву бази;
хост;
користувача;
режим завантаження конфігурації;
значення NODE_ENV.
Тестова конфігурація повинна явно вказувати на окрему базу.
Якщо repository замінений mock-об’єктом, тест уже не перевіряє шлях від HTTP-маршруту до бази даних. Такий підхід підходить для unit-тестів, але не для E2E.
app.init()Без await app.init() модулі та HTTP-інфраструктура можуть бути не готові до обробки запитів.
Якщо в main.ts застосовано глобальний префікс, pipe або guard, але в тесті його немає, E2E-тест перевіряє інший застосунок.
Тест не повинен очікувати, що користувач був створений попереднім тестом. Кожен тест має сам підготувати потрібні дані.
Статус 201 не гарантує, що дані правильно збережені. Перевіряйте також тіло відповіді та стан тестової бази.
Якщо не викликати await app.close(), Jest може залишити відкриті з’єднання з базою або інші ресурси.
Пов’язані записи можуть блокувати очищення через foreign key. У такому разі потрібно спочатку очищати дочірні таблиці або використовувати відповідну стратегію підготовки тестових даних.
E2E-тест перевіряє застосунок через реальні HTTP-запити.
У NestJS для цього використовують @nestjs/testing, Jest і Supertest.
TestingModule має імпортувати реальний AppModule.
Тестова база даних повинна бути ізольована від інших середовищ.
E2E-тест має перевіряти маршрут, валідацію, бізнес-результат і збереження даних.
Глобальна конфігурація в тесті повинна відповідати конфігурації реального застосунку.
Тести мають бути незалежними та очищати або ізолювати свій стан.
Після завершення тестів потрібно закривати Nest-застосунок і з’єднання з базою даних.