Опирается на правила:
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-Key — nock не сопоставит запрос, тест упадёт с ошибкой «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-X5 | nock / msw — мок на транспортном уровне |
Стабы в общих файлах __mocks__/catalog.json | NODETEST-24 | inline в it(...) |
Отсутствие nock.cleanAll() / server.resetHandlers() после теста | NODETEST-24 | afterEach(() => nock.cleanAll()) |
Хардкод CATALOG_BASE_URL=https://catalog.sber.ru в продовом коде | NODETEST-23 | ConfigService.get('CATALOG_BASE_URL') |
| Реальный HTTP-запрос к внешнему сервису в тесте | NODETEST-23 | nock / msw / WireMock-контейнер |
Один стаб на все сценарии через beforeAll | NODETEST-24 | стаб в каждом it под конкретный сценарий |
nock там, где нужно проверить retry / таймаут | NODETEST-25 | WireMock-контейнер |
Куда дальше
- node/basics.md — базовые правила: детерминизм, AAA-структура, полный стек теста.
- node/testing-module.md —
createTestApp,ConfigService-override,FakeAuthGuard. - node/one-test.md — структура одного теста: имена, supertest,
successToken(). - Resilience — retry и circuit breaker — тестирование политик через WireMock-контейнер.