open atlas
↑ К треку
NestJS с нуля до senior NEST · 06 · 01

Unit-тестирование провайдеров через testing module

Unit-тестируй провайдер Nest, собрав изолированный DI-контейнер через Test.createTestingModule, замокав каждую зависимость на её границе (repository, HTTP-клиент) через useValue или overrideProvider, и проверяя поведение через jest.fn().

NEST Middle ◷ 15 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

CI для UsersService месяцами был зелёным, а потом стал занимать девять минут и падать один прогон из пяти. Открываешь «unit»-тест — и видишь, что он поднимает весь AppModule, открывает реальное соединение с Postgres и стучится в staging-почту, чтобы «проверить» welcome-письмо. Флаки — это таймаут пула соединений; девять минут — это то, что у всех «unit»-сьют делает то же самое. И ничто из этого не тестирует то, что тебе важно, — логику UsersService.register: хэширует ли он пароль, отклоняет ли дублирующий email и вызывает ли save ровно один раз? Чтобы ответить на это, база данных не нужна. Нужен testing module с замоканным на границе repository.

Testing module — это одноразовый DI-контейнер

Когда видишь девятиминутный «unit»-сьют, спроси себя: зачем ему база данных? Почти всегда — незачем. Testing module возвращает это время обратно.

Test.createTestingModule({ providers: [...] }) собирает свежий, изолированный DI-контейнер Nest, в котором есть только то, что ты перечислил. Вызов .compile() резолвит граф и отдаёт TestingModule; module.get(Token) достаёт из него singleton-экземпляр — твой юнит под тестом, уже связанный. Поскольку массивом providers управляешь ты, ты решаешь, какие зависимости реальные, а какие — дублёры. Сервис под тестом перечисляй реальным классом, а каждую зависимость, которую он инжектит, подставляй как мок.

Самый чистый способ подставить мок — кастомный провайдер с useValue: тот же injection-токен, фейковая реализация. jest.fn() на каждый метод даёт шпиона, которого можно запрограммировать и по которому можно проверять.

import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UsersService } from './users.service';
import { User } from './user.entity';

describe('UsersService', () => {
  let service: UsersService;
  let repo: jest.Mocked<Repository<User>>;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService, // реальный — это юнит под тестом
        {
          provide: getRepositoryToken(User), // токен, который Nest инжектит для @InjectRepository(User)
          useValue: {
            findOne: jest.fn(),
            create: jest.fn((dto) => dto),
            save: jest.fn(),
          },
        },
      ],
    }).compile();

    service = module.get(UsersService);
    repo = module.get(getRepositoryToken(User));
  });

  it('отклоняет дублирующий email без сохранения', async () => {
    repo.findOne.mockResolvedValue({ id: 1, email: 'a@b.io' } as User);

    await expect(service.register({ email: 'a@b.io', password: 'pw' }))
      .rejects.toThrow('email already in use');

    expect(repo.save).not.toHaveBeenCalled(); // поведение, а не деталь реализации
  });

  it('сохраняет нового пользователя ровно один раз', async () => {
    repo.findOne.mockResolvedValue(null);
    repo.save.mockResolvedValue({ id: 7, email: 'new@b.io' } as User);

    const result = await service.register({ email: 'new@b.io', password: 'pw' });

    expect(repo.save).toHaveBeenCalledTimes(1);
    expect(repo.save).toHaveBeenCalledWith(
      expect.objectContaining({ email: 'new@b.io' }),
    );
    expect(result.id).toBe(7);
  });
});

Две детали оправдывают себя. Токен для TypeORM-repository — это не класс сущности, а getRepositoryToken(User), синтетический токен, в который резолвится @InjectRepository(User). Подставь значение под этот токен — и сервис получит твой мок. И проверки сделаны по поведению: «не сохранил при дубле», «сохранил один раз с этим email». Они никогда не лезут в приватные поля сервиса. Тесты, завязанные на поведение, переживают рефакторинг; тесты, завязанные на реализацию, ломаются в тот момент, когда ты переименовал переменную.

Мокай на границе — и только там

Тот же паттерн покрывает любого внешнего коллаборатора. JwtService, HTTP-клиент, оборачивающий платёжный API, mailer — каждый это шов между твоей логикой и внешним миром, и каждый получает useValue-дублёра по своему токену. Что ты не мокаешь — это сам юнит под тестом (тогда ты не тестируешь ничего) и чистые внутренние хелперы вроде value object или функции оценки сложности пароля — мок таких лишь замораживает реализацию и тестирует твой мок вместо твоего кода. Мокай I/O-границу; дай логике выполниться.

Для сервиса, который общается и с repository, и с внешним клиентом, это два дублёра:

const module = await Test.createTestingModule({
  providers: [
    OrdersService,
    { provide: getRepositoryToken(Order), useValue: { findOne: jest.fn(), save: jest.fn() } },
    { provide: PaymentClient, useValue: { charge: jest.fn().mockResolvedValue({ ok: true }) } },
  ],
}).compile();

Теперь OrdersService.checkout можно прогнать по каждой ветке — отказ платежа, конфликт в repository, счастливый путь — за миллисекунды, детерминированно, без сети и без БД.

overrideProvider, когда модуль реальный

useValue работает, когда провайдеры собираешь ты сам. Иногда удобнее импортировать реальный feature-модуль — чтобы его связка оставалась честной — и подменить только листовые зависимости. Это overrideProvider(TOKEN):

const module = await Test.createTestingModule({
  imports: [UsersModule], // реальный модуль, реальная связка
})
  .overrideProvider(getRepositoryToken(User))
  .useValue({ findOne: jest.fn(), save: jest.fn() })
  .compile();

После .useValue(...) можно вместо этого зацепить .useClass(FakeRepo) (класс-заглушку, которую Nest инстанцирует со своими зависимостями) или .useFactory({ factory: () => mock }) (когда дублёру нужна логика конструирования). А если не хочешь называть каждого дублёра, .useMocker((token) => ...) авто-заглушит всё, что не подставлено явно, — удобно для сервиса с длинным списком зависимостей, где тебе важны один-два коллаборатора, а остальные пусть будут инертными no-op-моками.

Два способа подставить зависимость, бок о бок

Способ подставить depКакТрогает БД / сеть?Когда использовать
Реальная реализацияПеречисли реальный провайдер; Nest свяжетДа — в этом и проблемаIntegration/e2e-тест, не unit-тест
useValue-мок{ provide: TOKEN, useValue: { m: jest.fn() } }НетМассив providers собираешь сам
overrideProvider.overrideProvider(TOKEN).useValue/useClass/useFactoryНет (для подменённого dep)Импортируешь реальный модуль и меняешь один лист
useMocker.useMocker((token) => mock)НетАвто-заглушка длинного списка; важны один-два
Почему это работает

Почему токен — это getRepositoryToken(User), а не Repository или User? @InjectRepository(User) инжектит не «какой-то Repository» — этот класс делят много repository, по одному на сущность, — поэтому Nest нужен токен на сущность, чтобы их различать. getRepositoryToken(User) возвращает ровно такой синтетический токен (строку вроде UserRepository). Модуль @nestjs/typeorm регистрирует под ним реальный repository в рантайме; в тесте ты регистрируешь под тем же токеном свой мок. Подставь useValue по ключу Repository — и его никто не заинжектит: сервис всё равно получит реальный repo (или ошибку missing-dependency). Всегда ключуй дублёра по токену, в который резолвится декоратор.

Выбери лучший вариант

Нужно unit-тестировать логику UsersService.register — захэшировать пароль, отклонить дубль, сохранить один раз — без базы данных, быстро и детерминированно. Как подставить зависимость UserRepository?

Викторина

В unit-тесте UsersService под каким токеном должен быть подставлен мок repository?

Викторина

Ты импортируешь реальный UsersModule в тест, но хочешь, чтобы repository был моком, а не ходил в Postgres. Какой подход подходит?

Вспомните перед уходом
  1. 01
    Пройди по unit-тесту UsersService против замоканного TypeORM-repository: что делает каждое из createTestingModule, массива providers, getRepositoryToken, compile и module.get?
  2. 02
    Какие есть способы подставить зависимость в unit-тесте Nest и каково правило — что мокать, а что нет?
Итог

Unit-тест провайдера Nest собирает одноразовый DI-контейнер через Test.createTestingModule({ providers: […] }) и .compile(), а затем module.get(Service) резолвит сервис под тестом, уже связанный с дублёрами, которыми управляешь ты. Сервис перечисляй реальным провайдером, а каждую зависимость, которую он инжектит, подставляй моком — самый чистый вариант это кастомный провайдер { provide: TOKEN, useValue: { method: jest.fn() } }. Для TypeORM-repository токен — это getRepositoryToken(User), синтетический токен на сущность, в который резолвится @InjectRepository(User), — не класс Repository и не сущность. Тот же паттерн покрывает JwtService или HTTP/платёжный клиент: каждый это I/O-граница, которая получает useValue-дублёра. Если удобнее импортировать реальный модуль, чтобы его связка осталась честной, .overrideProvider(TOKEN) меняет один лист через .useValue / .useClass / .useFactory, а .useMocker авто-заглушит всё, что не подставлено явно. Когда каждая зависимость замокана, нет ни БД, ни сети, поэтому тест быстрый и детерминированный — и проверяешь поведение (toHaveBeenCalledWith, not.toHaveBeenCalled, mockResolvedValue для задания состояния), а не деталь реализации, так что тест переживает рефакторинг. Что не мокают никогда — это сам юнит под тестом и его чистые внутренние хелперы; мокай границу, дай логике выполниться. Теперь, когда встретишь флакающий девятиминутный «unit»-сьют, ты знаешь, что проверить первым: есть ли там реальная БД — и если да, потянуться за createTestingModule с замоканными зависимостями на границе.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.