Опирается на правила:
NODETEST-4,NODETEST-5,NODETEST-6,NODETEST-7,NODETEST-8из Node Test Strategy → раздел 2. Базовый слой (TestingModule).
Важно знать
- Один платформенный test-setup на сервис: фабрика
TestingModule+globalSetupдля Testcontainers; доменные хелперы — на каждый Bounded Context отдельно.PostgreSqlContainerстартует один раз на прогон (jest globalSetup); DSN прокидывается черезConfigService-override или env-переменную, не хардкодом.- Дорогой setup (
container, schema,app.init()) —beforeAll, неbeforeEach;app.close()строго вafterAll.- Время и UUID — кастомные провайдеры с токенами
CLOCK/UUID_PROVIDER; в доменном коде —@Inject(CLOCK), в тесте —.overrideProvider(CLOCK).useValue(fixedClock).- Тестовая авторизация — override JWT-стратегии или guard-а на фейк; хелпер
successToken()— единый источник правды для токенов.- Никаких
new Date()/randomUUID()в Handler/Service/Aggregate напрямую — детерминизм ломается.app.close()вafterAll— Testcontainers завершают контейнер вglobalTeardown; не вызыватьcontainer.stop()внутри тестового файла.
Зачем нужен платформенный setup
В NestJS каждый интеграционный тест, собранный через Test.createTestingModule, подключает реальный DI-контейнер приложения. Если каждый файл стартует свой PostgreSqlContainer и свой app.init() — тесты медленные, ресурсы расходуются кратно числу файлов, Testcontainers перегружает Docker.
NODETEST-4 и NODETEST-5 фиксируют инвариант: контейнер стартует один раз (в jest globalSetup), DSN прокидывается в окружение, а каждый тестовый файл получает готовый app через фабрику. Доменная чистка (DELETE / TRUNCATE) — в beforeEach конкретного describe-блока; пересоздание схемы между тестами запрещено.
globalSetup — запуск Testcontainers
// test/global-setup.ts
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
let container: StartedPostgreSqlContainer;
export default async function globalSetup() {
container = await new PostgreSqlContainer('postgres:16-alpine')
.withDatabase('orders_test')
.withUsername('test')
.withPassword('test')
.start();
process.env.DATABASE_URL = container.getConnectionUri();
// миграции один раз на весь прогон
await runMigrations(process.env.DATABASE_URL);
}
export async function globalTeardown() {
await container.stop();
}
// jest.config.ts (фрагмент)
{
"globalSetup": "./test/global-setup.ts",
"globalTeardown": "./test/global-teardown.ts"
}
NODETEST-5: DSN берётся из process.env.DATABASE_URL — не хардкод строки подключения, не localhost:5432 с заранее известным портом. Testcontainers выдаёт порт динамически.
Фабрика TestingModule
Один платформенный хелпер на сервис (NODETEST-4). Все тестовые файлы получают app и дополнительные хелперы через него — не собирают TestingModule руками каждый раз.
// test/app-factory.ts
import { Test, TestingModule } from '@nestjs/testing';
import { AppModule } from '../src/app.module';
import { ConfigService } from '@nestjs/config';
import { CLOCK, UUID_PROVIDER } from '../src/shared/tokens';
export interface TestApp {
module: TestingModule;
app: INestApplication;
fixedClock: { now: () => Date };
fixedUuid: { generate: () => string };
}
export async function createTestApp(overrides?: {
clockAt?: Date;
uuid?: string;
}): Promise<TestApp> {
const clockAt = overrides?.clockAt ?? new Date('2026-01-15T12:00:00Z');
const uuid = overrides?.uuid ?? '11111111-1111-1111-1111-111111111111';
const fixedClock = { now: () => clockAt };
const fixedUuid = { generate: () => uuid };
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(CLOCK)
.useValue(fixedClock)
.overrideProvider(UUID_PROVIDER)
.useValue(fixedUuid)
.overrideProvider(ConfigService)
.useValue({
get: (key: string) => {
if (key === 'DATABASE_URL') return process.env.DATABASE_URL;
return process.env[key];
},
})
.compile();
const app = moduleRef.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
await app.init();
return { module: moduleRef, app, fixedClock, fixedUuid };
}
Точка роста: если нужен override JWT-guard (следующий раздел), он добавляется здесь же — не в каждом тесте.
beforeAll / afterAll — структура теста
NODETEST-6: дорогой setup один раз на блок describe, чистка БД — в beforeEach.
// orders/create-order.integration-spec.ts
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { createTestApp, TestApp } from '../../test/app-factory';
import { OrderDatabasePreparer } from '../../test/order-database-preparer';
describe('POST /v1/orders (CreateOrder)', () => {
let testApp: TestApp;
let preparer: OrderDatabasePreparer;
beforeAll(async () => {
testApp = await createTestApp();
preparer = new OrderDatabasePreparer(testApp.module);
});
afterAll(async () => {
await testApp.app.close();
});
beforeEach(async () => {
await preparer.clearOrders();
await preparer.clearCustomers();
});
it('BR-001: creates order when customer exists', async () => {
// Arrange
await preparer.createCustomer({ id: 'c-1', email: 'ivan@sber.ru' }).prepare();
// Act
const res = await request(testApp.app.getHttpServer())
.post('/v1/orders')
.set('Authorization', `Bearer ${successToken()}`)
.send({ customerId: 'c-1', amount: 5000 });
// Assert
expect(res.status).toBe(201);
expect(res.body.orderId).toBe('11111111-1111-1111-1111-111111111111');
expect(res.body.createdAt).toBe('2026-01-15T12:00:00.000Z');
});
});
app.init() в beforeAll — один раз на describe. Перезапускать приложение между тестами в рамках одного файла — антипаттерн: это секунды, не миллисекунды.
Провайдеры времени и UUID
NODETEST-7: детерминизм через DI-токены, не через jest.spyOn(Date, 'now').
// src/shared/tokens.ts
export const CLOCK = Symbol('CLOCK');
export const UUID_PROVIDER = Symbol('UUID_PROVIDER');
// src/shared/clock.interface.ts
export interface Clock {
now(): Date;
}
// src/shared/uuid-provider.interface.ts
export interface UuidProvider {
generate(): string;
}
// src/shared/real-clock.ts
import { Injectable } from '@nestjs/common';
import { Clock } from './clock.interface';
@Injectable()
export class RealClock implements Clock {
now(): Date {
return new Date();
}
}
// src/order/create-order.handler.ts (фрагмент)
@Injectable()
export class CreateOrderHandler {
constructor(
@Inject(CLOCK) private readonly clock: Clock,
@Inject(UUID_PROVIDER) private readonly uuidProvider: UuidProvider,
private readonly orderRepository: OrderRepository,
) {}
async execute(cmd: CreateOrderCommand): Promise<Order> {
const id = this.uuidProvider.generate();
const createdAt = this.clock.now();
// ...
}
}
В app.module.ts — регистрируем реальные реализации:
{ provide: CLOCK, useClass: RealClock },
{ provide: UUID_PROVIDER, useClass: RealUuidProvider },
В createTestApp — перекрываем на детерминированные значения:
.overrideProvider(CLOCK).useValue({ now: () => new Date('2026-01-15T12:00:00Z') })
.overrideProvider(UUID_PROVIDER).useValue({ generate: () => '11111111-...' })
Почему не jest.spyOn(Date, 'now'): глобальный патч Date влияет на Testcontainers, TypeORM internals и всё остальное в процессе. DI-override влияет только на код, который инжектирует CLOCK.
Тестовая авторизация
NODETEST-8: override JWT-guard + хелпер successToken().
// test/auth/fake-auth.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
@Injectable()
export class FakeAuthGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const header = req.headers['authorization'] ?? '';
if (!header.startsWith('Bearer ')) return false;
const token = header.slice(7);
req.user = decodeTestToken(token);
return true;
}
}
export function decodeTestToken(token: string): Record<string, unknown> {
const payload = Buffer.from(token, 'base64').toString('utf-8');
return JSON.parse(payload);
}
// test/auth/tokens.ts
export function successToken(sub = 'user-1', roles: string[] = ['customer']): string {
const payload = { sub, roles, iat: 0, exp: 9999999999 };
return Buffer.from(JSON.stringify(payload)).toString('base64');
}
export function adminToken(): string {
return successToken('admin-1', ['admin']);
}
export function customerToken(customerId: string): string {
return successToken(customerId, ['customer']);
}
// test/app-factory.ts — добавляем override guard
import { APP_GUARD } from '@nestjs/core';
import { FakeAuthGuard } from './auth/fake-auth.guard';
// внутри .overrideProvider(APP_GUARD).useClass(FakeAuthGuard)
В тесте:
const res = await request(testApp.app.getHttpServer())
.post('/v1/orders')
.set('Authorization', `Bearer ${successToken()}`)
.send({ customerId: 'c-1', amount: 5000 });
Живой Keycloak в тесте — NODETEST-X6 (запрещён). Сборка токена руками в каждом тесте — тоже запрещена: JWT-структура меняется в одном месте, не в десяти.
Пример: полный describe-блок с двумя сценариями
Домены Order и Customer, Sber-контекст.
describe('POST /v1/orders (CreateOrder) — BR-ORD-001, BR-ORD-002', () => {
let testApp: TestApp;
let preparer: OrderDatabasePreparer;
beforeAll(async () => {
testApp = await createTestApp({ uuid: 'aaaa-bbbb-cccc-dddd-eeee' });
preparer = new OrderDatabasePreparer(testApp.module);
});
afterAll(async () => {
await testApp.app.close();
});
beforeEach(async () => {
await preparer.clearOrders();
await preparer.clearCustomers();
});
it('BR-ORD-001: returns 201 and order id when customer exists', async () => {
// Arrange
await preparer
.createCustomer({ id: 'cust-sber', email: 'petrov@sber.ru' })
.prepare();
// Act
const res = await request(testApp.app.getHttpServer())
.post('/v1/orders')
.set('Authorization', `Bearer ${customerToken('cust-sber')}`)
.send({ customerId: 'cust-sber', amount: 12500 });
// Assert
expect(res.status).toBe(201);
expect(res.body.orderId).toBe('aaaa-bbbb-cccc-dddd-eeee');
expect(res.body.status).toBe('DRAFT');
});
it('BR-ORD-002: returns 404 when customer not found', async () => {
// Arrange
// Act
const res = await request(testApp.app.getHttpServer())
.post('/v1/orders')
.set('Authorization', `Bearer ${customerToken('unknown')}`)
.send({ customerId: 'unknown', amount: 100 });
// Assert
expect(res.status).toBe(404);
expect(res.body.code).toBe('CUSTOMER_NOT_FOUND');
});
});
Что есть в примере:
createTestAppс явнымuuid— UUID детерминирован, проверяем в Assert.preparer.clearOrders()/clearCustomers()вbeforeEach— тесты изолированы.customerToken('cust-sber')— токен с конкретнымsub; тест на авторизацию читается без расшифровки заголовка вручную.app.close()вafterAll— не вafterEach.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Каждый тест-файл создаёт свой PostgreSqlContainer | NODETEST-5 | один контейнер в globalSetup |
app.init() в beforeEach | NODETEST-6 | beforeAll + afterAll |
new Date() / randomUUID() в Handler/Service | NODETEST-X2 | @Inject(CLOCK) / @Inject(UUID_PROVIDER) |
jest.spyOn(Date, 'now', ...) вместо DI-override | NODETEST-7 | .overrideProvider(CLOCK).useValue(fixedClock) |
| Живой Keycloak или реальный JWT в тесте | NODETEST-X6 | FakeAuthGuard + successToken() |
| Сборка JWT-заголовка руками в каждом тесте | NODETEST-8 | successToken() / customerToken(id) |
synchronize: true или drop-and-create схемы между тестами | NODETEST-X3 | миграции один раз при старте, DELETE/TRUNCATE в beforeEach |
Хардкод localhost:5432 в конфиге тестов | NODETEST-5 | process.env.DATABASE_URL из Testcontainers |
Куда дальше
- node/basics.md — базовые правила: детерминизм, AAA-структура,
NODETEST-1…3. - node/database-preparer.md —
OrderDatabasePreparer:clear*(),create*(),prepare(), порядок при FK. - node/one-test.md — структура одного теста: имена
it(...), supertest,successToken(). - Пирамида тестов — Test Strategy — unit агрегата, unit контроллера, интеграционный, E2E: что чем покрывать.