Как это устроено
Эта страница объясняет, что createSpyFromClass делает внутри. Библиотека стоит на двух идеях:
- Во время выполнения она спрашивает у класса имена методов и делает спай на каждый.
- При компиляции TypeScript читает тип возврата каждого метода и предлагает подходящие хелперы.
Чтобы писать тесты, эта страница не нужна. Читайте её, если хотите понять, почему конструктор не выполняется или почему у метода есть resolveWith, а nextWith нет.
Зачем это нужно
Вы тестируете компонент, который вызывает UserService. Настоящий сервис полезет в сеть, поэтому в тесте его не использовать. Значит, подмену пишут руками:
const userService = {
getUser: vi.fn(),
saveUser: vi.fn(),
deleteUser: vi.fn(),
refresh: vi.fn(),
// …по строке на метод, и так до бесконечности
};У неё три проблемы, и ни одна не проявится падением теста:
- добавили метод в сервис — надо не забыть добавить его и сюда;
- переименовали метод — тест по-прежнему зелёный, но вызов больше не проверяет;
- типов нет:
getUser: vi.fn()ничего не знает о настоящей сигнатуре.
Ту же подмену библиотека собирает за вас:
const userService = createSpyFromClass(UserService);Идея первая — спросить у класса его методы
Одной фразой: у класса можно спросить, какие у него методы, и этого списка хватает, чтобы собрать объект, где каждое имя — спай.
Методы класса живут не на экземплярах, а на прототипе — общем объекте всех экземпляров:
class UserService {
getUser(id: number) {
/* … */
}
saveUser(user: User) {
/* … */
}
}
Object.getOwnPropertyNames(UserService.prototype);
// → ['constructor', 'getUser', 'saveUser']Пропустить constructor, на каждое остальное имя поставить мок-функцию — и вот ядро библиотеки:
function createSpyFromClass(SomeClass) {
const spy = {};
for (const name of Object.getOwnPropertyNames(SomeClass.prototype)) {
if (name === 'constructor') continue;
spy[name] = vi.fn();
}
return spy;
}const service = createSpyFromClass(UserService);
service.getUser; // vi.fn()
service.saveUser; // vi.fn()В этом вся идея. Генерации кода и плагина к компилятору нет: библиотека спрашивает имена методов и собирает из них объект.
Что добавляет настоящая реализация
Идея маленькая, работа — в крайних случаях. В каждом случае ниже простой цикл выше вернёт спай, который выглядит правильно, а ведёт себя неправильно.
Наследование. getOwnPropertyNames видит только один уровень. Для class Admin extends UserService не появится ни один метод UserService. Поэтому библиотека поднимается по цепочке родительских прототипов и останавливается перед Object.prototype — единственным, у которого родителя нет:
let current = SomeClass.prototype;
while (Object.getPrototypeOf(current)) {
// ← у Object.prototype родителя нет; на этом и останавливаемся
collectNamesFrom(current);
current = Object.getPrototypeOf(current);
}Эта остановка не пускает в ваш спай toString, hasOwnProperty и остальное из Object.prototype.
Геттеры. Если в классе объявлен get isReady(), чтение свойства запустит геттер. На классе, у которого не создавали экземпляр, это обычно кончается ошибкой. Поэтому библиотека читает дескрипторы свойств — они описывают свойство, не запуская его:
const descriptors = Object.getOwnPropertyDescriptors(current);
// дескриптор с `.get` — это аксессор, у него свой путь
Object.keys(descriptors).filter((name) => !descriptors[name]?.get);У аксессоров своя обработка — см. спаи на аксессорах.
Сам мок. Каждое имя получает спай из createFunctionSpy(name), а не голый vi.fn(). Он добавляет calledWith, resolveWith, nextWith и остальные управляющие хелперы. См. Какую мок-функцию используют спаи.
Цена. Список методов класса за прогон не меняется, а спека обычно подменяет один и тот же класс в каждом beforeEach. Поэтому библиотека кэширует список на класс. Кэш не держит класс в памяти, когда тесты перестали им пользоваться.
Следствие: ваш класс не выполняется никогда
Библиотека только читает класс. Она не создаёт экземпляр и не вызывает конструктор. Поэтому сервис, которому нужны пять зависимостей и живое соединение, подменяется без единого мока на них.
Обратная сторона: по прототипу не видно того, что конструктор кладёт на экземпляр. Это поля-стрелки (handle = () => {}), свойства signal() в Angular и методы signalStore() из ngrx. Для них перечислите имена в instanceMethodsToSpyOn:
const service = createSpyFromClass(UserService, { instanceMethodsToSpyOn: ['handle'] });Имя, которого нет в списке, так и останется пустым. Вызов бросит TypeError на строке вашей спеки. Заглушку для такого имени библиотека не придумывает. Заглушка была бы истинным значением, и if (service.optionalThing) в проверяемом коде молча ушёл бы не в ту ветку. См. почему поле экземпляра не находится.
Если класса нет совсем, а есть только интерфейс, берите createAutoMock<T>(). Он возвращает Proxy, который создаёт спай при первом обращении к ключу.
Идея вторая — типы читают тип возврата
Одной фразой: TypeScript смотрит, что метод возвращает, и предлагает подходящие к этому хелперы.
userService.getUser.resolveWith(user); // возвращает Promise → resolveWith
userService.items$.nextWith([1, 2]); // возвращает Observable → nextWith
userService.getName.calledWith(1).mockReturnValue('Ann'); // обычный → mockReturnValueЗдесь ничего не выполняется, это только типы. Инструмент — условный тип:
type ChooseHelpers<Method> = Method extends (...args: any[]) => Promise<infer P> // возвращает Promise<P>?
? { resolveWith(value: P): void; rejectWith(err: unknown): void }
: Method extends (...args: any[]) => Observable<infer O> // Observable<O>?
? { nextWith(value: O): void; complete(): void }
: { mockReturnValue(value: ReturnType<Method>): void }; // обычный методЧитайте его как if / else if / else: если метод возвращает Promise — эти хелперы, если Observable — эти, иначе — те. infer P запоминает тип внутри Promise, поэтому resolveWith принимает User, а не any.
Вторая половина раскладывает это по всем ключам класса:
type Spy<T> = {
[K in keyof T]: T[K] extends Func
? T[K] & ChooseHelpers<T[K]> // метод: он сам плюс его хелперы
: T[K]; // не метод: не трогаем
};T[K] & ChooseHelpers<T[K]> объединяет оба типа. Спай вызывается ровно как исходный метод и при этом имеет управляющие хелперы.
const service: Spy<UserService> = createSpyFromClass(UserService);
service.getUser.resolveWith(user); // ✅ getUser возвращает Promise<User>
service.getUser.nextWith(user); // ❌ такого хелпера нет — автодополнение его не предложитГде эти двое встречаются
Одна строка, в самом конце createSpyFromClass:
return autoSpy as Spy<T>;Во время выполнения библиотека собрала обычный объект из моков. TypeScript не проверял, как собран этот объект: приведение типа говорит ему, что у объекта форма Spy<T>.
Это единственное такое приведение в ядре, и убрать его нельзя. Объект строится из имён, известных только во время выполнения, а Spy<T> компилятор вычисляет из типа класса. Проверить друг друга эти стороны не могут. Работает это потому, что обе смотрят на один и тот же класс, и для методов на прототипе дают одинаковые ключи. Поля экземпляра — исключение, о нём сказано выше.
Какую мок-функцию используют спаи
По умолчанию спай каждого метода — собственная мок-функция библиотеки. Она легче vi.fn() и ведёт себя так же во всём, что может проверить спека: mock.calls, mockReturnValue, toHaveBeenCalledWith и остальное. Так по умолчанию с версии 4.1. Записанный вызов занимает около 100 байт, вызов vi.fn() — около 200 (замеры).
Чтобы спаи строились на мок-функции вашего раннера (vi.fn() в Vitest, аналоги в Bun, node:test и Rstest), вызовите setSpyEngine('runner') из vitest-auto-spy/setup. Спаи, созданные раньше, остаются на той функции, с которой их создали.
Библиотека никогда не импортирует раннер тестов напрямую. Каждая точка входа, например vitest-auto-spy или vitest-auto-spy/bun, при импорте подключает мок-функцию своего раннера. Поэтому один и тот же код работает на Vitest, Bun, node:test и Rstest.
Что ещё есть в пакете
Всё остальное построено на этих двух идеях:
| Часть | Что делает |
|---|---|
createFunctionSpy | отдельный спай с хелперами, которые обещают типы: calledWith, resolveWith, nextWith, mustBeCalledWith |
calledWith | возвращает разное в зависимости от аргументов; принимает и матчеры вроде expect.any(…) |
| точки входа раннеров | подключают мок-функцию раннера, поэтому библиотека работает на Vitest, Bun, node:test и Rstest |
vitest-auto-spy/rxjs | хелперы для Observable в отдельной точке входа; без неё в тесты не попадает код rxjs |
resetAutoSpy | сбрасывает все спаи объекта одним вызовом, включая ответы calledWith, до которых mockClear не достаёт |
/angular, /nestjs, /react, /vue, /svelte | небольшие слои под каждый фреймворк, например provideAutoSpy для TestBed в Angular |
Одно замечание про совместимость
API — прямая замена jest-auto-spies: переезд сводится к смене импорта. Это касается и methodsToSpyOn, который здесь добавляет имена в спай так же, как и там. Чтобы подменить только список методов, есть отдельная опция onlyMethodsToSpyOn. Так знакомое имя никогда не значит противоположное тому, чего ждёт перенесённая спека. См. Переезд. Требования к версиям — на странице Совместимость.
Дальше
createSpyFromClass— все опции- Управляющие хелперы — что делает каждый хелпер
- Мост между
Spy<T>иT— почемуSpy<T>не присваивается кT - Автомок по типу — когда класса нет
- Как устроены хелперы для Angular — почему точки входа для Angular сделаны именно так
- Подробнее — ниже: почему отдельные хелперы ведут себя так, как ведут
Подробнее
Каждый раздел отвечает на одно «почему» про какой-то хелпер. Чтобы писать тесты, они не нужны.
Почему у возвращаемого значения заглушки проверяется тип
Задать возвращаемое значение — самое частое, что спека делает со спаем. Поэтому mockReturnValue принимает только тип возврата самого метода, как всегда было в цепочке calledWith(…). Спай, который принимает там что угодно, пропустит то самое расхождение, ради которого он нужен.
Когда заглушка перестаёт подходить, её обычно обходят через @ts-expect-error на упавшей строке. Это дороже самой заглушки. Строка больше не проверяется вовсе. Поэтому следующее изменение типа ответа пройдёт незамеченным ровно там, где этот ответ описан. В одном переезде так набралось шестьдесят строк в двадцати пяти файлах, пока кто-то не нашёл опцию, которая чинит заглушку. Решение — в разделе Заглушка перестаёт совпадать с настоящим ответом.
Как распознаётся член-Observable
Член получает nextWith, когда в его типе есть subscribe и forEach(next), который возвращает промис. Тип элемента проверка берёт из forEach, а не из subscribe. Если TypeScript выводит тип из перегруженного метода, он берёт последнюю сигнатуру. В rxjs 7 последняя перегрузка subscribe — устаревшая, с позиционными аргументами. Через неё тип элемента получается unknown.
Проверка структурная, поэтому подходит и Observable из второй копии rxjs в node_modules. У Subject есть приватное поле. Проверка по самому классу отвергла бы такую копию, и nextWith пропал бы без всякого объяснения.
Импорт, который добавляет хелперы во время выполнения, vitest-auto-spy/rxjs, их же и типизирует. Поэтому поведение и типы не могут разойтись.
Почему хелперы для Observable останавливают синхронный источник
expectEmission и соседние хелперы подписываются своим подписчиком, а не обычным observer. rxjs оборачивает обычный observer и отдаёт подписку только после возврата из subscribe. Для of, from, range и всего, где есть repeat(), это случается, когда вся последовательность уже прошла. Хелпер тогда соберёт все значения, хотя просил одно. Выполнятся все побочные эффекты после принятого значения. Бесконечный синхронный источник закрутится внутри subscribe, куда не дотянется никакой таймаут. Со своим подписчиком хелпер останавливает источник, как только получил то, что просил.
Почему таймаут не замечает фейковые таймеры и fakeAsync
У каждого ожидания есть сторожевой таймер. Он роняет ожидание, если значение так и не пришло. Этот таймер использует setTimeout, который пакет запомнил при импорте. vi.useFakeTimers() подменяет глобальную функцию позже, поэтому заморозить таймер не может. Таймер, который может заморозить проверяемый код, ничего бы не защищал.
С zone.js иначе. Он подменяет setTimeout при загрузке, задолго до этого пакета. Его версия выбирает планировщик по текущей зоне в момент вызова. Внутри fakeAsync сторожевой таймер попал бы в виртуальную очередь. Тогда tick(1_500) навстречу debounceTime(2_000) уронил бы то самое ожидание, которое двигал. Поэтому таймер использует исходный setTimeout, который zone.js держит в стороне. Внутри fakeAsync он идёт по реальному времени, и tick() его не запустит.
Почему ошибка указывает на строку вашей спеки
Хелперы для Observable собирают ошибку позже, в колбэке подписки или таймера. К этому моменту строки вашей спеки на стеке уже нет, и отчёт указал бы внутрь пакета. Поэтому каждый хелпер запоминает стек в момент вызова и прикладывает его к ошибке. Фрагмент кода в отчёте — это строка await expectEmission(…) в вашей спеке.
vi.defineHelper из Vitest здесь не подходит. Он рассчитан на хелпер, который бросает, пока вызывающий код ещё на стеке. Здесь его маркерный кадр оказался бы последним, и тогда Vitest выбрасывает весь стек вместе с фрагментом кода.
Почему автомок не отвечает на schedule
Подмена на Proxy отвечает спаем на любой ключ. Поэтому она похожа на всё, что проверяет наличие метода. Случай, на котором это вскрылось: of(...) считает последний аргумент планировщиком, если typeof x.schedule === 'function'. Так of(double) принял всю подмену за планировщик. Он ничего не выпустил и отдал работу спаю, который ничего не делает. Компонент остался с null, а упавшая проверка касалась постороннего emit(); про of в ней не было ни слова.
Обычно это обходили через from([double]). Больше не нужно: на такие служебные ключи подмена отвечает undefined. Список — в разделе Автомок по типу.
Как откатываются подмены свойств и что они держат в памяти
mockValueProp и соседние хелперы записывают каждую подмену в журнал: объект и дескриптор свойства, который заменили. Журнал держит сильные ссылки. Откаченная запись помечается, а не удаляется. Удаление по одной сделало бы квадратичным набор тестов, который подменяет тысячи свойств. Поэтому журнал очищает только restoreMockedProps().
Откат одной подмены отпускает то, что она держала: откаченная запись не хранит ни объект, ни дескриптор. Она освобождает свою ячейку, а не вырезает её. Журнал сам уплотняется, сохраняя порядок, когда пустых ячеек становится больше живых. countMockedProps() — это вычитание, а не обход журнала.
При isolate: true журнал принадлежит одному файлу спеки и уходит вместе с ним. При isolate: false он принадлежит воркеру. Без restoreMockedProps() между тестами каждый объект, который подменила хоть одна спека, остаётся в памяти на весь прогон. setupAutoSpy() вызывает его за вас. Поэтому лучше взять его, чем помнить про откат вручную.
Почему спаи в node:test получают имя при создании
mock.fn() в node:test не принимает имя, и mockName() у него нет. Мок берёт name у функции, которую оборачивает. Поэтому библиотека даёт имя реализации каждого метода при её создании, и мок получает это имя; displayName тоже выставляется. Без этого каждый спай печатался как внутренний диспетчер библиотеки.
Имя даёт сам язык: функциональное выражение под вычисляемым ключом берёт ключ как имя. В отличие от сокращённой записи метода, его ещё можно вызвать через new, а это нужно mockConstructor. Переопределить name потом через Object.defineProperty тоже можно. Но функция тогда уходит с быстрого пути V8, и каждый мок занимает примерно втрое больше памяти. Цифры — на странице Производительность.
Как clearMocks в Rstest добирается до спаев библиотеки
Библиотека строит спаи методов на своей мок-функции, поэтому Rstest их никогда не видел. clearMocks и resetMocks в Rstest обходят все моки, которые создал сам Rstest, вызванные или нет. Поэтому точка входа для Rstest создаёт один скрытый мок Rstest. Его mockClear и mockReset заодно очищают и спаи библиотеки.
Почему без Vitest всё падает раньше, чем библиотека успеет объяснить
Большинство точек входа импортирует vitest статическим import. ESM связывает весь граф модулей до запуска любого кода. Поэтому без установленного Vitest прогон останавливается на ошибке самого Node Cannot find package 'vitest'. Показать сообщение понятнее библиотека там не успевает. npx vitest-auto-spy doctor сообщает о таком импорте как vitest-entry-without-vitest и называет точку входа, которую надо взять.
Откуда взялись некоторые хелперы
Эти хелперы появились, потому что одна и та же проблема раз за разом всплывала в настоящих наборах тестов:
narrow.defined.expect(value).toBeDefined()иassert.exists(value)проверяют, но ничего не возвращают. При строгой проверке типов каждое чтение необязательного значения тогда стоит двух инструкций и локальной переменной. В одном наборе тестов такое было пятнадцать раз в четырёх файлах. Дважды ради сужения типа там заводили отдельную функцию на каждый член заглушки.flushEventLoopUntil.await vi.advanceTimersByTimeAsync(0)тоже доводит отложенную работу, но читается как «сдвинь таймеры» в тесте, где таймеров нет. Следующий читатель удаляет его как мусор. Так и случилось с рукописной версией этого хелпера в наборе тестов, из-за которого он появился.trackInjections. Вручную это каждый раз одни и те же девять строк:providers.map(token => ({ provide: token, useFactory: … })), который пишет в массив, объявленный выше. В одном наборе тестов его написали дважды за один день и собирались в третий раз. Рукописная версия к тому же останавливается на записи, и спеке нужен второй механизм, чтобы подставить ответы. Хелпер строит и то и другое: провайдеры несут автоспаи, а журнал говорит, какие из них создал DI.- Спаи консоли без установки при импорте. Импорт
vitest-auto-spy/consoleставит спаи один раз на вычисление модуля. Приisolate: falseэто один раз на воркер. Спаи включаются в том файле, который импортирует их первым, и глушат все следующие файлы воркера. В одном Angular-проекте на 1759 файлов 32 из 39 файлов с этой точкой входа на это полагались. Три файла начали вызыватьrestoreConsole()вafterEach. Тогда упали 12 тестов в 5 других файлах, а в 7 файлах появился вывод, который скрывала тишина. Поэтому и естьuseConsoleSpies(), и поэтому правилоno-import-time-console-spiesсообщает об этом шаблоне. Под охраной от посторонней консоли импорт ничего не ставит.
Почему setImmediate важен для фейковых таймеров
По умолчанию toFake в Vitest подменяет все таймеры окружения, кроме process.nextTick и queueMicrotask (проверено на Vitest 4.1.9). В Node туда входит и setImmediate. Роутер Express завершает запрос без совпадений через setImmediate(done, layerError) (строка 203 в router/index.js в проверенной версии). Пока часы заморожены, запрос, который должен вернуть 404, висит до таймаута теста. Решение — на странице Фейковые таймеры.