Опирается на правила: NODETEST-26, NODETEST-27, NODETEST-28, NODETEST-X5, NODETEST-X6 из Node Test Strategy → раздел 8. Что НЕ покрывается интеграционными тестами.

Важно знать

  • Интеграционный тест проверяет сквозной путь: HTTP → Handler → PostgreSQL. Он не универсален.
  • Доменная логика агрегата (new Order(...), order.confirm()) — unit без Nest, без контейнера; самые быстрые тесты.
  • Контроллер + сериализация без БДTestingModule + supertest с override порта-репозитория на in-memory фейк.
  • E2E с реальной Kafka — отдельный jest-проект / тег, отдельный CI-этап; не более 5–10 на сервис.
  • Нельзя мокать Handler/Aggregate/порт-репозиторий через jest.mock() внутри интеграционного теста — он проверяет именно реальный путь.
  • Нельзя поднимать живой Keycloak в тесте — только фейковый валидатор (NODETEST-8).
  • Три типа теста образуют пирамиду: unit → integration → E2E; соотношение примерно 60 : 30 : 10.

Интеграционный тест на реальном Postgres — мощный инструмент, но он не единственный и не заменяет остальные уровни. Три сценария нужно выносить за его рамки.

Чистая доменная логика — unit без Nest

NODETEST-26: агрегаты и доменные объекты тестируются как обычные классы TypeScript, без TestingModule, без контейнера, без запроса к БД.

Пример агрегата Order:

export class Order {
  private constructor(
    readonly id: string,
    private status: OrderStatus,
    readonly customerId: string,
  ) {}

  static create(id: string, customerId: string): Order {
    return new Order(id, OrderStatus.DRAFT, customerId);
  }

  confirm(): void {
    if (this.status !== OrderStatus.DRAFT) {
      throw new OrderCannotBeConfirmedException(this.id, this.status);
    }
    this.status = OrderStatus.CONFIRMED;
  }

  getStatus(): OrderStatus {
    return this.status;
  }
}

Unit-тест — это просто Jest, никакого контейнера:

describe('Order', () => {
  it('confirm — when DRAFT — changes status to CONFIRMED', () => {
    const order = Order.create('11111111-1111-1111-1111-111111111111', 'customer-1');

    order.confirm();

    expect(order.getStatus()).toBe(OrderStatus.CONFIRMED);
  });

  it('confirm — when RESERVATION_FAILED — throws OrderCannotBeConfirmedException', () => {
    const order = Order.restore(
      '22222222-2222-2222-2222-222222222222',
      OrderStatus.RESERVATION_FAILED,
      'customer-1',
    );

    expect(() => order.confirm()).toThrow(OrderCannotBeConfirmedException);
  });
});

Что здесь важно:

  • Order инстанцируется напрямую, без DI.
  • Нет beforeAll, нет контейнера, нет HTTP.
  • Тест выполняется за единицы миллисекунд.
  • Граничные условия (CANCELLED, SHIPPED, PAYMENT_FAILED) проверяем именно здесь, не в интеграционном.

Для домена Product и Customer — та же схема: Product.deactivate(), Customer.block(), инварианты агрегата, доменные исключения.

Контроллер + сериализация без БД

NODETEST-27: если нужно проверить только HTTP-слой — валидацию DTO, трансформацию ответа, маппинг ошибок — поднимаем TestingModule с override порта-репозитория на in-memory фейк. Postgres не нужен.

describe('OrderController — serialization', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      controllers: [OrderController],
      providers: [
        ConfirmOrderHandler,
        {
          provide: ORDER_REPOSITORY,
          useValue: {
            findById: jest.fn().mockResolvedValue(
              Order.restore(
                '11111111-1111-1111-1111-111111111111',
                OrderStatus.DRAFT,
                'customer-1',
              ),
            ),
            save: jest.fn().mockResolvedValue(undefined),
          },
        },
        {
          provide: CLOCK,
          useValue: { now: () => new Date('2026-06-01T10:00:00Z') },
        },
        {
          provide: UUID_PROVIDER,
          useValue: { generate: () => '33333333-3333-3333-3333-333333333333' },
        },
      ],
    }).compile();

    app = moduleRef.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
    await app.init();
  });

  afterAll(() => app.close());

  it('POST /v1/orders/:id/confirm — when valid — returns 200 with orderId', async () => {
    await request(app.getHttpServer())
      .post('/v1/orders/11111111-1111-1111-1111-111111111111/confirm')
      .set('Authorization', 'Bearer ' + successToken())
      .expect(200)
      .expect((res) => {
        expect(res.body.orderId).toBe('11111111-1111-1111-1111-111111111111');
        expect(res.body.status).toBe('CONFIRMED');
      });
  });

  it('POST /v1/orders/:id/confirm — when UUID invalid — returns 400', async () => {
    await request(app.getHttpServer())
      .post('/v1/orders/not-a-uuid/confirm')
      .set('Authorization', 'Bearer ' + successToken())
      .expect(400);
  });
});

Разница с интеграционным тестом:

ИнтеграционныйController unit
Postgresреальный (Testcontainers)нет
ORDER_REPOSITORYреальная реализацияin-memory фейк
Что проверяемсквозной путь с БДсериализация, валидация, маппинг
Скорость100–500 мс< 50 мс

NODETEST-X5: нельзя делать то же самое в обратную сторону — мокать Handler или Aggregate внутри интеграционного теста с реальным Postgres. Интеграционный тест проверяет именно реальный путь; mock в нём ломает смысл.

E2E с реальной Kafka — отдельный jest-проект

NODETEST-28: сценарии, требующие реальной Kafka, внешних сервисов или нескольких контейнеров, выносятся в отдельный jest-проект с собственным тегом. Они не входят в основной integration suite.

Структура проекта:

jest.config.ts              ← integration suite (PG only)
jest.e2e.config.ts          ← E2E suite (Kafka + PG + mock-сервисы)
test/
  integration/
    order/
      confirm-order.spec.ts
  e2e/
    order-payment-flow.e2e-spec.ts

jest.e2e.config.ts:

export default {
  rootDir: '.',
  testRegex: '\\.e2e-spec\\.ts$',
  transform: { '^.+\\.ts$': 'ts-jest' },
  testTimeout: 60_000,
};

E2E-тест запускает KafkaContainer + PostgreSqlContainer:

describe('E2E: Order → Payment flow', () => {
  let kafkaContainer: StartedKafkaContainer;
  let pgContainer: StartedPostgreSqlContainer;
  let app: INestApplication;

  beforeAll(async () => {
    kafkaContainer = await new KafkaContainer().start();
    pgContainer = await new PostgreSqlContainer().start();

    const moduleRef = await Test.createTestingModule({
      imports: [
        AppModule.forRoot({
          database: { connectionUri: pgContainer.getConnectionUri() },
          kafka: { brokers: [kafkaContainer.getBootstrapServers()] },
        }),
      ],
    }).compile();

    app = moduleRef.createNestApplication();
    await app.init();
  }, 120_000);

  afterAll(async () => {
    await app.close();
    await kafkaContainer.stop();
    await pgContainer.stop();
  });

  it('OrderConfirmed event published to Kafka after confirm', async () => {
    // Arrange
    // Act
    // Assert
  });
});

Ограничения:

  • Не более 5–10 E2E-тестов на сервис — они дорогие по времени.
  • Отдельный CI-этап: integration suite зелёный → затем E2E.
  • NODETEST-X4: Kafka/Redis-контейнеры нельзя добавлять в основной integration suite.

Пирамида тестов для NestJS-сервиса

         /\
        /E2E\          ≤ 10 тестов, отдельный CI-этап
       /──────\
      /  integ  \      30–50 тестов, Jest + Testcontainers PG
     /────────────\
    /    unit      \   60+ тестов, Jest без Nest
   ──────────────────

Типичное распределение для домена Order:

УровеньЧто покрываетКоличество
UnitИнварианты Order, доменные исключения15–25
IntegrationHTTP → Handler → Postgres20–30
E2EКросс-сервисные сценарии с Kafka3–5

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

АнтипаттернПравилоЧто взамен
jest.mock() Handler/Aggregate в интеграционном тестеNODETEST-X5in-memory фейк только в controller unit-тесте
Живой Keycloak в тестеNODETEST-X6фейковый guard + successToken() хелпер
KafkaContainer в основном integration suiteNODETEST-X4отдельный jest.e2e.config.ts
Граничные условия агрегата только в интеграционныхNODETEST-26unit-тесты агрегата, быстро и многочисленно
Проверка сериализации через тест с реальным PostgresNODETEST-27TestingModule + in-memory репозиторий
Один E2E-тест на весь lifecycle — 200 строкNODETEST-28разбить на сценарии, соблюдать AAA

Куда дальше

  • node/basics.md — базовые правила, детерминизм, AAA-структура.
  • node/testing-module.md — сборка TestingModule, globalSetup для Testcontainers.
  • node/no-kafka-redis-async.md — почему Kafka/Redis не поднимаем в integration suite.
  • Kafka, Redis, async — по умолчанию НЕТ (Java) — тот же принцип, Java-идиомы.