Перейти к содержимому

Производительность ​

На этой странице собраны замеры: чего спай стоит по времени и памяти и какие настройки меняют скорость тестов. Создание спаев почти никогда не причина медленного прогона. Раздел «Коротко» ниже говорит, куда уходят время и память и какие настройки на это влияют.

Коротко ​

Подмена здесь — объект, который стоит в тесте вместо настоящей зависимости; её методы — спаи. Подмена «нетронутая», если тест не обратился ни к одному её методу.

Что быстро. Подмена строится за несколько микросекунд, так что тесты тормозят не из-за спаев.

ВопросКороткий ответПодробнее
Сколько строится одна подмена?Около 2 мкс для класса из 10 методов и 6 мкс для 40, если тест вызывает два-три метода.Что измерено
Какую фабрику брать?provideAutoSpy на Angular, createSpyFromClass в остальных случаях; вызывать в beforeEach.За чем тянуться
Быстрее ли это других библиотек?На одну подмену — в 2–11 раз; на целом наборе тестов — примерно в 1,5 раза быстрее семейства jest-auto-spies.Против других библиотек
На что уходит время медленного прогона?На TestBed: окружение на каждый файл, рендер дочерних компонентов, число воркеров.Что на самом деле замедляет тесты

Что стоит памяти. При isolate: false каждая подмена, созданная файлом, живёт до конца прогона. Так устроен isolate: false, это не утечка. В этом режиме первой кончается память, а не время.

ВопросКороткий ответПодробнее
Сколько держит нетронутая подмена?Меньше 300 Б, каким бы широким ни был класс.Удерживаемая память на подмену
Сколько держит вызванный метод?Около 1,1 кБ против 5,8 кБ у ручного vi.fn().Чего стоит один спай
А на большом наборе тестов?10 000 тестов, класс на 100 методов: пиковый RSS 2,1 ГБ с библиотекой, 6,4 ГБ с ручными vi.fn().Память при isolate: false
Влияет ли версия Node на память?Да: на одних и тех же тестах у Node 26 пик примерно на 15 % ниже, чем у Node 24.Какая версия Node

Какие настройки важны, от самого сильного эффекта:

НастройкаЭффектПодробнее
Vitest 5 под Angular-билдером, с покрытием−46 % времени с покрытием v8, −35,5 % с istanbul; без покрытия — ничегоVitest 5 под Angular-билдером
На Vitest 4, покрытие с множеством include-глобов: обновиться до 5 или обернуть провайдерфаза покрытия 229,59 с → 22,88 с на одном замеренном шардеСопоставление файлов покрытия на Vitest 4
Одно общее окружение (isolate: false + fileParallelism: false)jsdom и TestBed поднимаются раз на прогон, а не на файл; ограничением становится памятьОдно окружение
renderShallow для компонентов с большим числом детейв 3,85 раза при 100 детях, в 17,5 при 400; на листовом компоненте медленнееРендер поддерева детей
Node 26 (или 24) плюс NODE_COMPILE_CACHEхолодный импорт вдвое быстрее, чем на Node 22; кэш срезает импорт на треть, но под покрытием v8 не работаетКакая версия Node, кэш компиляции
Не задавать lazySpiesумолчание само выбирает более лёгкую подмену; lazySpies: false стоит памятиДве настройки, которые чего-то стоят
strayTimers и documentPollution: { nodes: true }небольшая цена на тест, которая копится на больших наборахДве настройки, которые чего-то стоят

Как найти медленные файлы в своём проекте — в разделе Как найти свои номера 1, 2 и 3.

Все числа сняты на одной машине (Apple M4 Max). Смотрите на соотношения, а не на абсолютное время. У каждой таблицы указаны дата и версии.

Что на самом деле замедляет тесты ​

Дело не в создании спаев — см. Что измерено. Три затраты, в которых дело, связаны с Angular:

  1. окружение на каждый файл тестов;
  2. поддерево детей каждого компонента, который рендерит спека;
  3. число воркеров.

У второй есть свой бенчмарк, его таблицы ниже. У первой и третьей его пока нет, поэтому в их разделах — механизм и конфигурация.

1. Одно окружение вместо одного на файл ​

По умолчанию каждый файл тестов получает своё окружение. Общее окружение платит за jsdom и zoneless-TestBed один раз на прогон, а не на каждый файл. Экономия идёт на файл, поэтому растёт вместе с проектом.

В Angular unit-test builder (ng test) isolate: false уже стоит по умолчанию. Добавьте только fileParallelism: false — в файл конфигурации Vitest, который вы передаёте ng test через --runner-config.

ts
// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    isolate: false, // в Angular unit-test builder уже стоит по умолчанию
    fileParallelism: false, // maxWorkers становится 1: все файлы делят одно окружение
  },
});

При ограничении памяти измерьте пиковый RSS, прежде чем оставлять этот режим. Он держит все подмены всех файлов до конца прогона. Если пик подходит к лимиту, оставьте параллельные файлы и уменьшите maxWorkers: тогда каждый воркер держит только свои файлы. Настройки библиотеки меняют пик немного — см. Память при isolate: false.

Две вещи здесь легко понять неправильно:

  • Рычаг — fileParallelism: false, а не isolate: false. Если поменять только isolate, с пулом воркеров по умолчанию, это не убирает цену на файл. Убирает её то, что все файлы попадают в один воркер. isolate: false вместе с setupAutoSpy() делает этот режим безопасным для постоянного включения. setupAutoSpy() — хелпер библиотеки для setup-файла, он чистит общее состояние между тестами.
  • poolOptions: { threads: { singleThread: true } } на Vitest 4 ничего не делает. Vitest пишет was removed in Vitest 4 и игнорирует опцию. Используйте fileParallelism: false на верхнем уровне.

У режима два риска, которых часы не показывают. Всё, что один раз на воркер патчит TestBed, переживает файл, который это запросил. Падение при загрузке приписывается всем файлам воркера. Прочитайте Vitest → Изоляция, прежде чем копировать конфигурацию.

Два хелпера в общем воркере дешевле — там цена на файл становится ценой на прогон:

  • setupAngularTestEnv() помнит, какое тестовое окружение стоит в воркере. Он больше не разбирает и не инициализирует окружение заново для каждого файла спек.
  • Повторный вызов installProxyZonePatch() ничего не делает. Раньше он добавлял слой Proxy на каждый файл спек: двести файлов оставляли двести слоёв на каждой операции зоны.

На маленьком проекте доминирует собственный старт Vitest, и режим почти не помогает. Набор тестов этой библиотеки — 107 файлов и 1 697 тестов. На Node 24 он идёт 4,05 с, из них сами тесты — только 1,8–1,9 с. Остальное — старт, трансформация и разбор, которые общее окружение убирает на каждом файле.

Замеренный эффект isolate: false на память и время — в разделе Память при isolate: false.

2. Рендер поддерева детей ​

renderShallow создаёт компонент через настоящий TestBed, но без детей и без шаблона.

ts
import { renderShallow } from 'vitest-auto-spy/angular';

const view = renderShallow(DashboardComponent); // без дочерних компонентов и без шаблона
const withTemplate = renderShallow(DashboardComponent, { keepTemplate: true });

Его цена не зависит от числа детей: поддерево он не строит. TestBed.createComponent растёт с ними линейно. Поэтому экономия — не фиксированный процент. Она равна разметке, которой владеет компонент.

Бенчмарк рендерит хост-компонент с @for по минимальному ребёнку. Каждая ветка проходит полный цикл теста: resetTestingModule, configureTestingModule, createComponent и первая проверка изменений.

Экземпляров детейTestBed.createComponentrenderShallowотношение
00,487 мс0,619 мс0,79×
250,762 мс0,470 мс1,62×
1001,757 мс0,457 мс3,85×
4007,047 мс0,403 мс17,5×

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

Медиана 60 повторов на ветку, пять проходов, сведённых по медиане, Node v24.19.0, Angular 22 и Vitest 5, 10.09.2026. Второй прогон из пяти проходов дал 0,80× / 1,50× / 3,89× / 16,4×. Читайте соотношения с этим разбросом: rme по веткам — от ±4 % до ±24 %. Строка без детей самая шумная: в зафиксированной базовой линии она колеблется вокруг 1,0, с разбросом ±16 % против ±3 % у строки со 100 детьми.

Куда уходит время на форме со 100 детьми:

Шаг цикламедиана
Полный цикл на тест: reset + configure + createComponent + CD1,556 мс
один resetTestingModule()0,001 мс
resetTestingModule() + configureTestingModule()0,001 мс
createComponent + CD на уже сконфигурированном модуле1,277 мс
configureTestingModule + overrideComponent + createComponent + CD0,365 мс
compileComponents() на standalone-стенде с AOT0,002 мс

configureTestingModule ничего не компилирует: он записывает метаданные и возвращается. Reset и configure — 0,06 % цикла. Переиспользование сконфигурированного стенда между тестами экономит всего 18 %. Почти весь цикл — это createComponent плюс первая проверка изменений, которая строит детей. Именно эту часть убирает renderShallow.

Размен того стоит, потому что спека редко проверяет шаблоны детей. Тест, который читает состояние компонента, платит за поддерево и ничего за это не получает.

Отношение на один рендер — верхняя граница для файла спек, а не прогноз. Файл платит ещё за импорты, модуль TestBed и проверки. Файлы, где цена именно в рендере, помогает найти enableTestBedDiagnostics().

На настоящем проекте. В приватном zoneless-проекте на Angular 22 (784 спеки, AOT unit-test builder) перевели три самые дорогие спеки компонентов. Пакет из десяти файлов прогнали трижды; медианы, одна машина:

Спека (479 тестов в пакете, все зелёные)ДоПослеИзменение
контейнер с глубоким деревом детей (34 теста)129 мс61 мс2,1×
список из 58 фикстур133 мс75 мс1,8×
маленький листовой компонент (20 тестов)29 мс38 мс0,8×
все три вместе291 мс174 мс1,7×

Листовой компонент стал медленнее, как и предсказывает бенчмарк: убирать нечего.

Покрытие веток уходит с скомпилированного компонента. renderShallow меняет компонент через TestBed.overrideComponent, и тот до конца файла спек перекомпилируется в JIT. С keepTemplate: true тоже: детей всё равно надо убрать. Ветки AOT-шаблона и host-биндингов выпадают из покрытия. Обычный рендер позже в том же файле их не возвращает. На проекте из 850 спек это было около 310 веток — хватило, чтобы не пройти порог в 90 %.

Если эти ветки важны, оставьте для компонента один настоящий рендер через TestBed.createComponent — в файле раньше любого renderShallow этого компонента. Компоненту, объявленному в модуле, при keepTemplate: true переопределение не нужно, и его не делают.

Средняя ступень, keepTemplate: true ​

Спеке, которой нужен viewChild, проекция контента или host-биндинг, нужен собственный шаблон компонента. Платить за всё дерево для этого не нужно. С keepTemplate: true renderShallow сохраняет собственные пайпы и директивы компонента и убирает только дочерние компоненты. Шаблон рендерится, а каждый дочерний компонент в нём под NO_ERRORS_SCHEMA не рендерится.

Ступеней три. На той же форме со 100 детьми:

Ступеньмедианапротив полного цикла
TestBed.createComponent, полный цикл1,558 мс—
renderShallow({ keepTemplate: true })1,211 мс1,29×
renderShallow()0,356 мс4,38×

Средняя ступень экономит ровно цену детей. У ребёнка в этом бенчмарке один элемент и один биндинг, так что 1,29× — нижняя граница. Второй прогон из пяти проходов дал 1,24× и 3,89×. Ждите примерно 1,2–1,3× на минимальном ребёнке и больше на настоящем.

Берите среднюю ступень, когда спека читает что-то, что создаёт шаблон. keepChildren оставьте для тех немногих детей, которые спеке нужны настоящими.

До 5.4.0 здесь терялась вся область видимости

keepTemplate: true раньше убирал и пайпы с директивами шаблона. 0 падал с NG0302, а attribute-директива молча не применялась. Спека оставалась зелёной над поведением, которое не выполнялось. Таблица выше измерена на исправленной версии. Поэтому она медленнее, чем в старой редакции этой страницы.

3. Число воркеров ​

Это важно, когда файлы по-прежнему идут параллельно, без fileParallelism: false. Каждый воркер заново импортирует весь граф модулей, и разделить эту работу ему не с кем. Поэтому лишние воркеры быстро перестают помогать, а каждый добавляет памяти. Измерьте, прежде чем поднимать maxWorkers: значение по умолчанию не обязательно лучшее.

Как найти свои номера 1, 2 и 3 ​

Три затраты выше в каждом проекте разные. Два флага Vitest 4.1 измеряют их на вашем. Ни один из них не относится к этой библиотеке.

  • experimental.importDurations: { limit, print } называет импорты, которых ждёт файл спеки, с порогом и необязательным падением. Он показывает, в чём цена файла: в TestBed, в подменах или в одном тяжёлом barrel-импорте, которым спека не пользуется. В Angular-проектах обычно в barrel-импорте.
  • experimental.preParse (4.1.3) убирает парсинг с критического пути на старте.

enableTestBedDiagnostics() показывает цену на файл изнутри TestBed. Флагами Vitest ищите, какие файлы дорогие. Диагностикой — куда уходит время внутри одного из них.

Проверка perf на медленной машине ​

Бюджет perf --gate считается в медианном тесте того же прогона. Поэтому ноутбук и раннер в девять раз медленнее дают один вердикт. Замер на проекте из 2 023 файлов с искусственным замедлением:

ЗамедлениеФайлов у прежнего правила с фиксированным временемФайлов у проверки
×100
×340
×680
×9190

Прежнее правило сравнивало файл с фиксированным порогом (5 с) и десятью медианными файлами. В том проекте медианный файл — 8,9 мс и пять тестов, так что решал один порог. Он отмечал самые большие файлы, например спеку сервиса на 209 тестов по 5 мс на тест. Одна спека компонента шла 0,96 с отдельно на ноутбуке и 5,62 с в CI: зелёная на одном, красная на другом. Правило по тестам не отмечает ничего ни при каком замедлении: самый тяжёлый файл набирает 742 медианных теста из 2 000 допустимых.

Что намерили находки doctor и perf ​

Находки doctor и perf называют вывод. Замеры за ними:

НаходкаЗамер
angular-build-happy-domAngular 22.2, 700 файлов: время −4,6 %, резидентная память −6 %
angular-cache-off-in-ci700 файлов: +2,91 с (+33 %) на прогон с выключенным кэшем билдера
runner-dom-differs-from-builderAngular-проект на 48 файлов с environment: 'happy-dom': 1,37 с → 1,10 с (−20 %)
perf-environment-engine117 Angular-файлов этого пакета: 26,5 с → 23,2 с пользовательского CPU (−12 %); спека, которая только строит DOM: 253 → 119 мс окружения на файл
perf-workers1,42 ГБ плюс ~155 МБ на воркер; лимит в половину ядер: 13,50 с против 13,13 с (+2,8 %) при 3,7 ГБ вместо 5,8 ГБ
perf-isolationпроект с накладными расходами больше 30 %: isolate: false быстрее на 0 %, работа переехала в файлы; jsdom-проект на 30 файлов: 1,13 с → 583 мс (--ab-isolate)
окружение считается раз на воркершард на 672 файла и 13 воркеров показал 126,4 с окружения при 2,44 с реально потраченных
голый прогон — не ваш набор тестоводно рабочее пространство: 1 830 файлов, 29 с по часам, 0 выполненных тестов
ng-test --shard700 спек, покрытие, кэш билдера выключен: медленный шард 7,58 с против 11,21 с на весь прогон; test.shard из конфигурации раннера — 9,38 с, потому что каждый шард компилировал все 700 спек
perf-heapодин @sentry/angular — около 7,5 МБ кучи
enableAngularDiagnostics()Angular-проект на 1759 файлов: настоящие дефекты в 25 файлах и 324 тестах; весь прогон 12,5 с с ней против 13,4 с — цена неизмерима

Vitest 5 под Angular unit-test builder ​

При включённом покрытии обновление раннера — единственный рычаг, который не требует правок в спеках. @angular/build 22.2.0 — первый релиз, чей @angular/build:unit-test принимает Vitest 5. Его peer-диапазон — ^4.0.8 || ^5.0.0; до 22.2.0 он заканчивался на ^4.

Проект: Angular 22.2, 700 файлов спек, 11 491 тест на этой библиотеке. Поменялся только раннер билдера — с Vitest 4.1.11 на 5.0.2:

провайдер покрытияVitest 4.1.11Vitest 5.0.2время прогона
v8 (по умолчанию у билдера)16,50 с8,91 с−46,0 % (в 1,85 раза быстрее)
istanbul37,07 с23,92 с−35,5 % (в 1,55 раза быстрее)

Откуда выигрыш:

  • Без покрытия выигрыша нет: 6,16 с против 5,97 с по медиане. Тесты на обеих версиях идут с одной скоростью. Vitest 5 сокращает обработку покрытия.
  • С v8 последний тест на обеих версиях заканчивается в один момент. Дальше Vitest 4 около 9,3 с конвертирует и сливает покрытие, а Vitest 5 — около 1,1 с.
  • С istanbul сокращаются обе фазы. Прогон с инструментированием — примерно с 27 с до 16–20 с, слияние — с 8–9 с примерно до 6 с.
  • Один переход Angular 22.1 → 22.2 — это шум: −2,0 % (v8) и −3,4 % (istanbul) на Vitest 4.
  • Выигрыш растёт с размером проекта. На 150 файлах спек (2 454 теста) он −15,5 % (v8) и −17,7 % (istanbul). В маленьком прогоне есть 2,5–3 с постоянной сборки и старта ng, которых Vitest 5 не касается.
  • Память не меняется. Пиковый RSS дерева процессов меняется на −1,8 % (v8) и +2,5 % (istanbul) — в пределах шума.

Как мерили.

ЧтоКак
Рабочее пространствоповторяет ng new 22.2.0 --defaults: zoneless, jsdom, настройки билдера по умолчанию (включая isolate: false), strict, strictTemplates
Настройкаодин setup-файл, импортирующий vitest-auto-spy/angular и vitest-auto-spy/rxjs; vitest-auto-spy 5.34.0 из tarball с npm
Тестысгенерированы по образцу настоящих проектов: 370 сервисов с 3–6 зависимостями через provideAutoSpy и injectSpy, 251 standalone-компонент через TestBed и whenStable(), 36 пайпов, 43 функциональных гарда
Командаng test --watch=false --coverage; файл конфигурации раннера (--runner-config) с единственной строкой test.coverage.provider
Прогоныодин отброшенный прогрев и 5 замеров на ячейку, вперемешку, в таблице медиана
МашинаApple M4 Max, 16 ядер, Node v24.19.0, 26.09.2026

Диапазоны не пересекаются. Самый медленный прогон на Vitest 5 (9,32 с v8, 24,60 с istanbul) быстрее самого быстрого на Vitest 4 (16,01 с и 35,44 с). Генератор, настройка и сырые замеры лежат в исходниках бенчмарка билдера.

Чего замер не показывает. Машина одна и не простаивала, поэтому смотрите на соотношения, а не на секунды. Тесты синтетические: по форме и размеру они совпадают с настоящими проектами, но сами проекты не прогонялись. Кэш билдера был тёплым, как при локальной разработке. При CI=true кэш билдера выключен: это добавляет те же 2,5–3 с к каждому прогону и немного уменьшает процент. CI-раннер с меньшим числом ядер не замерялся.

Как это получить:

  • @angular/build (и @angular/cli) 22.2.0 или новее.
  • vitest и все пакеты @vitest/coverage-* на 5, обновлённые вместе. Пакет покрытия пинит vitest на свою точную версию.
  • На Analog — @analogjs/vite-plugin-angular и @analogjs/vitest-angular 2.7.5 или новее.
  • Никаких npm overrides: peer-диапазоны уже допускают Vitest 5.
  • Vitest 5 по умолчанию включает clearMocks. Единственный приём в спеках, который от этого ломается, и его исправление — в Vitest → Единственное, что всё ещё может сломать ваши спеки. 11 491 тесту выше правки не понадобились: provideAutoSpy создаёт каждый спай на тест.

Ничто из этого не обязательно. Библиотека поддерживает Vitest 2.1+ и Angular 20+, а старый @angular/build продолжает работать как есть.

Без Angular ​

На обычном проекте одно обновление раннера помогает меньше, а движок спаев добавляет своё. 40 файлов спек, 800 тестов, 400 спаев методов на файл, покрытие v8, Node v24.19.0, медиана пяти прогонов, 06.09.2026, один и тот же исходник библиотеки:

Набор тестовVitest 4.1.11Vitest 5.0.0
движок спаев библиотеки (по умолчанию)1383 мс1276 мс
каждый метод через vi.fn() (setSpyEngine('runner'))1473 мс1390 мс
ИзменениеЭффект
только раннер, 4 → 5−7,7 %
движок спаев против vi.fn() на Vitest 4−6,1 %
движок спаев против vi.fn() на Vitest 5−8,1 %
Vitest 4 с vi.fn() → Vitest 5 с движком−13,4 %

Сопоставление файлов покрытия на Vitest 4 ​

На Vitest 4 решить, какие файлы попадают в отчёт покрытия, бывает дороже, чем собрать само покрытие. @vitest/coverage-v8 вызывает isIncluded для каждого файла. Его кэш хранит вердикт по имени файла, а не скомпилированный матчер глобов, поэтому каждое имя файла заново компилирует каждый шаблон. Vitest 5 строит матчеры один раз, так что раздел касается только Vitest 4 и старше. Где можно, дешевле обновиться.

Профиль одного шарда Angular-проекта на 1 725 файлов, область покрытия — 124 include-глоба и 304 отрицания:

Фаза Generate coverage, всего 224,2 свремя
чтение файлов покрытия 432 воркеров2,6 с
до первой конвертации54,3 с
перенос координат 1 958 покрытых файлов50,5 с
проход по 458 непротестированным файлам0,35 с
финальный coverageMap.filter114,1 с

Обычно винят проход по непротестированным файлам, а он занимает треть секунды. Финальный фильтр — один вызов isIncluded на файл — это половина прогона. Против матчера, скомпилированного один раз, на тех же глобах:

Операциякак естьскомпилирован один раз
isIncluded, 8 000 вызовов167 853 мс1 808 мс
coverageMap.filter81 393 мс713 мс
проход по 1 816 непротестированным файлам69 779 мс9 439 мс

Обёртка над провайдером покрытия, которая компилирует матчер один раз, на том же шарде и тех же репортерах:

ПоказательКак естьС обёрткой
фаза Vitest229,59 с22,88 с
файлов432 / 432432 / 432
отчёт cobertura8,0 МБ8,0 МБ
покрыто инструкций27 292 / 65 325 (41,77 %)27 289 / 65 325 (41,77 %)
на файл, 200 разных путей1,13 мс0,018 мс, вердикты совпадают

Разница в три инструкции — обычный дрейф общего окружения.

Обёртка, которая отдаёт allowExternal: false исходному методу, не даёт ничего

@angular/build:unit-test включает allowExternal: false. Обёртка, которая отдаёт этот случай исходному методу, на каждом вызове идёт медленным путём. Первая попытка так и сделала: 227,6 с против 229,6 с, что выглядит как «идея не работает». Проверяйте путь прямо в обёртке — двумя startsWith по корням рабочего пространства и проекта.

Ещё два числа из той же серии:

  • v8 против istanbul. На одном Angular-монорепозитории v8 выбросил с предупреждением 184 из 4969 файлов, которые не смог разобрать, и прогон остался зелёным. При одинаковых настройках он был на 28 % быстрее istanbul: 81 с против 113 с.
  • Размер отчёта. Отчёт cobertura весил 10,78 МБ без include и 8,89 МБ с ним. GitLab разбирает не больше 10 МБ. Сверх лимита отчёт отбрасывается молча: задача зелёная, а в merge request нет подсветки покрытия.

Разбиение кода выключено в @angular/build 22.1.5 и 22.1.6 ​

В @angular/build 22.1.5 и 22.1.6 бандл unit-тестов собирается esbuild без разбиения кода, и включить его обратно нельзя. 22.1.7 возвращает разбиение.

ЧтоЗамер
граф бандловкаждая спека — самостоятельный бандл: 791 чанк / 596 МБ на 784 спеки
с --coverageрастёт на сотни мегабайт без плато, пока прогон не убьют
подмена модулейбез изменений: vi.mock ведёт себя одинаково
предупреждение билдеранет, ни в одном режиме

Кэш модулей istanbul под билдером ​

Под билдером esbuild уже собрал код. Кэшировать осталось только инструментирование istanbul, через fsModuleCache в Vitest. Выигрыш зависит от проекта:

Проект (Angular 22.2, Vitest 5)istanbul, холодноistanbul, тёплый кэшv8, холодноv8, тёплый кэш
700 файлов спек24,55 с13,20 с (−46 %)—без выигрыша
700 файлов, кэш билдера выключен (как в CI)27,01 с15,85 с (−41 %)——
862 файла спек48 с39 с (−19 %)19–22 с18,7–22,5 с
862 файла, профиль CI (CI=1, 3 форка)51–53 с41–44 с31–32 с30 с

На проекте из 862 файлов v8 был примерно вдвое быстрее istanbul с тёплым кэшем. Его пик — 7,5–8 ГБ RSS против 10–11 ГБ у istanbul, при 100 % покрытии в обоих случаях.

Какая версия Node ​

Запускайте Node 26 или 24; при ограничении памяти — 26. Нижняя граница поддержки — Node 22 (engines.node: ">=22"), CI проверяет 22, 24 и 26. Замер на одной машине, 04.09.2026, на самом свежем релизе каждой мажорной версии:

2022242526
тесты библиотеки, 107 файлов, vitest run (лучшее из 3)4,85 с4,63 с4,05 с3,91 с3,84 с
холодный import('vitest-auto-spy/node') (лучшее из 7)15,0 мс14,8 мс6,7 мс6,2 мс6,3 мс
старт процесса (лучшее из 11)12,0 мс13,3 мс10,9 мс10,6 мс10,3 мс
пиковый RSS прогона (медиана из 3)3947 МБ4185 МБ5467 МБ5313 МБ4645 МБ
RSS после импорта (медиана из 7)43,9 МБ51,5 МБ52,1 МБ55,1 МБ58,3 МБ

Перелом — между 22 и 24:

С 22 на 24Изменение
холодный импортв 2,2 раза быстрее
время прогонав 1,14 раза быстрее
старт процессав 1,22 раза быстрее
пиковый RSS прогонана 1,3 ГБ выше

Импорт важнее всего, когда тесты разнесены по многим файлам: его оплачивает каждый воркер. Node 24, 25 и 26 по времени расходятся на несколько процентов. По памяти — нет: 26 даёт пиковый RSS примерно на 15 % ниже, чем 24.

Цена спая от версии не зависит. Удерживаемые байты на подмену совпадают с точностью до 0,2 % на 20, 22, 24, 25 и 26. Строки RSS двигаются, потому что новый V8 стартует с большей кучей и собирает мусор позже.

В CI-контейнере с ограничением памяти берите 26: он так же быстр, как 24, а пик у него примерно на 15 % ниже. Ещё задайте лимит кучи V8 ниже лимита контейнера, чтобы V8 собирал мусор раньше, чем контейнер убьёт процесс:

bash
export NODE_OPTIONS=--max-old-space-size=<мегабайт меньше лимита контейнера>

У Node 22 пик ещё ниже, но холодный импорт вдвое медленнее; лимит кучи решает ту же задачу без этой цены. Node 20 уже без поддержки и остался в таблице, только чтобы показать, почему граница сдвинулась. Про Node 18 — в разделе Подробнее.

Кэш компиляции Node ​

Начиная с Node 22.1, NODE_COMPILE_CACHE сохраняет скомпилированный V8 код каждого модуля, который грузит Node. Следующий старт его переиспользует. Воркеры Vitest наследуют переменную, так что одна строка покрывает весь прогон.

bash
# локально: любой каталог, который переживает прогон
export NODE_COMPILE_CACHE=node_modules/.cache/node-compile
npx vitest run
yaml
# GitHub Actions: восстановите каталог, иначе каждая задача стартует холодной
env:
  NODE_COMPILE_CACHE: ${{ github.workspace }}/.node-compile-cache
steps:
  - uses: actions/cache@v4
    with:
      path: .node-compile-cache
      key: node-compile-${{ runner.os }}-${{ matrix.node }}-${{ hashFiles('package-lock.json') }}
  - run: npx vitest run

Замер на этом пакете в голом Node с тёплым кэшем:

ИмпортБез кэшаС кэшем
корневая точка входа4,17 мс2,97 мс
корень + /setup (что файл спеки грузит от нас)6,51 мс4,32 мс (−34 %)

Раннер и любая другая зависимость, которую Vite не трансформирует (externalized), кэшируются так же. На настоящем проекте выигрыш не ограничивается этим пакетом.

Окупится ли кэш, решают три вещи:

  • Каталог должен переживать прогон. Кэш пишется при выходе процесса. CI-задача, которая стартует с пустого каталога, пишет кэш, который никто не прочтёт. Восстанавливайте его как node_modules/.vite, с ключом по версии Node и локфайлу.
  • Покрытие с провайдером v8 ничего не получает. Под coverage.provider: 'v8' Vitest выключает кэш в воркерах: покрытие V8 теряет точность на кэшированном коде. Под istanbul кэш остаётся.
  • Кэш покрывает только то, что Node компилирует сам. Ваши спеки и заинлайненные зависимости проходят через трансформацию Vite и модульный раннер Vitest. Числа выше их не включают.

NODE_DISABLE_COMPILE_CACHE=1 выключает кэш на один прогон.

За чем тянуться ​

СитуацияБеритеПочему
Зависимость в AngularprovideAutoSpy(Service)готовый DI-провайдер
Класс, без AngularcreateSpyFromClass(Service)та же фабрика, то же ленивое умолчание
В рантайме класса нет — интерфейс, типcreateAutoMock<T>()Proxy; член создаётся при первом обращении
signalStore() из ngrxcreateAutoMock<T>()его члены живут на экземпляре, прототипа для чтения нет
Форма данных, которую код только читаетcreateMock<T>(partial)вообще без спаев — это одна проверенная типом декларация
Вложенный граф объектовmockDeep<T>()сам создаёт цепочку спаев вниз по дереву

Вызывайте фабрику в beforeEach. Свежая подмена на каждый тест стоит микросекунды, а список методов кэшируется на класс.

Две настройки, которые чего-то стоят ​

Большинство опций не стоят ничего измеримого. В таблице — пять, которые стоят; первыми идут два значения lazySpies.

НастройкаЦенаКогда нужна
lazySpies: true на классе от 8 методовподмена, к которой тест обращался, на 21–68 % тяжелее и дольше строитсяспеке нужен обычный объект или она читает один член в горячем цикле
lazySpies: false11,50 мкс вместо 6,04 мкс (40 методов); 42 655 Б вместо 259 Б у нетронутой (100 методов)спека изучает спай через дескрипторы свойств
autoSpyAccessors: trueлишний шаг через аксессор на каждом спае; обход прототипа кэшируетсякогда нужны спаи аксессоров; для меньшего набора — перечислите их
setupAutoSpy({ strayTimers: true })запланированный и снятый таймаут дорожает с 70 нс до ~1,75 мкскогда ищете таймеры, пережившие свой файл
documentPollution: { nodes: true }от 3,5 до 188 мкс на тест, растёт с числом детей в <head>на тех блоках describe, которым это нужно, а не на всём наборе

У lazySpies три значения, и все, кроме false, ленивые:

ЗначениеЧто такое подмена
не задано (умолчание)до 8 методов — аксессор на каждый метод; от 8 методов — Proxy
trueаксессор на каждый метод при любой ширине
'proxy'Proxy при любой ширине
falseвсе спаи строятся сразу

lazySpies: true на широком классе даёт аксессор на каждый метод вместо Proxy. Чтения становятся обычными: около 5 нс против 20–25 нс через ловушку прокси. Нетронутая подмена легче примерно на 100 Б и остаётся обычным объектом. Тронутая тяжелее и строится дольше — см. таблицу по ширине.

lazySpies: false строит все спаи сразу. Перечисление (Object.keys, spread, снапшот) и так работает в любом ленивом режиме. false нужно, только если спека читает дескрипторы свойств спая.

autoSpyAccessors: true обходит цепочку прототипов в поисках геттеров и сеттеров. Обход кэшируется на прототип, как и обход методов. Класс, подменённый в 300 тестах, платит за него один раз. Цену лишнего шага через аксессор отдельно не мерил ни один бенчмарк. Явные списки gettersToSpyOn / settersToSpyOn нужны, чтобы спаев было меньше, а не ради скорости.

Всё остальное — константа: methodsToSpyOn, observablePropsToSpyOn, хелперы по типу возвращаемого значения. Точный calledWith — поиск по карте быстрее микросекунды: 0,17 мкс на две настроенные формы плюс промах, в микробенчмарке.

calledWith с асимметричным матчером — единственная более медленная форма. Это expect.any и expect.objectContaining, в том числе вложенные в объект, массив, Map или Set. Такую конфигурацию не превратить в статический ключ. Она хранится предикатом и сравнивается с фактическими аргументами после промаха точного поиска. Дешёвой её держат три вещи:

ЧтоРаньшеТеперь
Сериализация аргументов конфигурациина каждом вызовеодин раз, при регистрации конфигурации
Аргумент из 200 полей против восьми конфигураций636 мкс (рендер на каждую конфигурацию)0,4 мкс (рендер один раз на вызов)
Граф компонентов с обратной ссылкой4,1 мс на вызов0,1 мкс
Циклический граф глубиной 18 уровнейключ 10,7 МБ за 276 мсключ 84 кБ за 0,29 мс

Структурное сравнение добавляет около 10 % к ключу глубокого объектного аргумента. Шесть из этих десяти пунктов — чтение symbol-ключей каждого объекта, чтобы два аргумента, различающиеся только символом, не совпали. Единственный примитивный аргумент стоит столько же, сколько раньше. Если у вас есть точный calledWith, берите его.

setupAutoSpy({ strayTimers: true }) включается и через preset: 'strict'. Каждый setTimeout, setInterval и requestAnimationFrame снимает стек, чтобы забытый таймер мог назвать вызов, который его запланировал. Из-за этого запланированный и снятый таймаут дорожает с 70 нс примерно до 1,75 мкс. Почти всё это — V8, строящий стек: около 0,9 мкс на любой глубине. До того как 5.6 стала записывать происхождение, было 116 нс (Node v24.19.0, 11.09.2026).

Что ещё стоит снятие стека, Node v24.19.0, Apple M4 Max:

СлучайЦена
таймер в ожидании, пока не сработал или не убраноколо 0,9 кБ памяти сверху
каждый кадр за глубокой цепочкой фреймворкаоколо 60 нс
таймер за глубокой цепочкойоколо 3 мкс (двенадцать кадров: 1,4 мкс)
файл, который планирует 10 000 таймеровоколо 16 мс в сумме
имя теста, который запланировал таймерне измеримо: 4,0 мкс на отслеживаемую пару до и после, с покрытием

Чего стоит охрана из /setup на каждый тест ​

Проверки setupAutoSpy() выполняются на каждом тесте. Их цена растёт с числом тестов, а не с числом подмен. В таблице — полное время пустого теста вместе с раннером. Чтобы получить долю библиотеки, вычтите первую строку.

Настройкаnodehappy-dom
без setup-файла (только раннер)8 мкс8 мкс
setupAutoSpy() со своими умолчаниями16 мкс18 мкс
setupAutoSpy({ strayConsole: 'throw' })—23 мкс
setupAutoSpy({ guardGlobals: 'warn' })—19 мкс
setupAutoSpy({ preset: 'strict' })18 мкс30 мкс

5 000 пустых тестов, один воркер, Node v24.19.0, Vitest 5.0, 26.09.2026. С покрытием istanbul:

Настройкаnodehappy-dom
без setup-файла10 мкс11 мкс
setupAutoSpy() со своими умолчаниями20 мкс23 мкс
setupAutoSpy({ preset: 'strict' })33 мкс28 мкс

Десять тысяч тестов на строгом пресете добавляют около 0,2 с времени библиотеки на весь прогон. Цена на тест не растёт с числом проверок по трём причинам:

  • Каждая проверка — шаг в одном общем beforeEach и afterEach. Отдельные хуки стоили бы около 1 мкс каждый на тест.
  • Страховочная уборка после теста на Vitest 4.1 и новее работает через aroundEach. onTestFinished на каждый тест каждый раз снимал стек — около 5 мкс.
  • guardGlobals больше не перечисляет globalThis после каждого теста, а это стоило 20–25 мкс в DOM-окружении. Он отмечает, какой наблюдаемый объект получил неконфигурируемое определение, и проверяет только их.

documentPollution: { nodes: true } сравнивает детей <html>, <head> и <body> вместе с их атрибутами. Цена зависит от того, сколько там детей:

Детей в <head>nodes: trueс ignoreNodes
03,5 мкс5 мкс
5011 мкс43 мкс
30056 мкс243 мкс
1 000188 мкс808 мкс

Angular-проект, где стили компонентов копятся в <head>, может дойти до тысячи детей. На такой ширине проверка раньше стоила 7,1 мс на тест. Теперь она идёт по соседям, а не копирует живую HTMLCollection на каждом чтении. Селектор ignoreNodes проверяется раз на тест, а не раз на узел. Включайте nodes на том блоке describe, которому это нужно, а не на всём наборе тестов.

Чего стоят хелперы эмиссий на каждый вызов ​

expectEmission и родственные хелперы строят привязку стека для сообщения об ошибке только когда ожидание провалилось. Проходящий вызов стоит меньше:

Место вызоваПроходящий вызов, раньшеПроходящий вызов, теперь
верхний уровень3,6 мкс2,3 мкс
на глубине двадцати пяти кадров4,6 мкс3,2 мкс

Падающий вызов платит около 0,7 мкс сверху за ту же информацию.

skip и until решают, считать ли значение, в момент его прихода. Раньше они на каждом новом значении заново просматривали всё собранное. На потоке, который много эмитит перед тем, как успокоиться, это быстрее в 30–300 раз: линейно, а не квадратично. expectCompletion и expectError теперь считают значения, а не хранят их. Ожидание на десяти тысячах значений держит число, а не массив.

Что измерено ​

Чего стоит построить и вызвать одну подмену. Число — p75 прогона бенча Vitest. Эти сценарии создают спаи сотнями тысяч, поэтому паузы GC раскачивают hz между прогонами в разы. p75 воспроизводится до четвёртого знака.

Операцияна вызов (p75)
спай на класс из 10 методов, вызвано 2 — lazySpies: true2,25 мкс
то же, жадно (lazySpies: false)3,21 мкс
спай на класс из 40 методов, вызвано 3 — lazySpies: true6,04 мкс
то же, жадно11,50 мкс
createAutoMock<Service>() + 4 обращения1,79 мкс
диспетчеризация calledWith, 3 настроенных вызова0,21 мкс
ненастроенная диспетчеризация, 3 вызова с двумя объектами-аргументами0,25 мкс

Node v24.19.0, Vitest 4.1.11, Apple M4 Max, 04.09.2026. Когда в 4.1 появился движок спаев, каждая строка ускорилась в 1,6–6 раз. Сильнее всего — жадные строки.

Ленивый режим выигрывает, пока тест не вызывает все методы.

КлассЛенивый против жадного, вызвано 2–3 методаВызваны все методы: лениво / жадно
10 методовв 1,43 раза быстрее5,83 мкс / 4,21 мкс
40 методовв 1,90 раза быстрее23,08 мкс / 16,00 мкс

Когда вызваны все методы, жадный режим примерно в 1,4 раза быстрее. Выбор решает память. Нетронутая подмена на 100 методов удерживает 259 Б лениво и 42 655 Б жадно. Полностью построенные, они расходятся меньше чем на 1 % — см. Удерживаемая память на подмену. Поэтому ленивый режим — умолчание.

На фоне целого прогона это шум. Пять провайдеров на две тысячи тестов — десять тысяч подмен, около 0,02 с на весь прогон. Медленный прогон тратит время в TestBed. Это измеряет enableTestBedDiagnostics(), а обычно чинит renderShallow.

Три фабрики на Vitest 5 с Angular. Тот же класс из десяти методов, вызвано два. Node v24.19.0, Vitest 5.0.0, Angular 22.1.5, 06.09.2026, медиана p75 пяти прогонов (расходились не больше чем на 0,1 мкс):

Вызовна вызов (p75)
provideAutoSpy(Service) — лениво, по умолчанию2,1 мкс
createSpyFromClass(Service, { lazySpies: false }) — жадно2,8 мкс
createAutoMock<Service>() + 4 обращения1,5 мкс

createAutoMock быстрее всех, потому что не обходит прототип. Но он и теряет то, что даёт обход: подмену, которая падает, когда у настоящего класса пропал метод. provideAutoSpy ничего не добавляет к createSpyFromClass; разница между первыми двумя строками — это lazySpies.

Цена проверки типов измерена отдельно. Проверки репозитория считают, во сколько созданных экземпляров типов Spy<T> обходится tsc, и падают при выходе за бюджет — см. Цена проверки типов.

Почему это быстро ​

Подмену дешёвой держат три вещи:

  1. Ленивые спаи. lazySpies включён по умолчанию для createSpyFromClass и provideAutoSpy. Спай метода строится при первом обращении. Сервис на двадцать методов, к которому тест обратился дважды, построит два спая. До 8 методов заглушка — аксессор; от 8 подмена — Proxy.
  2. Кэш списка методов. Цепочка прототипов обходится один раз на класс и кэшируется в WeakMap. Класс, подменённый в 300 тестах, обходит прототип один раз.
  3. Собственный движок спаев. С 4.1 спай метода на Vitest — не vi.fn().

Движок спаев ​

vi.fn() дорого создавать, даже если его ни разу не вызвали. Каждый мок получает:

  • Около двадцати пяти замыканий собственными свойствами функции.
  • Шесть массивов состояния вызовов, даже если мок ни разу не вызовут.
  • Запись в сильном Set уровня модуля и в WeakMap.
  • defineProperty на length и на mock.

Подмена класса из сорока методов платила это сорок раз ради спеки, которая вызывает два метода.

Теперь библиотека строит спаи методов сама:

  • Один общий прототип несёт весь API Mock. Спай — это один объект-функция и одна маленькая запись конфигурации.
  • Состояние вызовов появляется на первом вызове. Метод, которого никто не касался, не владеет ни одним массивом. Вызванный спай пишет четыре массива: calls, invocationCallOrder, results и contexts. settledResults и instances выводятся при первом чтении, дальше пишутся на каждом вызове. До этого чтения results хранит само возвращённое значение, а записи { type, value } строятся при первом чтении. Записанный вызов стоит около 100 байт; вызов vi.fn() — около 200.
  • Нет глобального реестра, так что потом ничего не надо подчищать.

Всё, что видит спека, не изменилось: vi.isMockFunction, все матчеры, семейство mockReturnValue, mock.calls / .results / .settledResults / .instances / .contexts / .lastCall, mockClear / mockReset / mockRestore, using. Собственные тесты библиотеки гоняют спай и vi.fn() через одни и те же шаги и сравнивают состояние. Если Vitest начнёт записывать иначе, упадут эти тесты, а не ваши.

Две детали точно повторяют vi.fn():

  • Сброс (mockReset, mockRestore, using, vi.resetAllMocks()) выбрасывает имя из mockName(). Он возвращает имя, с которым спай был создан, как это делает vi.spyOn. Голый спай снова читается как vi.fn(), спай метода — как имя метода.
  • length спая равен length его реализации, как у vi.fn(impl).

vi.clearAllMocks(), clearMocks: true и mockReset: true до этих спаев тоже добираются. Как — в разделе Подробнее.

Что дал движок, 4.0 против 4.1 (медиана семи прогонов, та же машина, тот же день):

4.04.1
подмена, чей тест вызывает все 14 из 14 методов18,92 мкс (проигрыш ручному vi.fn())8,17 мкс (на 2,19× впереди)
подмена, чей тест вызывает все 45 из 45 методов75,33 мкс (проигрыш)26,12 мкс (на 2,37× впереди)
createAutoMock<T>(), затронуто 40 членов72,88 мкс (проигрыш vitest-mock-extended)18,92 мкс (на 3,00× впереди)
mockDeep<T>(), 3 уровня, вызван лист8,83 мкс (проигрыш)2,29 мкс (на 2,38× впереди)
удерживаемая куча, один построенный метод5 445 Б1 929 Б
удерживаемая куча на метод, жадная подмена, ничего не вызвано4 418 Б632 Б

Это цифры релиза 4.1, снятые на Vitest 4. Строки по куче с тех пор сдвинулись на Vitest 5, сильнее всего — собственный мок раннера. Актуальные числа — в разделе Удерживаемая память на подмену.

Строка с диспетчеризацией calledWith тоже сдвинулась, с 0,54 до 0,17 мкс, но по другой причине — см. микробенчмарк.

Единственное отличие и способ его обойти. mock.invocationCallOrder считает по собственной шкале библиотеки. expect(a).toHaveBeenCalledBefore(b) точен между двумя автоспаями. Между автоспаем и ручным vi.fn() он бессмыслен: два счётчика никогда не встречаются. Брать счётчик раннера значило бы вызывать его мок на каждой диспетчеризации, а именно этой цены движок и избегает. Поэтому переключение явное:

ts
// vitest.setup.ts
import { setSpyEngine } from 'vitest-auto-spy/setup';

setSpyEngine('runner'); // каждая подмена после этого — vi.fn() на метод, как до 4.1
APIЧто делает
setSpyEngine(engine)Принимает SpyEngine: 'auto-spy' (по умолчанию) или 'runner'. Влияет на подмены, созданные после вызова.
getSpyEngine()Возвращает текущий движок, чтобы setup-файл мог проверить, что получилось.
значение, которое возвращает setSpyEngineФункция отмены, возвращающая прежний движок, например в afterAll.

Уже созданные подмены сохраняют свой движок. Как переключить движок на один файл при isolate: false — в разделе Движок на один файл.

Движок работает только на Vitest. На Bun и node:test матчеры раннера распознают только моки самого раннера. Эти точки входа по-прежнему строят спаи из mock() и t.mock.fn().

Память, а не только время ​

Что измерено касается одной подмены. Влезет ли большой набор тестов в контейнер, зависит от того, что подмены удерживают. Здесь ленивость и оправдывает умолчание. На классе из 40 методов спека, вызывающая два метода, никогда не построит остальные 38. Каждый пропущенный метод — это функция, её карта аргументов и наборы хелперов.

Байты — в разделе Удерживаемая память на подмену. Разделы ниже объясняют, откуда они берутся.

Где остаётся память и lazySpies: 'proxy' ​

Ленивой подмене всё равно надо что-то определить под каждое имя метода. С заглушкой-аксессором (lazySpies: true) это один аксессор через Object.defineProperty на метод. Пара get / set общая для всех подмен с методом этого имени. Подмену она находит через this. Поэтому нетронутое имя стоит слот дескриптора — около двух байт. Нетронутая подмена удерживает меньше 200 Б, стоит ли она за 10 методов или за 100.

У первого чтения метода на этом пути есть цена. V8 держит подмены с общими аксессорами на общей «быстрой» карте. Превращение одного аксессора в свойство-данные переписало бы всю карту. Поэтому подмена один раз, при первом чтении, переходит в словарный режим, где это обновление хеша. Нетронутая подмена за это не платит. История этого решения — в разделе Подробнее.

Из-за этого словаря класс от 8 методов получает 'proxy', если lazySpies не задан. Прокси отвечает на все методы из одного обработчика ловушек и одного множества имён на класс. Он ничего не определяет, поэтому растущего словаря нет.

Байт на подмену при одном и двух вызванных методах (1 000 подмен одновременно, 27.09.2026):

Методов в классеtrue, тронуто 1 / 2'proxy', тронуто 1 / 2Экономия
4–61 743 / 2 887 Б1 415 / 2 573 Б−19 % / −11 %
8–162 110 / 3 269 Б1 417 / 2 573 Б−33 % / −21 %
20–302 877 / 4 036 Б1 416 / 2 573 Б−51 % / −36 %
454 412 / 5 570 Б1 417 / 2 577 Б−68 % / −54 %

Ступени задаёт V8: в словаре pow2ceil(1,5·(ширина + 3)) слотов.

'proxy' против trueЭффект
построить и тронуть, 8 методов2,2 → 1,1 мкс
построить и тронуть, 45 методов6,5 → 1,0 мкс
каждое чтение членаоколо 20–25 нс вместо 5 нс, всю жизнь подмены
нетронутая подменапримерно на 100 Б тяжелее
что видит спекаподмена — Proxy

Восемь методов — наименьшая ширина, где прокси экономит не меньше 15 % на обоих профилях. Ниже прокси всё ещё экономит 11–19 %, но ради этого не стоит отказываться от обычного объекта, поэтому умолчание остаётся обычным объектом. lazySpies: true возвращает широкому классу аксессоры.

Чего стоит один спай ​

На Vitest построенный спай метода — собственный спай библиотеки, а не мок раннера. Он удерживает 1 906 Б, а vi.fn() на той же машине и том же раннере — 5 783 Б. На Bun и node:test нижней границей по-прежнему остаётся мок раннера.

На метод, Node v24.19.0, Vitest 5.0.0:

удерживаетвремя
ленивая заглушка, ни разу не тронутая2 Б126 нс
построенный при первом чтении1 906 Б487 нс сверху
голый vi.fn()5 783 Б3 380 нс

Метод, которого спека не касается, стоит около двух байт. vi.fn() на его месте удерживает 4 726 Б ещё до первого вызова. Метод, которого спека касается, в 3,0 раза легче и в 5,5 раза быстрее в постройке: 126 + 487 нс против 3 380 нс.

Колонка удержания взята из ячеек «100 методов, нетронуты» и «100, все вызваны» таблицы удерживаемой памяти. Время vi.fn() — 20 000 подмен по 10 методов, медиана семи проходов. Разброс широкий, 3 042–7 947 нс, поэтому он публикуется вместе с медианой.

Цифра заглушки мала, потому что это слот дескриптора, а не объект. Пара аксессоров общая для всех подмен всех классов с таким именем метода. Поэтому на узком классе доминирует постоянная цена самой подмены. С lazySpies: true нетронутая подмена на 10 методов весит 156 Б целиком, на 100 методов — 178 Б.

Цепочки calledWith строятся при первом использовании. Раньше спай строил две такие цепочки — объект плюс карта аргументов каждая, — даже если спека не вызывала calledWith. Если спека его использует, цепочка строится ровно как раньше. resetAutoSpy() выбрасывает цепочки, так что сброшенный спай возвращается к весу свежего.

Память здесь важнее времени. При isolate: false воркер держит всё, что выделили его файлы, до конца прогона. CI-задачу в контейнере убивает куча, а не часы.

Хелперы, общие для всех спаев ​

У каждого построенного метода есть хелперы: calledWith, mustBeCalledWith, failWith, resolveWith, rejectWith, resolveWithPerCall и хуки сброса и очистки. С загруженным vitest-auto-spy/rxjs добавляются семь потоковых хелперов. Раньше это было от восьми до двадцати замыканий на спай.

Теперь это один набор функций на весь прогон. Каждая находит свой спай через this:

  • На движке библиотеки набор лежит на прототипе, который наследует каждый спай. Это минус шесть слотов на построенный метод и ещё семь с /rxjs. Замерено: 48 Б на построенный метод в таблице удерживаемой памяти.
  • Мок раннера (setSpyEngine('runner'), Bun, node:test) не имеет нашего прототипа, поэтому получает копию.
  • Хуки сброса и очистки живут на объекте состояния спая, под меткой спая. Метка и оба хука стоят одно свойство вместо трёх.
  • Ручка потока строится первым потоковым хелпером, а не для каждого спая.

На node:test и Bun доминирует собственный мок раннера. node:test снимает трассу стека в каждый записанный вызов и создаёт мок как Proxy. Все бенчмарки этой страницы идут под Vitest, поэтому цифр по node:test и Bun здесь нет.

Одно изменение для вызывающего кода. Хелпер, снятый со спая, больше не работает:

ts
const { resolveWith } = spy.load;
resolveWith(user); // → бросает ошибку с именем хелпера и двумя рабочими формами

spy.load.resolveWith(user); // работает
const bound = spy.load.resolveWith.bind(spy.load); // работает

Неймспейсы jasmine привязывают свои хелперы, так что там ничего не меняется.

На node:test ​

node:test держит каждый mock.fn() в своём MockTracker уровня модуля всю жизнь процесса. Очищает этот список только reset(), и он заодно всё восстанавливает. trackNodeMocks() из vitest-auto-spy/node переносит спаи библиотеки на собственный трекер.

20 000 спаев класса из 10 методов, 20 тестов, отброшены, две принудительные сборкиУдержано
базовая линия5,5 МБ
без trackNodeMocks()124,5 МБ
с trackNodeMocks()5,9 МБ (в 21 раз меньше)

Node v24.19.0, --expose-gc.

Имена спаев тоже стоят памяти. Мок node:test берёт name у функции, которую оборачивает, поэтому адаптер даёт имя реализации при создании. Переопределение name потом выбрасывает функцию из быстрой карты V8. На 200 000 моков, Node v24.19.0:

Как спай получает имяСверху на мок
переопределение name потом206 Б
имя при создании65 Б

Против других библиотек ​

Этот раздел сравнивает библиотеку с:

  • тремя библиотеками семейства jest-auto-spies: jest-auto-spies@3.0.1, jasmine-auto-spies@8.0.1, @bugsplat/vitest-auto-spies@1.0.0;
  • vitest-mock-extended и @golevelup/ts-vitest — для подмен по типу.

Сравнение и на одну подмену, и на целом наборе тестов. Node v24.19.0, Vitest 4.1.11, Apple M4 Max, macOS 26.6.2. У каждой таблицы своя дата; вся страница перемерена 04.09.2026.

Сначала смотрите на память. Время по часам на целом прогоне — самое шумное число здесь: наибольший разброс между раундами составил 16 % медианы ячейки. Пиковый RSS воспроизводится плотно и разводит ветки в разы. Поэтому время в тексте приводится с одной значащей цифрой: «примерно в 1,5 раза», а не «в 1,53 раза».

ЯчейкаТри раунда по часам
1 000 тестов1,35 / 1,33 / 1,32 с
10 000 тестов10,02 / 10,65 / 9,72 с

Три библиотеки семейства jest-auto-spies, все измерены ​

Все три зависят от @hirez_io/auto-spies-core@3.0.0. Различаются они только фабрикой спаев, которую ему передают:

БиблиотекаФабрика спаев
jest-auto-spies@3.0.1jest.fn()
jasmine-auto-spies@8.0.1jasmine.createSpy()
@bugsplat/vitest-auto-spies@1.0.0vi.fn()

Все три измерены напрямую. На любом сценарии они укладываются в несколько процентов друг от друга — см. строку на 40 методов ниже. Так что утверждение об общем алгоритме измерено, а не выведено.

jest-auto-spies и jasmine-auto-spies работают под минимальным глобальным jest / jasmine поверх vi.fn(). Тогда все остальные ветки создают один и тот же мок. Цена мока у раннера становится общей константой, и числа разделяют собственную работу каждой библиотеки. Они не показывают, что дал бы настоящий проект на Jest или Jasmine, где у мока раннера своя цена.

Ветка этой библиотеки — намеренное исключение. С 4.1 она вообще не вызывает vi.fn() — см. движок спаев. Часть её отрыва в том, что она никогда не платит раннеру за мок. Это видно по ветке hand-written vi.fn() per method: это мок раннера без библиотеки посередине. setSpyEngine('runner') возвращает библиотеку на vi.fn(). На дату таблицы ни одна другая библиотека vi.fn() не обходит.

Микробенчмарк ​

Одна подмена, на вызов. Каждая цифра — медиана p75 семи независимых прогонов, каждый в своём процессе, с удвоенным бюджетом итераций. Канонический прогон — 03.09.2026; полный повтор 04.09.2026 сдвинул абсолютные величины на 2–6 %, а соотношения — на несколько процентов.

(×) после цифры конкурента — его время, делённое на наше. Больше 1× — медленнее этой библиотеки.

Подмена из класса. Эта библиотека, @bugsplat, jest-auto-spies и jasmine-auto-spies читают класс. vi.fn() — тот же класс, собранный руками из обычных моков.

Сценарийvitest-auto-spy@bugsplatjest-auto-spiesjasmine-auto-spiesручной vi.fn()n
малый проект — 6 методов, вызван 11,42 мкс8,33 мкс (5,88×)8,38 мкс (5,91×)8,50 мкс (6,00×)6,83 мкс (4,82×)90,000
средний проект — 14 методов, вызвано 22,67 мкс19,37 мкс (7,27×)19,17 мкс (7,19×)19,71 мкс (7,39×)15,92 мкс (5,97×)70,000
крупный проект — 45 методов, вызвано 25,79 мкс64,04 мкс (11,06×)65,67 мкс (11,34×)66,25 мкс (11,44×)52,79 мкс (9,11×)36,000
худший случай — 14 методов, вызваны все 148,17 мкс21,87 мкс (2,68×)21,79 мкс (2,67×)22,25 мкс (2,72×)17,92 мкс (2,19×)30,000
худший случай — 45 методов, вызваны все 4526,12 мкс73,42 мкс (2,81×)71,83 мкс (2,75×)73,04 мкс (2,80×)62,04 мкс (2,37×)9,000
настроить возврат + 3 вызова2,21 мкс19,63 мкс (8,88×)19,33 мкс (8,75×)не измерялось16,38 мкс (7,41×)102,000

Ширины и числа вызовов взяты из четырёх приватных Angular-проектов: около 2 700 файлов спек и 2 742 подмены из класса.

ПерцентильМетодов в подменяемом сервисеМетодов, которых касается спека
медиана5–81
p7512–16—
p9032–442

Как выбраны размеры. jasmine-auto-spies нет в последней строке намеренно. Его вызов настройки — spy.method.and.calledWith(x).returnValue(y), это другая операция, чем mockReturnValue.

Подмена из типа. Все три ветки на Proxy и делают равную работу.

Затронуто членовvitest-auto-spyvitest-mock-extended@golevelup/ts-vitestn
21,00 мкс2,79 мкс (2,79×)4,92 мкс (4,92×)590,000
104,67 мкс13,83 мкс (2,96×)24,92 мкс (5,34×)60,000
4018,92 мкс56,79 мкс (3,00×)103,37 мкс (5,46×)16,000
настроить + 3 вызова0,71 мкс1,58 мкс (2,24×)1,58 мкс (2,24×)584,000

Глубокая подмена и calledWith:

Сценарийnvitest-auto-spyvitest-mock-extended@golevelup@bugsplat
глубокая подмена, 3 уровня, вызван лист232,0002,29 мкс5,46 мкс (2,38×)6,00 мкс (2,62×)—
диспетчеризация calledWith, 2 настроено + 1 промах2,304,0000,17 мкс0,54 мкс (3,25×)—0,96 мкс (5,74×)

Строка calledWith сдвинулась в 4.1 не из-за движка. Конфигурация с одним примитивным аргументом раньше на каждом вызове превращалась в строковый ключ. Map с ключом по самому значению делает тот же поиск без выделений памяти. Две формы остаются на строковом пути и сохраняют прежний ответ. Символ превращается в ключ по своему описанию. -0 даёт ключ отдельно от 0, хотя по SameValueZero это один ключ.

Все таблицы выше — в пользу этой библиотеки, включая две строки худшего случая. До 4.1 vitest-mock-extended выигрывал каждую строку по типам и строку с глубокой подменой. Ручной vi.fn() выигрывал обе строки, где вызваны все методы. Это изменил движок спаев.

Эти множители не переносятся на целый прогон

На одной подмене из 45 методов библиотека примерно в 9 раз быстрее ручного vi.fn(). На настоящем наборе тестов этого преимущества нет — см. Масштаб набора тестов. Построение подмены — около 1 % цены теста. Множитель микробенчмарка описывает подмену, а не прогон.

Измеренный предел разрешения ​

Прочитайте это, прежде чем цитировать любое число из этих таблиц.

Один прогон этого бенчмарка гуляет на несколько процентов между запусками неизменного кода. Это состояние машины, а не ошибка выборки: вчетверо больше итераций снижает rme, но не дрейф. Поэтому страница публикует медиану семи прогонов. Колонка ± у каждой строки говорит, насколько эта медиана может ошибаться.

По 47 строкам прогона от 04.09.2026Ошибка медианы
медиана±0,9 %
худшая (строка диспетчеризации calledWith, самая быстрая)±6,3 %

Для одного локального прогона держите более грубое правило: разница меньше примерно 20 % между двумя ветками — не повод её цитировать.

Самый узкий разрыв на этой странице — 2,19× (вызваны все 14 методов), самый широкий — 11,4×. Оба на порядок больше ошибки. В таблице, которую заменила эта, одна строка лежала внутри шума, и её пришлось подать как паритет.

Почему цифрам можно доверять:

  • Колонка ± ограничивает медиану, а не среднее. rme ограничивает среднее, а в нём доминируют хвосты GC. Большой rme рядом со стабильным p75 значит шумный хвост, а не ненадёжное число.
  • Все ветки блока прогоняют одинаковое число итераций, оно напечатано как n. По умолчанию tinybench даёт каждой ветке одинаковое время, и быстрая ветка делает больше итераций. GC растёт с числом созданных объектов, и быстрая ветка платила бы за собственную скорость.
  • Публикуется p75: его пауза GC не двигает. Операции в секунду, где они есть, — это 1/p75, а не hz раннера, основанный на среднем.

Удерживаемая память на подмену ​

Сколько байт держит живой каждая подмена. Node v24.19.0, Vitest 5.0.0, 27.09.2026.

ПараметрЗначение
подмен одновременно500
повторы5 на ячейку (медиана), 4 принудительных прохода GC; весь бенчмарк прогнан трижды, медиана трёх
числоприрост кучи ÷ 500 = байты на подмену; в скобках ÷ методы = байты на метод
между веткамиреестр моков подчищается (худший остаток 0,2 %)
разброскаждая ячейка в пределах 0,2 % от медианы по трём прогонам; худший разброс повторов ±33 % — на самой маленькой ячейке (нетронутая подмена по умолчанию на 10 методов)

Без подчистки каждая ветка тащила бы за собой всё, что выделили предыдущие.

Построено из класса:

Ветка10 методов, нетронуты10, все вызваны100 методов, нетронуты100, все вызваны
vitest-auto-spy по умолчанию ('proxy' на этих ширинах)258 Б (26 Б/метод)11 367 Б (1 137 Б/метод)259 Б (3 Б/метод)110 837 Б (1 108 Б/метод)
vitest-auto-spy lazySpies: true156 Б (16 Б/метод)11 987 Б (1 199 Б/метод)178 Б (2 Б/метод)116 082 Б (1 161 Б/метод)
vitest-auto-spy lazySpies: false4 454 Б (445 Б/метод)11 297 Б (1 130 Б/метод)42 655 Б (427 Б/метод)110 730 Б (1 107 Б/метод)
jest-auto-spies67 246 Б (6 725 Б/метод)77 802 Б (7 780 Б/метод)674 658 Б (6 747 Б/метод)779 711 Б (7 797 Б/метод)
jasmine-auto-spies69 723 Б (6 972 Б/метод)80 289 Б (8 029 Б/метод)699 474 Б (6 995 Б/метод)804 525 Б (8 045 Б/метод)
@bugsplat/vitest-auto-spies67 234 Б (6 723 Б/метод)77 807 Б (7 781 Б/метод)674 671 Б (6 747 Б/метод)779 723 Б (7 797 Б/метод)
ручной vi.fn()47 258 Б (4 726 Б/метод)57 825 Б (5 783 Б/метод)476 862 Б (4 769 Б/метод)581 883 Б (5 819 Б/метод)

Изменения от 29.09.2026 в таблицу ещё не вошли

С 29.09.2026 вызванный спай пишет четыре массива вместо шести, пока его состояние не прочитали, а results до этого хранит голые значения. В этом бенчмарке подмены «все вызваны» похудели на 19–21 % во всех ветках. Умолчание на 10 методах — 11 369 → 9 037 Б, на 100 методах — 110 848 → 87 638 Б. Нетронутые подмены не сдвинулись. Подробности — в разделе Подробнее.

Построено из типа. Нетронутая подмена — один и тот же Proxy при любой ширине:

Ветканетронутовызвано 10 членоввызвано 100 членов
vitest-auto-spy createAutoMock369 Б11 744 Б113 566 Б
vitest-mock-extended mock353 Б60 138 Б602 182 Б
@golevelup/ts-vitest createMock496 Б116 462 Б1 158 269 Б

Что это показывает:

  1. От 8 методов умолчание даёт более лёгкую подмену, как только тест её коснулся. Нетронутой lazySpies: true меньше, потому что её заглушка — слот дескриптора. После первого чтения она платит за словарь шириной с класс, а прокси — нет. См. таблицу по ширине.
  2. Вызванный метод удерживает около 1 110 Б против 5 819 Б у мока раннера. Жадно построенный, но ни разу не вызванный (lazySpies: false, нетронутые) — 427 Б. Спай, который не вызывали, не выделяет ни одного из массивов, которые vi.fn() выделяет заранее.
  3. jest-auto-spies и @bugsplat совпадают в пределах 0,01 % по удерживаемым байтам. Это подтверждает общее ядро на метрике без шума замеров времени.
  4. Ядро jest-auto-spies стоит около 2,0 кБ на метод сверх голого vi.fn() ещё до вызовов: 6 725 против 4 726 Б. jasmine-auto-spies ещё примерно на 4 % выше.
  5. Полностью построенные, четыре ветки меряют @vitest/spy. Ручной контроль удерживает 5 783 Б на мок после одного вызова, семейство jest-auto-spies — на 35–39 % выше. Спай этой библиотеки собственный, поэтому он в 5,1 раза ниже этого пола, около 1 120 Б.

Где библиотека проигрывает по памяти:

  • Нетронутый createAutoMock<T>() удерживает 369 Б против 353 Б у vitest-mock-extended (на 4,5 % больше) и 496 Б у @golevelup. С первого вызванного члена он в 5,1 раза легче vitest-mock-extended и в 9,9 раза легче @golevelup.
  • lazySpies: false удерживает на 0,1–0,6 % меньше умолчания, когда все методы всё равно вызваны. Это 11 297 против 11 367 Б на 10 методах и 110 730 против 110 837 Б на 100.

Мерился только heapUsed, память вне кучи — нет. Реестр-Set из @vitest/spy учитывается и списывается на все ветки одинаково. Абсолютные байты специфичны для V8. Соотношения должны переноситься, но это не проверялось.

Масштаб набора тестов ​

Целый сгенерированный набор тестов на каждую ветку. Все ветки импортируют собранный пакет, как потребитель. Ветки идут вперемешку по кругу. Покрытие включено.

Класс на 20 методов, isolate: true, 3 раунда на ячейку после отброшенного прогрева, медиана трёх. 04.09.2026, сборка 4.1, Node v24.19.0, Vitest 4.1.11, Apple M4 Max:

Тестовvitest-auto-spy@bugsplatвручную
1 0001,33 с · 1116 МБ2,00 с (1,50×) · 1237 МБ1,31 с (0,99×) · 1085 МБ
3 0002,86 с · 1167 МБ4,61 с (1,61×) · 1349 МБ2,85 с (1,00×) · 1136 МБ
10 00010,02 с · 1295 МБ15,45 с (1,54×) · 1509 МБ9,25 с (0,92×) · 1283 МБ
По девяти раундамДиапазонМедиана
ручной vi.fn()0,84×–1,00×0,97×
@bugsplat1,46×–1,62×1,54×

Наибольший разброс между раундами — 16 % медианы ячейки. Смотрите на соотношения, а не на секунды. Ручной vi.fn() до движка спаев стоял на 0,76× при 10 000 тестах, сейчас — на 0,92×.

Класс на 100 методов, isolate: true, 10 000 тестов, 5 раундов после отброшенного прогрева:

ВеткаМедиана по часамМедиана пикового RSSПо 5 раундам
vitest-auto-spy (наш)9,42 с1294 МБ—
наш, lazySpies: 'proxy'9,41 с1276 МБмедиана ровно 1,00× (1,00 / 0,98 / 1,00 / 1,02 / 0,98) — не результат
@bugsplat15,85 с1518 МБмедиана отношения 1,68, 5 из 5 выше 1,0× (1,66–1,73×)
ручной vi.fn()9,21 с1280 МБмедиана 0,98×, 5 из 5 ниже 1,0× (0,97–1,00×)

Что держится на 1 000, 3 000 и 10 000 тестах и на обеих ширинах класса:

  • Библиотека примерно в 1,5–1,7 раза быстрее семейства jest-auto-spies, измеренного через @bugsplat. Отношения по раундам — от 1,46× до 1,73×, так что не приводите это с двумя значащими цифрами.
  • Ручные подмены на vi.fn() при isolate: true всё ещё чуть дешевле: около 3 % по медиане. Раньше разница была 10–15 %. Два из девяти раундов на 20 методах вышли вровень, а хвост доходит до 0,84×. Поэтому точнее «нескольких процентов» ничего не утверждается.
  • lazySpies: 'proxy' при isolate: true не даёт ничего измеримого. Его медиана по пяти раундам — ровно 1,00×.

Построение подмены — около 1 % цены теста. Десятикратный выигрыш в микробенчмарке превращается здесь в несколько процентов. Оставшийся разрыв — то, что библиотека делает помимо построения подмен.

Память при isolate: false ​

Это самый крупный и самый чистый эффект на странице. Он воспроизводится плотно там, где время по часам не воспроизводится.

Класс на 100 методов, isolate: false, 10 000 тестов, 7 раундов, пиковый RSS:

ВеткаМедиана пикового RSSДиапазонПо часам
ручной vi.fn()6366 МБ6249–68012,63 с
vitest-auto-spy по умолчанию2103 МБ1995–21672,17 с
vitest-auto-spy, lazySpies: 'proxy'1851 МБ1835–19602,38 с

Ручной vi.fn() даёт пик в 3,0 раза выше умолчания библиотеки и в 3,4 раза выше режима 'proxy'. Отношение прокси к умолчанию было ниже 1,0× в 7 раундах из 7: 0,854, 0,879, 0,889, 0,893, 0,894, 0,922, 0,932. Колонка по часам меняет знак от раунда к раунду, так что это не результат. Здесь читайте только RSS.

Почему это видно только при isolate: false:

  • При isolate: true подмены теста освобождаются, когда разбирается его окружение. По файлу ничего не копится.
  • При isolate: false каждая подмена, когда-либо созданная файлом, остаётся достижимой всю жизнь воркера. Цена одной подмены умножается на все подмены файла.

Так разница на подмену превращается в разрыв в гигабайты. Она может решить, дойдёт ли прогон до конца или его убьёт OOM.

Та же нагрузка при isolate: false идёт ещё и примерно в 4 раза быстрее (2,17 с), чем при isolate: true (9,42 с). Это больше любого выбора библиотеки, измеренного здесь.

Эти таблицы RSS сняты до прокси от 27.09.2026 и меряют старый вариант.

Как выбрать настройку для своего набора тестов ​

  1. Сначала isolate: false. Он один даёт примерно 4× — больше любой библиотеки или флага, измеренных здесь. Ограничением тогда становится память: всё, что выделил файл, живёт до конца прогона. См. Одно окружение вместо одного на файл.
  2. Не задавайте lazySpies. Ширина сама выбирает более лёгкую подмену: обычный объект до 8 методов, прокси от 8. lazySpies: true — только для спеки, которой нужен обычный объект или которая читает один член в горячем цикле.
  3. Не ждите выигрыша по скорости против ручного vi.fn() на целом прогоне. При isolate: true они идут вровень на 1 000 и 3 000 тестах. На 10 000 библиотека отстаёт примерно на 8 %. Доводы за библиотеку — память, ленивое умолчание, кэш обхода прототипа, calledWith, подмены по типу и глубокие подмены.
  4. Против семейства jest-auto-spies выигрыш держится везде, где проверялся: примерно в 1,5 раза быстрее в каждом раунде, на любом размере набора и любой ширине класса.

Что осталось необъяснённым ​

Около 98 % отставания @bugsplat на целом прогоне не объясняется разницей на одну подмену:

При 10 000 тестовПо часам
измеренное отставание @bugsplatоколо 5,4 с
разрыв на подмену (около 10 мкс) × 10 000 тестовоколо 0,1 с

Его пиковый RSS тоже выше: 1509 МБ против 1295 МБ при 10 000 тестов на классе из 20 методов. Больше работы GC согласуется с остатком разрыва. Эта связь не измерялась и не утверждается.

Как это воспроизвести ​

bash
npm ci
npm ci --prefix bench
npm run bench:vs                 # микробенчмарк, около минуты
npm run bench:suite --help       # стенд целого прогона описывает себя сам; десятки минут на 10 000 тестов
КомандаЧто меряет
npm run benchЧто измерено: библиотеку против неё самой
npm run bench:vsмикробенчмарк, один прогон, около минуты
npm run bench:vs:preciseопубликованный микробенчмарк: семь прогонов, около одиннадцати минут
npm run bench:memoryУдерживаемая память на подмену
npm run bench:suiteМасштаб набора тестов и Память при isolate: false
npm run bench:angular -- --repeat 5Рендер поддерева детей
npm run bench:angular-builderVitest 5 под Angular-билдером

Установка бенча намеренно выставляет legacy-peer-deps=true. Конкуренты объявляют vitest peer-зависимостью. Вторая копия рядом с корневой даёт два реестра моков. Подчистка бенча тогда дотягивается только до одного, и прогон умирает от нехватки памяти.

README бенчмарка Angular-рендера описывает методику и два способа, которыми он может молча ничего не измерить.

Размер бандла ​

Бейдж говорит 30,3 кБ min+gzip, и это вся корневая точка входа, собранная вместе. Каждый подпуть — отдельная точка входа, и проект платит только за те, что импортирует:

Импортированоmin+gzip
. — корневая точка входа, то, что меряет бейдж30,3 кБ
vitest-auto-spy/angular сам по себе38,7 кБ
vitest-auto-spy/angular/doubles21,4 кБ
vitest-auto-spy/angular/diagnostics7,5 кБ
vitest-auto-spy/angular/matchers3,2 кБ
vitest-auto-spy/react / /vue / /svelte30,3 кБ
vitest-auto-spy/setup31,1 кБ
vitest-auto-spy/node29,5 кБ
vitest-auto-spy/dom-stubs12,1 кБ
vitest-auto-spy/rxjs2,3 кБ
vitest-auto-spy/angular-router11,2 кБ
vitest-auto-spy/angular-http4,3 кБ
vitest-auto-spy/signal-forms1,3 кБ
vitest-auto-spy/zone1,2 кБ

В продакшн-бандл ничего из этого не попадает: пакет — devDependency.

  • Три строки /angular/* — это разобранный /angular, а не добавка. До разбиения проект платил 30,4 кБ в каждом Angular-файле спек. После — 26,3 кБ там и остальные строки один раз, в setup-файле. Этот файл вызывает enableAngularDiagnostics, registerSignalMatchers или подмены диалога Material.
  • /angular берёт ядро из корневой точки входа. До 26.09.2026 каждая несла свою копию ядра в ~130 кБ, которая разбиралась дважды на файл спек. Корень плюс /angular теперь весят 38,8 кБ вместо 55,8 кБ и импортируются за 5,8 мс вместо 7,3 мс. /angular, импортированный отдельно, тянет корень за собой: 33,6 кБ вместо 30,6 кБ.
  • Строки фреймворков равны строке ядра. /react, /vue и /svelte — реэкспорты корня на 1,1–1,3 кБ (/vue добавляет provideAutoSpy). Процесс, который грузит корень рядом с одной из них, грузит ядро один раз.

Каждая цифра — зафиксированная базовая линия размера, которую читает и бейдж. Проверки репозитория падают, как только строка перестаёт с ней совпадать. Рост больше чем на 3 % относительно последнего релиза валит проверку размера, пока в changelog не написано, за что эти байты. Отдельная проверка сравнивает все двадцать шесть точек входа с базовой линией. Точка входа, тихо получившая вторую копию ядра, её не проходит.

Как каждая строка пришла к нынешнему размеру — в разделе Подробнее.

Что лежит в скачиваемом пакете ​

Публикуемый tarball — 1 019 кБ в 114 файлах (npm pack --dry-run, 26.09.2026). В распакованном виде — 3,5 МБ. В нём только changelog ветки 5.x; полная история — в репозитории.

Где лежат байты:

ФайлРазмерГрузит ли прогон спек?
CLI vitest-auto-spy252 кБнет: это инструмент командной строки
плагин ESLint (.cjs)180 кБнет: его грузит ESLint
корневая точка входа170 кБвсегда
точка входа /angular59 кБна Angular; ядро она импортирует из корня
README.md417 кБнет
AGENTS.md с agent-docs/389 кБнет: справочник для кодовых агентов, читающих установленную копию

/setup и /node собраны одним файлом каждый. Раньше каждый дотягивался через загрузчик до двенадцати– четырнадцати модулей. На файл спеки, Node 24, медиана 25 прогонов:

ИмпортРаньшеТеперь
/setup5,6 мс3,1 мс
корень + /setup7,0 мс4,9 мс
/react5,5 мс3,1 мс
/node3,4 мс1,7 мс
корень + /angular + /setup7,7 мс5,9 мс

Та же Angular-форма стала 9,9 → 8,5 мс, когда /angular начал брать ядро из корня. Цена — +108 кБ в tarball и около 442 кБ на диске, один раз при установке. Проверка холодного импорта сторожит эти времена по зафиксированной базовой линии. Она считает и модули, и байты.

Двух из этих миллисекунд загрузка не касалась вовсе. Корневая точка входа и /setup при импорте форматировали стек, чтобы в сообщениях о месте вызова отличать фрейм вызывающего кода от фрейма библиотеки. Под Vitest первый отформатированный стек в файле спеки стоит почти миллисекунду. Теперь эта проба выполняется, только когда строгая подмена или подмена конструктора впервые сообщает место вызова. На файл спеки под Vitest 5, медиана 93 файлов по 31 прогону:

ИмпортРаньшеТеперь
корень + /setup8,6 мс6,8 мс
только корень5,4 мс4,0 мс

Корневая точка входа и /angular к тому же доходят до загрузчика малым числом модулей. Загрузчик платит за модуль, а не за байт. Замер 04.09.2026:

Точка входаМодулей, до → послеЭкономия на файл спеки
корень8 → 2около 0,8 мс
/angular10 → 2около 1,0 мс

Это стоило около 120 кБ в tarball. Состояние на весь процесс (реестр мок-адаптера, поддержка observable и jasmine, идентичность пакета, таймаут эмиссий) лежит в одном общем модуле, так что реестр ровно один. Туда попадает только состояние. Даже перенос туда одного таймаута по умолчанию для хелпера эмиссий окупился. Он снял 10,0 кБ с графа модулей семи точек входа. С восьми, которые экспортируют хелпер эмиссий, он снял 2,5 кБ; /setup min+gzip тогда стал 12 092 → 12 072 Б. Сделать каждую точку входа самостоятельной стоило бы +429 кБ и раздвоило бы реестры.

Внутри общих чанков ни один модуль не эмитится дважды. Ни одного байта peer-зависимости (vitest, @angular/*, rxjs) нигде не вшито. /signal-forms раньше тихо нарушал это правило: его граф мерился в 274 кБ, почти всё — @angular/forms. Собственный граф этой точки входа — 5,9 кБ.

CommonJS есть только у vitest-auto-spy/node и vitest-auto-spy/eslint-plugin. Всё остальное — только ESM. Почему — в разделе Подробнее.

DOM-стабы вне корневой точки входа ​

С 4.0 DOM-стабы лежат в vitest-auto-spy/dom-stubs. Реэкспорт в ESM жадный, а тестовый файл ни один раннер не встряхивает (tree-shaking), поэтому раньше их вычисляла каждая спека. Замер при переезде:

Файл спекиИзменение
не импортирует /dom-stubs−0,159 мс
импортирует /dom-stubs+0,155 мс
собранный код−20,3 кБ

Типы без rxjs ​

С 4.0 ни одна декларация, кроме vitest-auto-spy/rxjs и vitest-auto-spy/observer-spy, не называет rxjs, и сборка падает, если это случится. Замер на потребителе, который использует из библиотеки только createSpyFromClass:

3.184.0
файлов в TypeScript-программе потребителя303114
из них .d.ts из rxjs1890
TS2307 при skipLibCheck: false без установленного rxjsестьнет

Импорт только типов разрешается так же, как импорт значения, так что одного import type не хватило бы.

Цена линта у ESLint-плагина ​

Плагин тратит 56 мс на 173 файла спек и 68 мс на одну спеку в 1,7 МБ. Раньше большую часть цены давали три правила, которые задавали вопрос про весь файл на каждом узле. Теперь они спрашивают раз на файл или на блок:

ПравилоДоПослеНа чём
no-inject-before-override, no-overridden-provider1 028 мс0,13 мсспека в 249 кБ
prefer-render-shallow2 528 мс12,8 мсспека в 1,7 МБ
no-redundant-smoke-test46 мс9,7 мс173 спеки
весь плагин93 мс56 мс173 спеки
весь плагин3 263 мс68 мсспека в 1,7 МБ

Ни одно сообщение и ни одна позиция не сдвинулись: правила решают то же самое, только реже.

Почему это не написано на Rust ​

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

Что нативная версия действительно могла бы взять на себя ​

На каждом вызове спай делает две вещи. Он входит в записывающий код и сохраняет аргументы для mock.calls. Кандидат только второе, и оно сопротивляется.

Аргументы — живые значения JavaScript: экземпляры компонентов, Subject, узлы DOM. toHaveBeenCalledWith сравнивает их и по идентичности, и по структуре. Удерживать такое из Rust — значит создавать и освобождать ссылку N-API на каждый аргумент на каждом вызове. Сериализация была бы дешевле, но уничтожила бы идентичность, от которой зависит каждый матчер. Массив JavaScript сохраняет семантику и не пересекает границу.

Всё вокруг этого остаётся на JavaScript:

  • spy.mock.calls обязан быть обычным массивом: матчеры Vitest обходят его синхронно.
  • mockReturnValue обязан вернуть значение JavaScript.
  • На Bun и node:test объект мока приходит из mock() / t.mock.fn() и принадлежит раннеру.

И бюджет невелик. Строка unconfigured dispatch в разделе Что измерено — 0,25 мкс на три вызова: около 83 нс на каждый, вместе с mockClear. Это вся цена на вызов. С 4.1 на Vitest вся она — код этой библиотеки: движок спаев.

И построение спаев не видно в CI-задаче ​

Возьмите Angular-проект на 10 500 тестов, где каждый тест строит четыре подмены. При 2,67 мкс на подмену из 14 методов это около 0,11 с процессорного времени на весь прогон, ещё до деления между воркерами. Даже убрав построение спаев целиком, CI-задачу не сдвинуть. Её минуты уходят на checkout, установку, сборку и покрытие.

С покрытием соотношение только хуже. Покрытие обычно ограничено исходниками приложения, поэтому библиотека не инструментируется. Замедление от покрытия ложится на ваш код и минует спаи.

Чего стоила бы нативная сборка ​

Против экономии, неотличимой от шума:

  • Шесть и больше платформенных бинарников в devDependencies каждого потребителя и падения при установке. Тестовая библиотека не должна ломать инструмент, которым это диагностируют.
  • Раннеры, которые поддерживает пакет. bun:test, node:test и browser mode здесь первоклассные. Нативный аддон грузится в них по-разному, а в браузере не грузится вовсе. Запасной вариант на WASM был бы медленнее JavaScript, который он заменил.
  • Песочницы: StackBlitz, WebContainers, голый CI-образ без подходящей пресборки.

Нативные выигрыши в тестовом стеке на JavaScript реальны, и их уже забрали другие: бандлер, трансформер, линтер. Эти инструменты перемалывают всё дерево исходников за прогон. Фабрика спаев возвращает объект — по микросекунде-другой за раз. Ей правильно быть маленькой, ленивой и скучной.

Подробнее ​

История и устройство за числами выше. Чтобы выбрать настройку, это читать не нужно.

Как движок добирается до vi.clearAllMocks() ​

Две части движка пришлось построить отдельно.

  • vi.clearAllMocks() должен добраться до спая, которого нет ни в одном реестре. Vitest чистит моки, обходя Set внутри @vitest/spy. В него пишут только vi.fn() и vi.spyOn(), и API, чтобы туда добавить, нет. Поэтому адаптер регистрирует один собственный vi.fn(), чей mockClear подметает спаи библиотеки. Обход добирается до этого мока, а он — до всех наших. clearMocks: true и mockReset: true в конфигурации продолжают работать: Vitest применяет их тем же путём. На этом часовом стоит метка, которую пропускает чистильщик реестра. Без неё подметание молча перестало бы работать.
  • Подметание не должно ничего обходить. Оно увеличивает счётчик. Каждый спай сверяет с ним свою отметку, прежде чем записать или отдать данные, и опустошается, если отстал. Очистка всех спаев — один инкремент, и он ничего не держит живым. Объект состояния mock отдаёт массивы через аксессоры. Объект состояния, который держит спека, тоже отвечает опустошёнными массивами — как у самого раннера.

Почему аксессоры общие ​

Создание ленивой подмены по-прежнему стоит одного defineProperty на заглушку-аксессор. Теперь это почти вся её цена. Набор хелперов переехал на общий прототип; заглушки так не могут.

Одну пару get / set на имя метода однажды уже пробовали и отвергли. Подмена строилась быстрее, но материализовалась заметно медленнее. V8 держит объекты, все аксессоры которых пришли из одних дескрипторов, на общей быстрой карте. Превращение аксессора в свойство-данные переписывает всю карту. Объект со свежими замыканиями уже в словарном режиме и платит лишь обновление хеша.

Построить все методы одной подменыДо общих аксессоровНаивно общиеОбщие + словарь на первом чтении
100 методов45 мкс223 мкс48,7 мкс
300 методов195 мкс1 733 мкс144 мкс

Решение — войти в словарный режим намеренно, один раз, при первой материализации подмены. Нетронутая подмена туда не доходит. На JSC (Bun) проблемы общей карты нет. Переключение там ничего не даёт и стоит около 50 нс на построенный метод. Его всё равно платят, потому что выигрыш по памяти есть на обоих движках. У Object.create(prototype, descriptors) та же проблема с быстрым режимом.

Единственное несделанное сокращение — положить аксессоры на общий прототип. Тогда Object.keys(spy) и { ...spy } перестали бы перечислять методы, которых никто не трогал. Спека могла бы это заметить.

Как снижалась удерживаемая память ​

Таблица в разделе Удерживаемая память на подмену — издание от 27.09.2026. Против издания от 26.09.2026 сдвинулись только строки этой библиотеки; раннер и семейство jest-auto-spies — в пределах нескольких байт.

ДатаИзменениеЭффект
17.09.2026ленивая заглушка стала одной общей парой аксессоров на имя метода, а не парой замыканий на подменунетронутая подмена lazySpies: true: 25 593 → 215 Б (100 методов), 70 165 → 284 Б (300 методов); подмена на 300 методов строится на ~28 % дольше, построение всех её методов на ~26 % дешевле
26.09.2026массивы состояния вызовов получают запас на четыре вызова, а не семнадцать слотов на первом pushметод, вызванный 1–4 раза, держит на 30–38 % меньше; при большем числе вызовов +8 Б. На вызванный метод: 1 947 → 1 332 Б (10 методов), 1 905 → 1 290 Б (100)
27.09.2026построенный спай метода лишился пустой очереди Once, контекста замыкания, своего объекта состояния и контекста диспетчеризации на шесть слотов: 128 Б«все вызваны», lazySpies: true, 100 методов: 1 290 → 1 161 Б на метод (−10 %)
27.09.2026мешок accessorSpies собран из объектов, рассчитанных на ноль свойств (V8 резервирует четыре слота в каждом {})мешок 152 → 88 Б; нетронутая подмена lazySpies: true на 100 методов 243 → 178 Б
27.09.2026createAutoMock и узлы mockDeep заводят коллекции аксессоров и удалённых ключей при первом defineProperty или deleteнетронутый авто-мок 705 → 369 Б (1 184 Б изданием раньше)
27.09.2026обработчик Proxy авто-мока — один на прогон, а не объект и семь замыканий-ловушек на подменучасть тех же 369 Б
27.09.2026'proxy': один обработчик ловушек на подмену и одно множество имён методов на класснетронутая прокси-подмена на 100 методов 4 097 → 259 Б
27.09.2026классы от 8 методов по умолчанию получают 'proxy'строка умолчания теперь 'proxy'; прежнее умолчание — строка lazySpies: true
29.09.2026вызванный спай пишет четыре массива, пока их не прочитали; пятый вызов копирует в восемь слотов, девятый — в семнадцатьспай, вызванный один раз, 890 → 696 Б; пять вызовов 1 503 → 1 208 Б; восемь 1 793 → 1 495 Б; с девяти без изменений
29.09.2026mock.results хранит возвращённое значение, пока его не прочитали; записи { type, value } строятся при первом чтениизаписанный вызов 142 → 102 Б; подмена «все вызваны» легче ещё на 4 % (умолчание, 100 методов: 91 638 → 87 638 Б)

V8 резервирует семнадцать слотов при первом push в пустой массив: около 900 Б пустых слотов на метод, вызванный один раз. Это и убрало изменение от 26.09.2026.

Ошибки бенчмарка, которые исправлены ​

В первой версии сравнения с другими библиотеками было две ошибки:

  • Первый прогон импортировал библиотеку из исходников, вне node_modules. @vitest/coverage-v8 пропускает любой URL с /node_modules/ ещё до чтения пользовательской конфигурации. Поэтому инструментировались и трансформировались в каждом воркере только исходники этой библиотеки, а собранный код конкурентов — нет. Теперь каждая ветка меряет собранный пакет, и штраф, падавший только на эту библиотеку, ушёл.
  • Сначала ветки шли блоками: все повторы одной, потом следующей. Дрейф машины доставался той ветке, что шла позже. Теперь ветки идут вперемешку по кругу.

Таблица в разделе Какая версия Node снята на отдельном стенде, а не через npm run bench. Бенч Vitest давал между прогонами одного сценария цифры, отличающиеся на порядки. @vitest/spy держит каждый мок в сильном Set уровня модуля, так что каждый сценарий упирался в кучу, оставленную предыдущим. Теперь бенч после каждого сценария вызывает pruneMockRegistry() (из vitest-auto-spy/setup). Сравнить два рантайма он всё равно не может: для этого нужен отдельный процесс на версию, с раннером вне замера.

Node 18 ​

Node 18 был прежней нижней границей. vitest run там сразу завершается, а Vitest 4 на Node 18 не запускается вообще (см. Установку). Импорт библиотеки при этом работает: 9,4 мс холодного импорта, 45,7 МБ RSS после, 13,7 мс на старт. Это быстрее, чем на 20 и 22, так что порядок не монотонен по номеру версии. Поддержка Node 18 и 20 закончилась.

Как строки размера пришли к нынешним числам ​

Ранняя редакция приводила 15,1, 18,7 и 14,5 кБ для первых трёх строк. Это было до реестра умолчаний, отчёта о патче вне хука и проверки перекрытых провайдеров. Все числа ниже — дерево, измеренное 20.09.2026, от старых изменений к новым.

ИзменениеТочка входаПрирост
stubWebStorage/dom-stubs+256 Б
createComponentStub/angular+1,02 кБ
запись о захвате, которая сохраняет хелперы на спае, когда две копии пакета делят процессстроки ядра+0,12…0,27 кБ
общие настройки с globalThis, уровень для ошибок конфигурации, ограниченный строгий отчёт, исключение хуков жизненного цикластроки ядра+0,34…0,43 кБ
запись для takeStrictViolations, защита от проглоченных строгих бросковстроки ядра+0,16…0,41 кБ
отчёт о чтениях: следилки за геттерами и потоками, учёт unconfiguredReads, selfReturningстроки ядра; /setup+0,37…0,41 кБ; +0,61 кБ
регистрация токеновстроки ядра+18 Б
отчёт о подмене диспетчеризации (хук и текст — в общем движке спаев)каждая точка входа времени выполнения; /setup+0,28…0,44 кБ, корень 17,19 → 17,50 кБ; +85 Б
фикс формы renderShallow/angular, /bun, /nodeсверх предыдущего
setInputs, подмены window / document и диалога Material, подмена ResourceRef, счётчики пересчётов/angular+3,26 кБ против v5.8.0 — самый большой сдвиг этой точки входа
из них: члены, за которые подмена window отвечает вручную, и проверка ссылки диалога по компоненту/angular0,35 кБ
подмена Router на настоящих DefaultUrlSerializer, createUrlTreeFromSnapshot и RouterState/angular-router+4,32 кБ (втрое)
навигация в полёте: currentNavigation(), setCurrentNavigation(), учёт событий/angular-router+390 Б (6411 → 6801 Б, +6,1 %)
отчёт о подмене диспетчеризации/angular-router+338 Б (6801 → 7139 Б, +5,0 %)
подмена Location: SpyLocation из Angular, collectRouterEvents(), поля resolve / title/angular-router8492 → 9048 Б (+6,5 %)
правила двух предыдущих релизов/eslint-plugin+1,77 кБ
no-redundant-smoke-test/eslint-plugin+1122 Б (31295 → 32417 Б, +3,6 %)
два фикса: субъект, который взвешивает правило, и подмены, освобождённые от prefer-create-spy-from-class/eslint-plugin+371 Б (32417 → 32788 Б, +1,1 %)
prefer-provide-auto-spy, prefer-set-inputs/eslint-plugin+2068 Б (до 34 856 Б, +6,3 %)
createForm и один матчер поверх form() из Angular/signal-forms (новая)1,36 кБ
после v5.15.1: проверка, отказывающая в AOT-скоупе, который renderShallow не может пересобрать (в основном текст ошибки)/angular; /bun-angular+452 Б; +508 Б (21 429 → 21 937 Б, +2,4 %)
narrow.definedостальные строки времени выполнения+53…112 Б
сообщение для объектного спреда, исключение фабрик заглушек/eslint-plugin+501 Б
точки входа собираются самостоятельными бандлами, а не оболочкой вокруг общих чанков/setup; строки фреймворков18,3 → 17,1 кБ; 17,9 → 17,6 кБ
ремонтный раунд: структурный матчер для вложенных матчеров, поиск symbol-ключей и статических членов, модули уборки и проверок в /setup, канарейка внутренних структур Angularсамостоятельные строки; чанковые+2,4…3,6 кБ (корень 17,6 → 20,1, /angular 26,9 → 30,4, /setup 17,1 → 18,9 кБ); +0,1…1,3 кБ
v5.20.0: массивы в mockDeep, fallbackMockImplementation, passthrough у createSpyFromInstance, adoptMock, passthrough у moduleNamespaceкаждая строка ядра+1,74…1,79 кБ (корень 20,3 → 22,1 кБ)
v5.20.0: stubResponse и проверка MSW/setup+0,36 кБ
v5.20.0: no-hand-assigned-global/eslint-plugin+1,01 кБ
v5.21.0: /angular разобран на три точки входа/angular30,4 → 26,3 кБ
v5.21.0: отметка файла в журнале патчей, реестр умолчаний на слабых ключахстроки ядра+0,2…0,4 кБ
prefer-stub-response/eslint-plugin+0,87 кБ
JSON-тело null/setup+0,11 кБ

Самостоятельные бандлы — единственный шаг вниз. Дублирование, которое они дают, оплачивается на диске, а не в какой-то одной точке входа: см. Что лежит в скачиваемом пакете.

История самого tarball:

КогдаTarballПримечание
v2.0.0241 кБ, 54 файлатринадцать подпутей, без CLI, точка входа ESLint вшестеро меньше
12.09.2026584 кБ, 84 файлапротив 1 560 кБ собранного кода
(то же дерево)751 кБ, 82 файлапосле самостоятельных бандлов, до ремонтного раунда
17.09.2026818 кБ, 84 файла
20.09.2026867 кБ, 96 файловдвенадцать файлов от разбиения Angular и два JSON-отчёта CLI
26.09.20261 019 кБ, 114 файловтекущий

Одна из старых редакций подавала цифры v2.0.0 как сокращение. Для того пакета они были верны, но это был другой пакет.

NG0101 под zone.js ​

NG0101: ApplicationRef.tick is called recursively может появиться, когда тест запускает проверку изменений под zone.js. Цепочка такая:

  1. TestBed.createComponent создаёт компонент внутри ngZone.run(…). Каждый effect() из его конструктора запоминает внутреннюю зону.
  2. Тик, запущенный из тела теста, идёт в зоне раннера. Чтобы выполнить грязный эффект, Angular возвращается в зону через effect.zone.run(() => effect.run()).
  3. Выход из этого перехода делает зону стабильной. Об этом сообщает NgZone.onMicrotaskEmpty.
  4. Подписчик, которого ставит provideZoneChangeDetection(), отвечает вызовом ApplicationRef._tick(). От своего планировщика он защищён, а от уже идущего тика — нет. Идущий тик запускается повторно, и Angular бросает NG0101.

Встретить это трудно, а заметить ещё труднее. @angular/build:unit-test из Angular CLI добавляет provideZoneChangeDetection() в любой проект, который грузит zone.js, а один effect() — обычное дело. Но эффект должен быть грязным именно в этот момент, поэтому тот же вызов работает, пока не запустит первый рендер. Ошибка уходит в ErrorHandler, а не бросается. Набор тестов, который не падает на выводе в консоль, остаётся зелёным с недоделанной проверкой изменений.

На Angular 22-проекте из 1771 файла спек замена 451 вызова componentRef.setInput на setInputs сделала 57 зелёных файлов красными, все на NG0101. Когда тик перенесли внутрь зоны, красных не осталось.

Одно побочное следствие под zone.js: выход из зоны делает её стабильной, и подписчик тикает ещё раз. По счёту на ApplicationRef.afterTick один flushEffects() даёт 2 тика приложения вместо 1 под zone.js и не меняется под zoneless. fixture.detectChanges() в этом месте всегда давал тот же второй проход, и тот ничего грязного не находит.

Когда ресурсы успокаиваются, замер ​

Каждому ресурсному примитиву Angular нужно своё ожидание. Замер на Angular 21.2.17, zoneless TestBed:

ЧтоЧто нужно, чтобы значение устоялось
httpResource() после отправки ответаодин тик + одна микрозадача
resource() с асинхронным загрузчикомдва раунда того же
только что созданный httpResource()тик, иначе он не делает запрос

Как Angular-проекты используют подмены ​

В Angular-проекте примерно на 370 файлов спай почти всегда приходит через DI:

APIФайлов
provideAutoSpy371
injectSpy308
mockReadonlyProp127
instanceMethodsToSpyOn103
observablePropsToSpyOn79
голый createSpyFromClass41

Провайдеры, написанные руками, по двум приватным Angular-проектам:

Провайдер, написанный рукамиСколькоОбычная форма
window95useValue: window (ничего не изолирует), срез вроде { screen: { … } }
document70mockDocument с одним написанным руками querySelector
диалог Material (данные, ref, диалог)36объект в MAT_DIALOG_DATA, { close: vi.fn() } в MatDialogRef, спай на MatDialog

Срезы ломаются, когда компонент читает член, о котором автор не подумал: он получает undefined. Самодельный ref диалога падает на afterClosed(). Заплатка вроде afterClosed: () => of('saved') отвечает раньше, чем диалог закрыли, и спека проходит, вызывали close() или нет.

Почему CommonJS есть только у двух точек входа ​

Раньше пакет вёз сборку .cjs для каждой точки входа, и большую её часть загрузить было нельзя. Vitest отказывается грузиться через require (Vitest cannot be imported in a CommonJS module using require()), так что восемь из двенадцати файлов .cjs падали на первой строке. esbuild не умеет делить CommonJS на чанки, поэтому каждый уцелевший файл нёс свою копию реестров адаптеров. Даже require('vitest-auto-spy/rxjs') рядом с require('vitest-auto-spy/node') падал с «Observable spies require rxjs»: два бандла, два несвязанных реестра. Теперь CommonJS есть только там, где require() работает и не нужна вторая точка входа: vitest-auto-spy/node и vitest-auto-spy/eslint-plugin.

Опубликовано под лицензией MIT.