NestJS тестируется хорошо по той же причине, по которой хорошо структурируется, — из-за DI: любой провайдер подменяется на тестовый, не трогая код, который его использует. Это позволяет проверять и отдельный Handler с моками, и весь путь от HTTP до базы. Тестовый раннер — Jest.

Тестовый модуль

В тесте собирают усечённый модуль через Test.createTestingModule — с теми провайдерами, что нужны, и моками для остального.

import { Test } from '@nestjs/testing';

describe('CreateProductHandler', () => {
  let handler: CreateProductHandler;
  const repo = { save: jest.fn() };

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      providers: [
        CreateProductHandler,
        { provide: ProductRepository, useValue: repo },
      ],
    }).compile();

    handler = moduleRef.get(CreateProductHandler);
  });

  it('saves a product', async () => {
    repo.save.mockResolvedValue({ id: 1 });
    const result = await handler.handle({ name: 'Кофемолка', price: 4990 });
    expect(result.id).toBe(1);
  });
});

Здесь Handler тестируется в изоляции: репозиторий — мок, базы нет, тест быстрый. Это нижний, самый широкий уровень пирамиды.

Переопределение провайдеров

Когда поднимают реальный модуль, но хотят подменить часть, используют overrideProvider.

const moduleRef = await Test.createTestingModule({
  imports: [ProductModule],
})
  .overrideProvider(ProductRepository)
  .useValue(fakeRepository)
  .compile();

Так же подменяют guards (overrideGuard) — чтобы тестировать контроллер, не возясь с настоящим токеном. Подменяемость встроена, потому что всё идёт через DI.

E2E через supertest

Сквозной тест поднимает приложение и шлёт ему реальные HTTP-запросы через supertest.

import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';

describe('Products (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
    app = moduleRef.createNestApplication();
    await app.init();
  });

  it('POST /products', () => {
    return request(app.getHttpServer())
      .post('/products')
      .send({ name: 'Кофемолка', price: 4990 })
      .expect(201);
  });

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

E2E проходит весь конвейер — pipes, guards, контроллер, Handler — и потому самый ценный для проверки контракта, но и самый медленный; их держат немного.

Тестовая база

E2E с данными бьют в реальную базу того же типа (PostgreSQL — в PostgreSQL, не в SQLite), но изолированно: отдельная тестовая база, которую миграции поднимают перед прогоном, или обёртка каждого теста в транзакцию с откатом. Поднимать настоящую базу в контейнере на время тестов — обычная практика; так проверяется и схема, и запросы.

Где это в UCP

Подменяемость через DI задаёт пирамиду: быстрые тесты Handler-ов и домена с моками портов — снизу, тесты контроллеров с подменой репозитория — посередине, немного e2e с тестовой базой — сверху. Логику не гоняют через HTTP без нужды, HTTP-слой не дублируют в каждом тесте. Это та же стратегия, что в Spring-биндинге со слайс-тестами и TestContainers, только Jest и supertest. Сервис, который так тестируется, продукт-инженер меняет без страха — обратная связь приходит за секунды.