NestJS
vitest-auto-spy/nestjs заменяет зависимости Nest-сервиса типизированными спаями. Это нужно, когда вы тестируете один провайдер и хотите, чтобы всё, что он получает через DI, было подменой под вашим контролем. Спай — функция-заглушка: она запоминает вызовы и возвращает то, что вы ей задали.
import { Test, type TestingModule } from '@nestjs/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { type Spy } from 'vitest-auto-spy';
import { injectSpy, provideAutoSpy } from 'vitest-auto-spy/nestjs';
import { AuthService } from './auth.service';
import { UserService } from './user.service';
describe('AuthService', () => {
let moduleRef: TestingModule;
let auth: AuthService;
let users: Spy<UserService>;
beforeEach(async () => {
moduleRef = await Test.createTestingModule({
providers: [AuthService, provideAutoSpy(UserService)],
}).compile();
auth = moduleRef.get(AuthService);
users = injectSpy(moduleRef, UserService);
});
it('signs in a known user', async () => {
users.findByEmail.calledWith('ada@example.com').resolveWith({ id: 1, name: 'Ada' });
await expect(auth.signIn('ada@example.com')).resolves.toMatchObject({ id: 1 });
expect(users.findByEmail).toHaveBeenCalledWith('ada@example.com');
});
it('rejects an unknown one', async () => {
users.findByEmail.resolveWith(null);
await expect(auth.signIn('nobody@example.com')).rejects.toThrow('Unknown user');
});
});AuthService указан как есть, поэтому Nest создаёт настоящий класс — его и тестируем. UserService указан через provideAutoSpy, поэтому сервис получает спай.
Под Vitest компилируйте через unplugin-swc с включёнными метаданными декораторов: стандартная трансформация Vite (esbuild) не пишет метаданные, которые нужны Nest. Подробнее — в разделе Метаданные пишет ваш компилятор.
Как задать ответ методу спая:
resolveWith(value)— для метода, который возвращаетPromise; вызов разрешится значениемvalue.mockReturnValue(value)— для метода, который возвращает обычное значение.calledWith(args)перед любым из них — ответ сработает только на вызов с этими аргументами.
Все хелперы для ответов — на странице Хелперы управления.
Здесь injectSpy принимает два аргумента
В Angular injectSpy(token) читает из глобального TestBed. В Nest глобального модуля нет, поэтому здесь injectSpy(moduleRef, token): сначала ссылка на модуль.
Частая ошибка: в providers указан сам класс (providers: [UserService]), а потом на нём вызывают injectSpy. Вы получаете настоящий сервис с типом спая, и первый же заданный ответ падает с TypeError: users.findByEmail.resolveWith is not a function. Указывайте его через provideAutoSpy(UserService).
Если тестовый модуль вообще не нужен, createNestUnit собирает сервис и все его спаи одним синхронным вызовом.
Что экспортирует точка входа
| Экспорт | Что делает |
|---|---|
provideAutoSpy(Class, config?) | возвращает { provide: Class, useValue: spy } для providers |
injectSpy(moduleRef, token) | достаёт провайдер из скомпилированного модуля с типом Spy<T> |
createNestUnit(Target, options?) | создаёт Target по его DI-метаданным, со спаем на каждую зависимость |
trackInjections | записывает, какие провайдеры юнит на самом деле запросил |
Второй аргумент provideAutoSpy принимает те же опции, что и createSpyFromClass, например provideAutoSpy(ApiService, { onlyMethodsToSpyOn: ['get', 'post'] }).
Абстрактные классы и токены-интерфейсы
Nest часто внедряет зависимость по абстрактному классу или по строковому либо символьному токену. provideAutoSpy нужен настоящий класс, чтобы прочитать методы. Передайте ему конкретный класс, а токен направьте на спай:
// абстрактный класс как токен, конкретный класс как форма
providers: [{ provide: PaymentGateway, useValue: provideAutoSpy(StripeGateway).useValue }];
// строковый или символьный токен
providers: [{ provide: 'PAYMENT_GATEWAY', useValue: provideAutoSpy(StripeGateway).useValue }];Достаёте его по тому же токену, что и Nest:
const gateway = injectSpy(moduleRef, PaymentGateway);Если класса нет совсем, только интерфейс, передайте в useValuecreateAutoMock<PaymentGateway>(). Он собирает такой же спай по типу.
Любой раннер
На Vitest точка входа раннера не нужна: достаточно одного vitest-auto-spy/nestjs.
На других раннерах импортируйте ещё точку входа своего раннера (vitest-auto-spy/node, vitest-auto-spy/bun или vitest-auto-spy/rstest), и хелперы Nest будут создавать моки этого раннера. vitest-auto-spy/nestjs не импортирует vitest, поэтому там работает. Её объявления типов тоже не упоминают vitest, так что проект на Bun или node:test проходит проверку типов с skipLibCheck: false и без установленного Vitest. Точку входа раннера можно импортировать до или после vitest-auto-spy/nestjs.
Частая ошибка: на Vitest набор тестов, который импортирует только vitest-auto-spy/nestjs, получает спаи без mockThrow из Vitest. Добавьте import type {} from 'vitest-auto-spy'; в любой файл; подробнее — Совместимость.
createNestUnit экспортируется ещё и из vitest-auto-spy/node и vitest-auto-spy/bun, поэтому тестам Nest на node --test или bun test хватает одного импорта:
import 'reflect-metadata';
import { createNestUnit } from 'vitest-auto-spy/node';Версии provideAutoSpy и injectSpy для Nest есть только в /nestjs. Импортируйте их оттуда на любом раннере.
Сборка юнита по его метаданным
createNestUnit(Target, options?) собирает тестируемый класс без Test.createTestingModule. Он читает метаданные, которые уже пишут декораторы Nest, создаёт класс через new и даёт каждой зависимости спай. Когда конструктор меняется, спека остаётся прежней: список провайдеров никто не пишет руками.
import { expect, it } from 'vitest';
import { createNestUnit } from 'vitest-auto-spy/nestjs';
import { CartService } from './cart.service';
import { PricingService } from './pricing.service';
import { TaxService } from './tax.service';
it('adds tax to the total', () => {
const { unit, spies } = createNestUnit(CartService);
spies.get(PricingService).total.mockReturnValue(100);
spies.get(TaxService).rate.mockReturnValue(0.5);
expect(unit.checkout(3)).toBe(150);
expect(spies.autoSpiedTokens()).toEqual([PricingService, TaxService]);
});
it('saves the order through the repository', async () => {
const { unit, spies } = createNestUnit(OrderService);
spies.get(OrderRepository).save.resolveWith({ id: 7 }); // save() возвращает Promise
await expect(unit.place({ total: 30 })).resolves.toEqual({ id: 7 });
expect(spies.get(OrderRepository).save).toHaveBeenCalledWith({ total: 30 });
});compile() не нужен: сам createNestUnit синхронный. spies.get(X) возвращает тот же спай, который получил юнит, поэтому ответы и проверки вызовов на нём такие же, как с injectSpy. Для createNestUnit import 'reflect-metadata' должен загрузиться раньше ваших классов (см. раздел о метаданных ниже).
| Опция | Тип | По умолчанию | Смысл |
|---|---|---|---|
expose | NestUnitClass[] | [] | классы, которые создаются по-настоящему, а не спаятся |
providers | NestUnitProvider[] | [] | ваши значения; важнее спаев и expose |
Метод spies | Возвращает |
|---|---|
get(token) | спай (или ваше значение), который юнит получил для token |
autoSpiedTokens() | токены, которые получили спай |
exposedTokens() | классы из expose, которые юнит действительно создал |
Как ведёт себя граф зависимостей:
- На каждый токен один экземпляр, как в синглтон-области Nest по умолчанию. Если два класса делят зависимость, у них один спай.
- У спая класса есть только методы настоящего класса. Метод с опечаткой — это
undefined, а не новый спай, так что тест падает на опечатке:spies.get(PricingService).totl // опечатка → undefined. - У строкового или символьного токена нет класса, поэтому он получает спай
createAutoMock(), собранный по типу. spies.get(token)бросает ошибку на токен, который юнит не запрашивал, и перечисляет токены, получившие спай. Иначе вы настроили бы спай, которым юнит не пользуется, и тест ничего бы не проверял.
Это аналог Angular-хелпера createWithAutoSpies для Nest.
Sociable — expose
expose создаёт зависимость по-настоящему, а её собственные зависимости по-прежнему получают спаи. Это короткая запись { provide: X, useClass: X }.
const { unit, spies } = createNestUnit(CheckoutFacade, { expose: [CartService] });
// CartService настоящий; его спаи — те, что ниже.
spies.get(PricingService).total.mockReturnValue(10);
spies.get(TaxService).rate.mockReturnValue(0.2);
expect(unit.run(1)).toBe(12);
expect(spies.exposedTokens()).toEqual([CartService]);Частая ошибка: spies.get(CartService) здесь бросает ошибку: юнит получил настоящий экземпляр, а не спай. Читайте спаи его зависимостей или уберите класс из expose. Если класса из expose нет в exposedTokens(), значит, никто в графе его не запросил.
Токены без класса — providers
В providers вы задаёте значения сами. Поддерживаются три формы: useValue, useClass и useFactory. Результат provideAutoSpy(X, config) — это провайдер useValue, он тоже подходит.
import { createNestUnit, provideAutoSpy } from 'vitest-auto-spy/nestjs';
const { unit, spies } = createNestUnit(CartService, {
providers: [
provideAutoSpy(TaxService, { onlyMethodsToSpyOn: ['rate'] }),
{ provide: 'CONFIG', useValue: { currency: 'EUR' } },
{ provide: PaymentGateway, useClass: StripeGateway },
{ provide: FLAGS, useFactory: () => ({ beta: true }) },
],
});
expect(spies.get('CONFIG')).toEqual({ currency: 'EUR' }); // заданное значение возвращается как естьuseClassсоздаётся как класс изexpose: настоящий класс, зависимости — спаи.useFactoryне принимает аргументов и вызывается один раз, когда токен понадобился впервые. Списокinjectне поддерживается.
Частая ошибка: токен конфига вроде @Inject('CONFIG') не задан в providers. Он получает спай по типу, и config.currency оказывается функцией-спаем, а не строкой. Значения конфига задавайте в providers.
Необязательные зависимости и зависимости-свойства
- Для обычной зависимости
@Optional()ничего не меняет: она всё равно получает спай. @Optional()важен только для параметра, чей токен вообще нельзя внедрить (см. ошибки ниже). Такой параметр получаетundefinedвместо ошибки.- Внедрение в свойство (
@Inject(Logger) logger!: Logger) работает по тем же правилам. Свойство заполняется после конструктора.
Метаданные пишет ваш компилятор
Nest берёт типы параметров конструктора из design:paramtypes. Компилятор пишет эти метаданные при включённом emitDecoratorMetadata: true. Они нужны обоим способам на этой странице: и Test.createTestingModule, и createNestUnit.
Одно правило для тестовой сборки:
- Vitest: компилируйте через
unplugin-swcс включёнными метаданными декораторов, как советует документация NestJS. Стандартная трансформация Vite (esbuild) метаданные не пишет. - tsc или SWC (
jsc.transform.decoratorMetadata: true), напримерnode --testна выходе tsc: ничего дополнительно не нужно, оба пишут метаданные. reflect-metadataдолжен загрузиться раньше ваших классов. ДляTest.createTestingModuleего загружает тестовый пакет Nest. ДляcreateNestUnitдобавьтеimport 'reflect-metadata'в начало спеки или setup-файла.reflect-metadata— ваша dev-зависимость, этот пакет её не ставит.
Если флаг включить нельзя, ставьте @Inject(X) на каждый параметр конструктора. Декоратор сам записывает токен, и createNestUnit его читает. @Inject() без токена не поможет: он опирается на те самые метаданные, которых нет.
Что он отвергает и что говорит сообщение
Каждая ошибка называет способ починки и заканчивается ссылкой на эту страницу.
- Параметр без токена, который можно внедрить. Интерфейсы, объединения и примитивы компилируются в
Object,String,Numberи т. п., и Nest их тоже внедрить не может. Сообщение называет класс и номер параметра. Почините через@Inject(TOKEN)плюс запись вprovidersили пометьте параметр@Optional(). Параметр, скомпилированный какundefined, обычно означает циклический импорт: используйте@Inject(forwardRef(() => X)). О свойстве сообщается так же, по имени. - Класс с параметрами конструктора и без метаданных. Сообщение называет класс и три нужные вещи:
@Injectable(), флаг компилятора иreflect-metadata, загруженный первым. Классу без параметров метаданные не нужны. - Цикл среди классов, созданных по-настоящему, в виде
A -> B -> A. Этот хелпер не разрешает циклы черезforwardRef. Уберите одну сторону изexposeили задайте её значением. spies.getпо токену, который юнит не запрашивал. Сообщение перечисляет токены со спаями.spies.getпо классу изexpose. Этот класс настоящий, а не спай.
Зависимости
@nestjs/common и @nestjs/testing остаются вашими dev-зависимостями: точка входа их не импортирует. injectSpy нужен только объект с методом get(token) (тип NestModuleRef), так что он работает и с самописным фейковым модулем.