Опирается на правила: NODETEST-15NODETEST-18 из Node Test Strategy → раздел 5. Структура одного теста.

Важно знать

  • Тест использует общий setup-хелпер (app + preparer), не собирает TestingModule руками в каждом файле.
  • Имена: it('<action> when <condition> — <expected>'), BR-код — в describe или it.
  • HTTP через supertest(app.getHttpServer()) — реальный стек Nest, полный контроль над методом, заголовками, телом.
  • JWT через хелпер (successToken(), customerToken(...)), не токены руками.
  • AAA-структура (Arrange / Act / Assert) с пустой строкой между блоками.
  • Assert проверяет и ответ, и состояние БД — не только status-код.
  • DatabasePreparer готовит состояние Arrange; clear*() вызывается в beforeEach через фабрику.

Полный пример

import * as request from 'supertest';
import { createOrderApp } from '../support/app.factory';
import { OrderDatabasePreparer } from '../support/order-database-preparer';
import { OrderBuilder } from '../support/order-builder';
import { successToken } from '../support/auth.helpers';
import { OrderStatus } from '../../src/order/domain/order-status';

describe('POST /v1/orders/:id/confirm', () => {
  let app: INestApplication;
  let preparer: OrderDatabasePreparer;

  beforeAll(async () => {
    ({ app, preparer } = await createOrderApp());
    await app.init();
  });

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

  beforeEach(async () => {
    await preparer.clearOrders();
  });

  it('confirms order when status is DRAFT — returns 200 and outbox event', async () => {
    // Arrange
    const orderId = '11111111-1111-1111-1111-111111111111';
    const order = new OrderBuilder()
      .withId(orderId)
      .withStatus(OrderStatus.DRAFT)
      .build();
    await preparer.createOrder(order).prepare();

    // Act
    const response = await request(app.getHttpServer())
      .post(`/v1/orders/${orderId}/confirm`)
      .set('Authorization', `Bearer ${successToken()}`)
      .send();

    // Assert
    expect(response.status).toBe(200);
    expect(response.body.orderId).toBe(orderId);

    const outbox = await preparer.findOutboxEvents();
    expect(outbox).toHaveLength(1);
    expect(outbox[0].eventType).toBe('OrderConfirmed');
  });
});

Что есть:

  • createOrderApp() возвращает собранное приложение и preparer — TestingModule не собирается руками.
  • beforeEach чистит только нужные таблицы через preparer.clearOrders().
  • AAA-структура с пустыми строками между блоками.
  • Assert проверяет и ответ, и side-effect (Outbox-таблицу).

Фабрика приложения

NODETEST-15: один platform-хелпер на сервис.

// test/support/app.factory.ts
export async function createOrderApp(): Promise<{
  app: INestApplication;
  preparer: OrderDatabasePreparer;
}> {
  const moduleRef = await Test.createTestingModule({
    imports: [AppModule],
  })
    .overrideProvider(CLOCK)
    .useValue(fixedClock)
    .overrideProvider(UUID_PROVIDER)
    .useValue(fixedUuid)
    .compile();

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

  const dataSource = moduleRef.get<DataSource>(DataSource);
  const preparer = new OrderDatabasePreparer(dataSource);

  return { app, preparer };
}

Что даёт фабрика:

  • Единый DI-override для CLOCK и UUID_PROVIDER — детерминизм гарантирован для всех тестов.
  • Единая конфигурация ValidationPipe и прочих глобальных middleware.
  • preparer создаётся из того же DataSource, что и само приложение — нет отдельного соединения.

Не дублируем Test.createTestingModule(...) в каждом describe-блоке: любое расхождение в конфиге делает тесты неконсистентными.

Имена тестов

NODETEST-16: формат it('<action> when <condition> — <expected>').

// Хорошо
it('confirms order when status is DRAFT — returns 200')
it('confirms order when reservation failed — returns 409 OUT_OF_STOCK')
it('creates order when email is invalid — returns 400')
it('gets order when not found — returns 404')
it('cancels order when already cancelled — returns 409')

BR-код — в describe или it:

describe('BR-002: confirm order', () => {
  it('fails when reservation failed — returns 409', async () => { ... });
  it('succeeds when status is DRAFT — returns 200', async () => { ... });
});

// Или прямо в it — когда describe охватывает одно правило
it('BR-002: confirm fails when reservation failed — returns 409', async () => { ... });

BR-код (BR-002) берётся из спецификации Bounded Context (см. Use Case Pattern). Это создаёт прямую трассируемость: спека → тест → код. Изменили BR-002 — сразу видно, какие тесты нужно обновить.

Плохие имена — то, что ничего не говорит:

// Плохо
it('test1')
it('testCancel')
it('should work')
it('order endpoint')

supertest — HTTP с полным контролем

NODETEST-17: вызов через supertest(app.getHttpServer()).

const response = await request(app.getHttpServer())
  .post(`/v1/orders/${orderId}/confirm`)
  .set('Authorization', `Bearer ${successToken()}`)
  .set('X-Idempotency-Key', idempotencyKey)
  .send({ note: 'срочно' });

Что есть:

  • .post(url) — явный HTTP-метод.
  • .set(header, value) — точный контроль заголовков.
  • .send(body) — тело запроса.
  • Ответresponse.status, response.body, response.headers.

Полный пример с проверкой ответа:

const response = await request(app.getHttpServer())
  .post(`/v1/orders/${orderId}/confirm`)
  .set('Authorization', `Bearer ${successToken()}`)
  .send();

expect(response.status).toBe(409);
expect(response.body.code).toBe('OUT_OF_STOCK');
expect(response.headers['content-type']).toMatch(/application\/json/);

supertest vs TestRestTemplate

ИнструментКогда
supertestIntegration-тест с реальным NestJS context + PostgreSQL
supertest + in-memory фейкUnit-тест контроллера: overrideProvider порта-репозитория на фейк, без БД

supertest работает с реальным HTTP-сервером Nest — проходит весь стек: guards, pipes, interceptors, exception filters. Это и есть нужный уровень интеграции.

JWT через хелперы

NODETEST-18: токены через хелперы, не руками.

// Хорошо
import { successToken, customerToken, adminToken } from '../support/auth.helpers';

.set('Authorization', `Bearer ${successToken()}`)
.set('Authorization', `Bearer ${customerToken(customerId)}`)
.set('Authorization', `Bearer ${adminToken()}`)
// Плохо — токен захардкожен
.set('Authorization', 'Bearer eyJhbGciOiJSUzI1NiJ9...')

// Плохо — сборка токена в каждом тесте
const payload = { sub: userId, role: 'customer' };
const token = jwt.sign(payload, testSecret);
.set('Authorization', `Bearer ${token}`)

Хелперы:

  • successToken() — generic токен с ролью customer.
  • adminToken() — роль admin.
  • customerToken(customerId)sub = customerId, роль customer.
  • sellerToken(sellerId) — роль seller, sub = sellerId.

Единый source-of-truth: структура токена меняется в одном месте. Тесты на ABAC — явно передаём разные sub через хелпер, не угадываем строки.

Хелпер создаёт токен, совместимый с фейковым JWT-валидатором, настроенным в фабрике (overrideProvider guard'а). Подробнее — в node/testing-module.

Полный пример — негативный сценарий

describe('BR-002: confirm order', () => {
  it('fails when reservation failed — returns 409 OUT_OF_STOCK', async () => {
    // Arrange
    const orderId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
    const order = new OrderBuilder()
      .withId(orderId)
      .withStatus(OrderStatus.RESERVATION_FAILED)
      .build();
    await preparer.createOrder(order).prepare();

    // Act
    const response = await request(app.getHttpServer())
      .post(`/v1/orders/${orderId}/confirm`)
      .set('Authorization', `Bearer ${successToken()}`)
      .send();

    // Assert
    expect(response.status).toBe(409);
    expect(response.body.code).toBe('OUT_OF_STOCK');

    const orders = await preparer.findOrders({ id: orderId });
    expect(orders).toHaveLength(1);
    expect(orders[0].status).toBe('RESERVATION_FAILED');
  });
});

Assert здесь двойной:

  1. Ответ содержит правильный error-код.
  2. БД не изменилась — статус остался RESERVATION_FAILED. Это проверяет, что Handler не записал частичное изменение.

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

АнтипаттернПравилоЧто взамен
Test.createTestingModule(...) в каждом describe рукамиNODETEST-15createOrderApp() фабрика
it('test1'), it('should work')NODETEST-16it('<action> when <condition> — <expected>')
Нет BR-кода в describe/itNODETEST-16describe('BR-002: ...') или it('BR-002: ...')
axios.get(...) / fetch(...) вместо supertestNODETEST-17request(app.getHttpServer()).get(...)
Захардкоженный JWT-токен в тестеNODETEST-18successToken() / customerToken(id)
Сборка токена через jwt.sign(...) в тестеNODETEST-18хелперы из auth.helpers.ts
Assert только на status-код, без проверки БДNODETEST-15проверять side-effects: Outbox, статус записи
Один большой it на весь lifecycle заказаNODETEST-3один it = один сценарий

Куда дальше

  • node/basics.md — базовые правила: синхронность, детерминизм, AAA.
  • node/testing-module.md — фабрика TestingModule, DI-overrides, JWT-guard.
  • node/database-preparer.md — OrderDatabasePreparer: clear*(), create*(), prepare().
  • node/fluent-builders.md — OrderBuilder с with*() и build().
  • node/no-kafka-redis-async.md — почему Kafka не поднимаем, Outbox в Assert.
  • Use Case Pattern — спецификация — откуда берутся BR-коды.