Unit-тестирование провайдеров через testing module
Unit-тестируй провайдер Nest, собрав изолированный DI-контейнер через Test.createTestingModule, замокав каждую зависимость на её границе (repository, HTTP-клиент) через useValue или overrideProvider, и проверяя поведение через jest.fn().
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. Какой подход подходит?
- 01Пройди по unit-тесту UsersService против замоканного TypeORM-repository: что делает каждое из createTestingModule, массива providers, getRepositoryToken, compile и module.get?
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.