Опирается на правила: NODETEST-23, NODETEST-24, NODETEST-25 из Node Test Strategy → раздел 7. Внешние HTTP — мок.

Важно знать

  • nock или msw перехватывают исходящие HTTP-запросы на уровне Node.js — без поднятия дополнительного контейнера.
  • base-url внешнего клиента задаётся через ConfigService-override в createTestApp; не хардкодим в продовом коде.
  • Стабы пишем в самом тесте, не в общих файлах — видно, на что опирается конкретный сценарий.
  • nock.cleanAll() в afterEach — незаконченные интерсепторы не перетекают в следующий тест.
  • WireMock-контейнер предпочтительнее, когда нужно проверить HTTP-сериализацию, заголовки, retry или timeout клиента.
  • jest.mock() для HTTP-клиента в integration-тесте запрещён — проверяем реальный путь, мок только внешней системы.
  • Один перехватчик на одну системуnock('https://catalog.sber.ru') и nock('https://payment.sber.ru') отдельно.

Внешние HTTP — единственный класс зависимостей, который не заменяется на Outbox-проверку (как Kafka) и не перекрывается DI-токеном (как время или UUID). Платёжный шлюз, каталог, служба логистики — они есть в продовом потоке, и тест должен моделировать их поведение реалистично. Инструменты Node-экосистемы для этого — nock и msw.

Как работают интерсепторы

nock патчит модуль http/https Node.js на уровне процесса: когда HttpService (axios) или fetch-based клиент отправляет запрос на перехваченный URL — nock отдаёт заранее описанный ответ без сетевого вызова. msw делает то же через Service Worker (браузер) или @mswjs/interceptors (Node.js) — API немного другое, поведение эквивалентное.

В отличие от Java-подхода с WireMockExtension в BaseIntegrationTest, в NestJS-сервисе интерсептор не требует отдельного контейнера и @DynamicPropertySource. Достаточно убедиться, что base-url клиента приходит из ConfigService и в тесте он указывает на реальный хост, который будет перехвачен.

Настройка base-url через ConfigService

NODETEST-23: base-url внешнего клиента — в конфиге, не хардкод.

// src/catalog/catalog-client.module.ts
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { CatalogHttpClient } from './catalog-http.client';

@Module({
  imports: [
    HttpModule.registerAsync({
      useFactory: (config: ConfigService) => ({
        baseURL: config.get<string>('CATALOG_BASE_URL'),
        timeout: config.get<number>('CATALOG_TIMEOUT_MS') ?? 3000,
      }),
      inject: [ConfigService],
    }),
  ],
  providers: [CatalogHttpClient],
  exports: [CatalogHttpClient],
})
export class CatalogClientModule {}
// src/catalog/catalog-http.client.ts
import { Injectable } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';

export interface CatalogProduct {
  id: string;
  name: string;
  price: string;
  currency: string;
  available: boolean;
}

@Injectable()
export class CatalogHttpClient {
  constructor(private readonly http: HttpService) {}

  async getProduct(productId: string): Promise<CatalogProduct> {
    const { data } = await firstValueFrom(
      this.http.get<CatalogProduct>(`/api/v1/products/${productId}`),
    );
    return data;
  }
}

В createTestApp в ConfigService-override прописываем тот URL, который будет перехватывать nock:

// test/app-factory.ts (фрагмент)
.overrideProvider(ConfigService)
.useValue({
  get: (key: string) => {
    if (key === 'DATABASE_URL')    return process.env.DATABASE_URL;
    if (key === 'CATALOG_BASE_URL') return 'https://catalog.sber.ru';
    if (key === 'PAYMENT_BASE_URL') return 'https://payment.sber.ru';
    return process.env[key];
  },
})

Теперь в тесте nock('https://catalog.sber.ru') перехватит запрос — продовый CatalogHttpClient не изменился ни на строчку.

Стабы в самом тесте

NODETEST-24: стаб описывается там, где нужен — в теле it(...), не в общих json-файлах.

// orders/create-order.integration-spec.ts
import * as nock from 'nock';
import * as request from 'supertest';
import { createTestApp, TestApp } from '../../test/app-factory';
import { OrderDatabasePreparer } from '../../test/order-database-preparer';
import { successToken, customerToken } from '../../test/auth/tokens';

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();
  });

  afterEach(() => {
    nock.cleanAll();
  });

  it('BR-007: create order when valid product — returns 201 with total amount', async () => {
    const productId = 'prod-1111-2222-3333-4444';

    // Arrange
    nock('https://catalog.sber.ru')
      .get(`/api/v1/products/${productId}`)
      .reply(200, {
        id: productId,
        name: 'Ноутбук ThinkPad',
        price: '75000.00',
        currency: 'RUB',
        available: true,
      });

    await preparer
      .createCustomer({ id: 'cust-ivanov', email: 'ivanov@sber.ru' })
      .prepare();

    // Act
    const res = await request(testApp.app.getHttpServer())
      .post('/v1/orders')
      .set('Authorization', `Bearer ${customerToken('cust-ivanov')}`)
      .send({ customerId: 'cust-ivanov', productId, quantity: 2 });

    // Assert
    expect(res.status).toBe(201);
    expect(res.body.totalAmount).toBe('150000.00');
    expect(res.body.currency).toBe('RUB');
  });

  it('BR-008: create order when product unavailable — returns 409', async () => {
    const productId = 'prod-out-of-stock';

    // Arrange
    nock('https://catalog.sber.ru')
      .get(`/api/v1/products/${productId}`)
      .reply(200, {
        id: productId,
        name: 'Товар под заказ',
        price: '5000.00',
        currency: 'RUB',
        available: false,
      });

    await preparer
      .createCustomer({ id: 'cust-petrov', email: 'petrov@sber.ru' })
      .prepare();

    // Act
    const res = await request(testApp.app.getHttpServer())
      .post('/v1/orders')
      .set('Authorization', `Bearer ${customerToken('cust-petrov')}`)
      .send({ customerId: 'cust-petrov', productId, quantity: 1 });

    // Assert
    expect(res.status).toBe(409);
    expect(res.body.code).toBe('PRODUCT_UNAVAILABLE');
  });

  it('BR-009: create order when catalog returns 503 — returns 502', async () => {
    const productId = 'prod-any';

    // Arrange
    nock('https://catalog.sber.ru')
      .get(`/api/v1/products/${productId}`)
      .reply(503, { message: 'Service Unavailable' });

    await preparer
      .createCustomer({ id: 'cust-smirnov', email: 'smirnov@sber.ru' })
      .prepare();

    // Act
    const res = await request(testApp.app.getHttpServer())
      .post('/v1/orders')
      .set('Authorization', `Bearer ${customerToken('cust-smirnov')}`)
      .send({ customerId: 'cust-smirnov', productId, quantity: 1 });

    // Assert
    expect(res.status).toBe(502);
    expect(res.body.code).toBe('CATALOG_UNAVAILABLE');
  });
});

Каждый it описывает стаб, который нужен именно ему. Сценарий «товар недоступен» и сценарий «каталог упал» используют разные ответы — и это видно без перехода в другой файл.

nock.cleanAll() в afterEach гарантирует, что зарегистрированный, но не вызванный интерсептор не повлияет на следующий тест.

Несколько внешних систем

Для платёжного шлюза и каталога — отдельные nock-блоки в одном тесте:

it('BR-015: confirm order when payment succeeds — sets status CONFIRMED', async () => {
  const orderId   = '11111111-1111-1111-1111-111111111111';
  const productId = 'prod-laptop';

  // Arrange
  nock('https://catalog.sber.ru')
    .get(`/api/v1/products/${productId}`)
    .reply(200, { id: productId, price: '50000.00', currency: 'RUB', available: true });

  nock('https://payment.sber.ru')
    .post('/api/v1/payments')
    .reply(201, { paymentId: 'pay-0001', status: 'SUCCESS' });

  await preparer
    .createCustomer({ id: 'cust-kozlov', email: 'kozlov@sber.ru' })
    .createOrder({ id: orderId, customerId: 'cust-kozlov', status: 'DRAFT' })
    .prepare();

  // Act
  const res = await request(testApp.app.getHttpServer())
    .post(`/v1/orders/${orderId}/confirm`)
    .set('Authorization', `Bearer ${customerToken('cust-kozlov')}`);

  // Assert
  expect(res.status).toBe(200);
  expect(res.body.status).toBe('CONFIRMED');

  const outbox = await preparer.queryOutbox(orderId);
  expect(outbox).toHaveLength(1);
  expect(outbox[0].event_type).toBe('OrderConfirmed');
});

Проверка исходящего запроса

nock позволяет задать условия не только на URL, но и на заголовки, тело:

nock('https://payment.sber.ru')
  .post('/api/v1/payments', (body) => body.amount === '50000.00' && body.currency === 'RUB')
  .matchHeader('Idempotency-Key', /.+/)
  .matchHeader('Authorization', /Bearer .+/)
  .reply(201, { paymentId: 'pay-0001', status: 'SUCCESS' });

Если сервис не выставит Idempotency-Keynock не сопоставит запрос, тест упадёт с ошибкой «Nock: No match for request». Это аналог verify в WireMock: проверяем не только что внешний сервис был вызван, но и как именно.

Когда нужен WireMock-контейнер

NODETEST-25: nock перехватывает на уровне Node.js, внутри процесса. Он не проверяет:

  • Настоящую HTTP-сериализацию (Content-Type переговоры, chunked transfer).
  • Поведение клиента при ECONNRESET, ETIMEDOUT.
  • Retry-политику (exponential backoff, количество попыток).
  • Корректность TLS-handshake.

Когда эти аспекты важны — поднимаем WireMock как Testcontainer:

// test/global-setup.ts (дополнение)
import { GenericContainer, StartedTestContainer } from 'testcontainers';

let wiremockContainer: StartedTestContainer;

export default async function globalSetup() {
  // ... PostgreSqlContainer ...

  wiremockContainer = await new GenericContainer('wiremock/wiremock:3.5.4')
    .withExposedPorts(8080)
    .start();

  const wiremockPort = wiremockContainer.getMappedPort(8080);
  process.env.CATALOG_BASE_URL = `http://localhost:${wiremockPort}`;
  process.env.PAYMENT_BASE_URL = `http://localhost:${wiremockPort}`;
}
// test/wiremock.ts — хелпер для регистрации стабов
export async function stubCatalogProduct(
  wiremockBaseUrl: string,
  productId: string,
  response: object,
): Promise<void> {
  await fetch(`${wiremockBaseUrl}/__admin/mappings`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      request: { method: 'GET', url: `/api/v1/products/${productId}` },
      response: { status: 200, jsonBody: response },
    }),
  });
}

export async function resetWiremockStubs(wiremockBaseUrl: string): Promise<void> {
  await fetch(`${wiremockBaseUrl}/__admin/reset`, { method: 'POST' });
}

WireMock-контейнер предпочтителен в сервисах с явными retry, circuit breaker или таймаутами — там, где поведение клиента при сетевых ошибках входит в критерии приёмки.

msw как альтернатива

msw (@mswjs/interceptors) — функционально эквивалентен nock, API другое:

import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer();

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

it('BR-007: create order when valid product — returns 201', async () => {
  const productId = 'prod-1111-2222-3333-4444';

  server.use(
    http.get(`https://catalog.sber.ru/api/v1/products/${productId}`, () =>
      HttpResponse.json({
        id: productId,
        name: 'Ноутбук ThinkPad',
        price: '75000.00',
        currency: 'RUB',
        available: true,
      }),
    ),
  );

  // Act + Assert — идентично nock-примеру выше
});

onUnhandledRequest: 'error' — каждый незарегистрированный исходящий запрос роняет тест. Полезно: неожиданные вызовы внешних сервисов видны сразу, а не проходят незамеченными.

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

АнтипаттернПравилоЧто взамен
jest.mock() для HTTP-клиента в integration-тестеNODETEST-X5nock / msw — мок на транспортном уровне
Стабы в общих файлах __mocks__/catalog.jsonNODETEST-24inline в it(...)
Отсутствие nock.cleanAll() / server.resetHandlers() после тестаNODETEST-24afterEach(() => nock.cleanAll())
Хардкод CATALOG_BASE_URL=https://catalog.sber.ru в продовом кодеNODETEST-23ConfigService.get('CATALOG_BASE_URL')
Реальный HTTP-запрос к внешнему сервису в тестеNODETEST-23nock / msw / WireMock-контейнер
Один стаб на все сценарии через beforeAllNODETEST-24стаб в каждом it под конкретный сценарий
nock там, где нужно проверить retry / таймаутNODETEST-25WireMock-контейнер

Куда дальше

  • node/basics.md — базовые правила: детерминизм, AAA-структура, полный стек теста.
  • node/testing-module.md — createTestApp, ConfigService-override, FakeAuthGuard.
  • node/one-test.md — структура одного теста: имена, supertest, successToken().
  • Resilience — retry и circuit breaker — тестирование политик через WireMock-контейнер.