Опирается на правила: 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.

Что запрещено

АнтипаттернПравилоЧто взамен
Каждый тест-файл создаёт свой PostgreSqlContainerNODETEST-5один контейнер в globalSetup
app.init() в beforeEachNODETEST-6beforeAll + afterAll
new Date() / randomUUID() в Handler/ServiceNODETEST-X2@Inject(CLOCK) / @Inject(UUID_PROVIDER)
jest.spyOn(Date, 'now', ...) вместо DI-overrideNODETEST-7.overrideProvider(CLOCK).useValue(fixedClock)
Живой Keycloak или реальный JWT в тестеNODETEST-X6FakeAuthGuard + successToken()
Сборка JWT-заголовка руками в каждом тестеNODETEST-8successToken() / customerToken(id)
synchronize: true или drop-and-create схемы между тестамиNODETEST-X3миграции один раз при старте, DELETE/TRUNCATE в beforeEach
Хардкод localhost:5432 в конфиге тестовNODETEST-5process.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: что чем покрывать.