КурсыNestJSТема 13

NestJS / 38 минут

Тесты и подмена зависимостей

Собирайте TestingModule и заменяйте реальные зависимости контролируемыми doubles.

В процессе Теория: впереди Практика: впереди Тест: впереди
13 / 14
После этой главы
  • Собирайте TestingModule и заменяйте реальные зависимости контролируемыми doubles.
  • Применить тему в задаче «Тесты и подмена зависимостей» и объяснить граничные случаи.

Около 8 минут на чтение и разбор примеров. Практика — отдельно, в своём темпе.

Проверяемый контракт

Тест полезен, когда подтверждает наблюдаемое поведение: правильный результат, отказ, отсутствие приватных полей или согласованность записи. Проверка имени класса либо точной последовательности внутренних вызовов часто слишком тесно связана с реализацией. Сначала сформулируйте, что обещает сценарий пользователю и какой сбой должен обнаружить тест.

Unit-тест сервиса не требует открытого HTTP-порта. Передайте контролируемый репозиторий и вызовите метод напрямую. Интеграционный тест проверяет взаимодействие компонентов, например маршрута, ValidationPipe и guard. End-to-end тест собирает приложение с реальной инфраструктурой в отдельном окружении. Не называйте тест с подменённой БД проверкой настоящей транзакции.

TypeScript
const repo = { find: jest.fn().mockResolvedValue(null) };
const service = new BooksService(repo);
expect(await service.title('missing')).toBeNull();

Пример предполагает конструктор BooksService с соответствующим контрактом. Такой тест проверяет отсутствие книги, но ничего не доказывает о DI-регистрации или HTTP 404. Именно поэтому нужны разные уровни, а не один огромный тест, пытающийся охватить каждую деталь одновременно.

TestingModule собирает контейнер

Пакет @nestjs/testing предоставляет Test.createTestingModule. compile строит модуль, разрешает зависимости и возвращает TestingModule. Его можно использовать без сервера для получения сервиса по токену. Если нужен HTTP, создайте приложение, примените настройки bootstrap и вызовите init.

TypeScript
const moduleRef = await Test.createTestingModule({
  providers: [{ provide: 'SOURCE', useValue: 'real' }],
}).compile();
try {
  expect(moduleRef.get('SOURCE')).toBe('real');
} finally {
  await moduleRef.close();
}

Не забывайте закрывать модуль: его провайдеры могут владеть соединениями и таймерами. Тест, который проходит только при принудительном завершении процесса, способен скрывать утечку ресурса. Ошибка до конца проверки тоже должна приводить к освобождению через finally или надёжный afterAll.

Подмена до compile

overrideProvider заменяет зависимость в тестовой сборке, не меняя production-класс. Подмена выполняется до compile, пока контейнер ещё не создал граф. useValue удобен для точных результатов, useClass — для отдельного fake-адаптера. Fake должен сохранять важный контракт, включая ошибки и асинхронность.

TypeScript
const moduleRef = await Test.createTestingModule({ imports: [BooksModule] })
  .overrideProvider(BOOKS)
  .useValue({ find: async () => ({ id: '1', title: 'JS' }) })
  .compile();

BOOKS должен быть тем же токеном, который использует модуль. Созданный рядом новый Symbol с тем же описанием не подменит провайдер. Не исправляйте тест экспортом приватных внутренних полей сервиса: лучше получить зависимый сервис через контейнер и проверить результат публичного метода.

Настоящий HTTP без постоянного порта

Supertest может отправлять запросы к серверу, полученному через app.getHttpServer. Вызывайте app.init перед запросами. Production-приложение может иметь global prefix, ValidationPipe, cookie parser и filters; тестовая сборка должна применять тот же общий configureApp. Иначе HTTP-тест проверит другое приложение с более слабыми границами.

TypeScript
const app = moduleRef.createNestApplication();
try {
  configureApp(app);
  await app.init();
  await request(app.getHttpServer()).get('/api/books/missing').expect(404);
} finally {
  await app.close();
}

configureApp здесь — функция проекта. Не требуется одновременно вызывать постоянный listen с фиксированным портом. Проверяйте JSON и заголовки, а не только статус. Успех 200 не доказывает, что passwordHash отсутствует, cookies безопасно настроены или форма ответа соответствует обещанию.

Ошибки, конкуренция и данные

Для каждого важного сценария полезны обычный вход, граница и отказ зависимости. В доступе к книге проверьте чужого владельца и отсутствие сессии. В DTO — лишние поля, null и неверный тип. В наградах — повторный и конкурентный запрос, чтобы клиент не мог получить XP дважды. Изоляция аккаунтов должна проверяться двумя отдельными пользователями.

Тесты БД используют отдельную test-базу с явной защитой имени окружения. Примените миграции и создавайте данные с уникальным префиксом; очищайте только принадлежащие тесту записи. Не используйте production credentials в CI. Подменённый runner позволяет проверить сравнение результатов API, но изоляцию пользовательского кода проверяют отдельные Docker-тесты.

Практика overrideProvider

Создайте TestingModule с TOKEN, изначально равным real. До compile замените TOKEN переданным text через overrideProvider и useValue. Получите значение из модуля и верните его из solve. Закройте модуль даже при ошибке. Пустая строка должна остаться пустой строкой, поскольку она допустима по контракту.

После решения подмените репозиторий книги и проверьте отсутствие записи и отказ хранилища. Затем добавьте один HTTP-тест с настоящим ValidationPipe. Объясните, какое поведение подтверждает каждый уровень и какие свойства остаются за пределами проверки. Честные границы теста полезнее утверждения, что один зелёный запуск гарантирует полную корректность приложения.

Загружаем редактор…

Музыка: Reverie — Scott Buckley · CC BY 4.0