Опирается на правила:
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 |
| Integration | HTTP → Handler → Postgres | 20–30 |
| E2E | Кросс-сервисные сценарии с Kafka | 3–5 |
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
jest.mock() Handler/Aggregate в интеграционном тесте | NODETEST-X5 | in-memory фейк только в controller unit-тесте |
| Живой Keycloak в тесте | NODETEST-X6 | фейковый guard + successToken() хелпер |
KafkaContainer в основном integration suite | NODETEST-X4 | отдельный jest.e2e.config.ts |
| Граничные условия агрегата только в интеграционных | NODETEST-26 | unit-тесты агрегата, быстро и многочисленно |
| Проверка сериализации через тест с реальным Postgres | NODETEST-27 | TestingModule + 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-идиомы.