Производительность
На этой странице собраны замеры: чего спай стоит по времени и памяти и какие настройки меняют скорость тестов. Создание спаев почти никогда не причина медленного прогона. Раздел «Коротко» ниже говорит, куда уходят время и память и какие настройки на это влияют.
Коротко
Подмена здесь — объект, который стоит в тесте вместо настоящей зависимости; её методы — спаи. Подмена «нетронутая», если тест не обратился ни к одному её методу.
Что быстро. Подмена строится за несколько микросекунд, так что тесты тормозят не из-за спаев.
| Вопрос | Короткий ответ | Подробнее |
|---|---|---|
| Сколько строится одна подмена? | Около 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. Одно окружение вместо одного на файл
По умолчанию каждый файл тестов получает своё окружение. Общее окружение платит за jsdom и zoneless-TestBed один раз на прогон, а не на каждый файл. Экономия идёт на файл, поэтому растёт вместе с проектом.
В Angular unit-test builder (ng test) isolate: false уже стоит по умолчанию. Добавьте только fileParallelism: false — в файл конфигурации Vitest, который вы передаёте ng test через --runner-config.
// 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, но без детей и без шаблона.
import { renderShallow } from 'vitest-auto-spy/angular';
const view = renderShallow(DashboardComponent); // без дочерних компонентов и без шаблона
const withTemplate = renderShallow(DashboardComponent, { keepTemplate: true });Его цена не зависит от числа детей: поддерево он не строит. TestBed.createComponent растёт с ними линейно. Поэтому экономия — не фиксированный процент. Она равна разметке, которой владеет компонент.
Бенчмарк рендерит хост-компонент с @for по минимальному ребёнку. Каждая ветка проходит полный цикл теста: resetTestingModule, configureTestingModule, createComponent и первая проверка изменений.
| Экземпляров детей | TestBed.createComponent | renderShallow | отношение |
|---|---|---|---|
| 0 | 0,487 мс | 0,619 мс | 0,79× |
| 25 | 0,762 мс | 0,470 мс | 1,62× |
| 100 | 1,757 мс | 0,457 мс | 3,85× |
| 400 | 7,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 + CD | 1,556 мс |
один resetTestingModule() | 0,001 мс |
resetTestingModule() + configureTestingModule() | 0,001 мс |
createComponent + CD на уже сконфигурированном модуле | 1,277 мс |
configureTestingModule + overrideComponent + createComponent + CD | 0,365 мс |
compileComponents() на standalone-стенде с AOT | 0,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 файлов с искусственным замедлением:
| Замедление | Файлов у прежнего правила с фиксированным временем | Файлов у проверки |
|---|---|---|
| ×1 | 0 | 0 |
| ×3 | 4 | 0 |
| ×6 | 8 | 0 |
| ×9 | 19 | 0 |
Прежнее правило сравнивало файл с фиксированным порогом (5 с) и десятью медианными файлами. В том проекте медианный файл — 8,9 мс и пять тестов, так что решал один порог. Он отмечал самые большие файлы, например спеку сервиса на 209 тестов по 5 мс на тест. Одна спека компонента шла 0,96 с отдельно на ноутбуке и 5,62 с в CI: зелёная на одном, красная на другом. Правило по тестам не отмечает ничего ни при каком замедлении: самый тяжёлый файл набирает 742 медианных теста из 2 000 допустимых.
Что намерили находки doctor и perf
Находки doctor и perf называют вывод. Замеры за ними:
| Находка | Замер |
|---|---|
angular-build-happy-dom | Angular 22.2, 700 файлов: время −4,6 %, резидентная память −6 % |
angular-cache-off-in-ci | 700 файлов: +2,91 с (+33 %) на прогон с выключенным кэшем билдера |
runner-dom-differs-from-builder | Angular-проект на 48 файлов с environment: 'happy-dom': 1,37 с → 1,10 с (−20 %) |
perf-environment-engine | 117 Angular-файлов этого пакета: 26,5 с → 23,2 с пользовательского CPU (−12 %); спека, которая только строит DOM: 253 → 119 мс окружения на файл |
perf-workers | 1,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 --shard | 700 спек, покрытие, кэш билдера выключен: медленный шард 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.11 | Vitest 5.0.2 | время прогона |
|---|---|---|---|
| v8 (по умолчанию у билдера) | 16,50 с | 8,91 с | −46,0 % (в 1,85 раза быстрее) |
| istanbul | 37,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-angular2.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.11 | Vitest 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.filter | 114,1 с |
Обычно винят проход по непротестированным файлам, а он занимает треть секунды. Финальный фильтр — один вызов isIncluded на файл — это половина прогона. Против матчера, скомпилированного один раз, на тех же глобах:
| Операция | как есть | скомпилирован один раз |
|---|---|---|
isIncluded, 8 000 вызовов | 167 853 мс | 1 808 мс |
coverageMap.filter | 81 393 мс | 713 мс |
| проход по 1 816 непротестированным файлам | 69 779 мс | 9 439 мс |
Обёртка над провайдером покрытия, которая компилирует матчер один раз, на том же шарде и тех же репортерах:
| Показатель | Как есть | С обёрткой |
|---|---|---|
| фаза Vitest | 229,59 с | 22,88 с |
| файлов | 432 / 432 | 432 / 432 |
| отчёт cobertura | 8,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, на самом свежем релизе каждой мажорной версии:
| 20 | 22 | 24 | 25 | 26 | |
|---|---|---|---|---|---|
тесты библиотеки, 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 собирал мусор раньше, чем контейнер убьёт процесс:
export NODE_OPTIONS=--max-old-space-size=<мегабайт меньше лимита контейнера>У Node 22 пик ещё ниже, но холодный импорт вдвое медленнее; лимит кучи решает ту же задачу без этой цены. Node 20 уже без поддержки и остался в таблице, только чтобы показать, почему граница сдвинулась. Про Node 18 — в разделе Подробнее.
Кэш компиляции Node
Начиная с Node 22.1, NODE_COMPILE_CACHE сохраняет скомпилированный V8 код каждого модуля, который грузит Node. Следующий старт его переиспользует. Воркеры Vitest наследуют переменную, так что одна строка покрывает весь прогон.
# локально: любой каталог, который переживает прогон
export NODE_COMPILE_CACHE=node_modules/.cache/node-compile
npx vitest run# 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 выключает кэш на один прогон.
За чем тянуться
| Ситуация | Берите | Почему |
|---|---|---|
| Зависимость в Angular | provideAutoSpy(Service) | готовый DI-провайдер |
| Класс, без Angular | createSpyFromClass(Service) | та же фабрика, то же ленивое умолчание |
| В рантайме класса нет — интерфейс, тип | createAutoMock<T>() | Proxy; член создаётся при первом обращении |
signalStore() из ngrx | createAutoMock<T>() | его члены живут на экземпляре, прототипа для чтения нет |
| Форма данных, которую код только читает | createMock<T>(partial) | вообще без спаев — это одна проверенная типом декларация |
| Вложенный граф объектов | mockDeep<T>() | сам создаёт цепочку спаев вниз по дереву |
Вызывайте фабрику в beforeEach. Свежая подмена на каждый тест стоит микросекунды, а список методов кэшируется на класс.
Две настройки, которые чего-то стоят
Большинство опций не стоят ничего измеримого. В таблице — пять, которые стоят; первыми идут два значения lazySpies.
| Настройка | Цена | Когда нужна |
|---|---|---|
lazySpies: true на классе от 8 методов | подмена, к которой тест обращался, на 21–68 % тяжелее и дольше строится | спеке нужен обычный объект или она читает один член в горячем цикле |
lazySpies: false | 11,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() выполняются на каждом тесте. Их цена растёт с числом тестов, а не с числом подмен. В таблице — полное время пустого теста вместе с раннером. Чтобы получить долю библиотеки, вычтите первую строку.
| Настройка | node | happy-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:
| Настройка | node | happy-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 |
|---|---|---|
| 0 | 3,5 мкс | 5 мкс |
| 50 | 11 мкс | 43 мкс |
| 300 | 56 мкс | 243 мкс |
| 1 000 | 188 мкс | 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: true | 2,25 мкс |
то же, жадно (lazySpies: false) | 3,21 мкс |
спай на класс из 40 методов, вызвано 3 — lazySpies: true | 6,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, и падают при выходе за бюджет — см. Цена проверки типов.
Почему это быстро
Подмену дешёвой держат три вещи:
- Ленивые спаи.
lazySpiesвключён по умолчанию дляcreateSpyFromClassиprovideAutoSpy. Спай метода строится при первом обращении. Сервис на двадцать методов, к которому тест обратился дважды, построит два спая. До 8 методов заглушка — аксессор; от 8 подмена —Proxy. - Кэш списка методов. Цепочка прототипов обходится один раз на класс и кэшируется в
WeakMap. Класс, подменённый в 300 тестах, обходит прототип один раз. - Собственный движок спаев. С 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.0 | 4.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() он бессмыслен: два счётчика никогда не встречаются. Брать счётчик раннера значило бы вызывать его мок на каждой диспетчеризации, а именно этой цены движок и избегает. Поэтому переключение явное:
// 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–6 | 1 743 / 2 887 Б | 1 415 / 2 573 Б | −19 % / −11 % |
| 8–16 | 2 110 / 3 269 Б | 1 417 / 2 573 Б | −33 % / −21 % |
| 20–30 | 2 877 / 4 036 Б | 1 416 / 2 573 Б | −51 % / −36 % |
| 45 | 4 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 здесь нет.
Одно изменение для вызывающего кода. Хелпер, снятый со спая, больше не работает:
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.1 | jest.fn() |
jasmine-auto-spies@8.0.1 | jasmine.createSpy() |
@bugsplat/vitest-auto-spies@1.0.0 | vi.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 | @bugsplat | jest-auto-spies | jasmine-auto-spies | ручной vi.fn() | n |
|---|---|---|---|---|---|---|
| малый проект — 6 методов, вызван 1 | 1,42 мкс | 8,33 мкс (5,88×) | 8,38 мкс (5,91×) | 8,50 мкс (6,00×) | 6,83 мкс (4,82×) | 90,000 |
| средний проект — 14 методов, вызвано 2 | 2,67 мкс | 19,37 мкс (7,27×) | 19,17 мкс (7,19×) | 19,71 мкс (7,39×) | 15,92 мкс (5,97×) | 70,000 |
| крупный проект — 45 методов, вызвано 2 | 5,79 мкс | 64,04 мкс (11,06×) | 65,67 мкс (11,34×) | 66,25 мкс (11,44×) | 52,79 мкс (9,11×) | 36,000 |
| худший случай — 14 методов, вызваны все 14 | 8,17 мкс | 21,87 мкс (2,68×) | 21,79 мкс (2,67×) | 22,25 мкс (2,72×) | 17,92 мкс (2,19×) | 30,000 |
| худший случай — 45 методов, вызваны все 45 | 26,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–8 | 1 |
| p75 | 12–16 | — |
| p90 | 32–44 | 2 |
Как выбраны размеры. jasmine-auto-spies нет в последней строке намеренно. Его вызов настройки — spy.method.and.calledWith(x).returnValue(y), это другая операция, чем mockReturnValue.
Подмена из типа. Все три ветки на Proxy и делают равную работу.
| Затронуто членов | vitest-auto-spy | vitest-mock-extended | @golevelup/ts-vitest | n |
|---|---|---|---|---|
| 2 | 1,00 мкс | 2,79 мкс (2,79×) | 4,92 мкс (4,92×) | 590,000 |
| 10 | 4,67 мкс | 13,83 мкс (2,96×) | 24,92 мкс (5,34×) | 60,000 |
| 40 | 18,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:
| Сценарий | n | vitest-auto-spy | vitest-mock-extended | @golevelup | @bugsplat |
|---|---|---|---|---|---|
| глубокая подмена, 3 уровня, вызван лист | 232,000 | 2,29 мкс | 5,46 мкс (2,38×) | 6,00 мкс (2,62×) | — |
диспетчеризация calledWith, 2 настроено + 1 промах | 2,304,000 | 0,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: true | 156 Б (16 Б/метод) | 11 987 Б (1 199 Б/метод) | 178 Б (2 Б/метод) | 116 082 Б (1 161 Б/метод) |
vitest-auto-spy lazySpies: false | 4 454 Б (445 Б/метод) | 11 297 Б (1 130 Б/метод) | 42 655 Б (427 Б/метод) | 110 730 Б (1 107 Б/метод) |
| jest-auto-spies | 67 246 Б (6 725 Б/метод) | 77 802 Б (7 780 Б/метод) | 674 658 Б (6 747 Б/метод) | 779 711 Б (7 797 Б/метод) |
| jasmine-auto-spies | 69 723 Б (6 972 Б/метод) | 80 289 Б (8 029 Б/метод) | 699 474 Б (6 995 Б/метод) | 804 525 Б (8 045 Б/метод) |
| @bugsplat/vitest-auto-spies | 67 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 createAutoMock | 369 Б | 11 744 Б | 113 566 Б |
vitest-mock-extended mock | 353 Б | 60 138 Б | 602 182 Б |
@golevelup/ts-vitest createMock | 496 Б | 116 462 Б | 1 158 269 Б |
Что это показывает:
- От 8 методов умолчание даёт более лёгкую подмену, как только тест её коснулся. Нетронутой
lazySpies: trueменьше, потому что её заглушка — слот дескриптора. После первого чтения она платит за словарь шириной с класс, а прокси — нет. См. таблицу по ширине. - Вызванный метод удерживает около 1 110 Б против 5 819 Б у мока раннера. Жадно построенный, но ни разу не вызванный (
lazySpies: false, нетронутые) — 427 Б. Спай, который не вызывали, не выделяет ни одного из массивов, которыеvi.fn()выделяет заранее. jest-auto-spiesи@bugsplatсовпадают в пределах 0,01 % по удерживаемым байтам. Это подтверждает общее ядро на метрике без шума замеров времени.- Ядро jest-auto-spies стоит около 2,0 кБ на метод сверх голого
vi.fn()ещё до вызовов: 6 725 против 4 726 Б.jasmine-auto-spiesещё примерно на 4 % выше. - Полностью построенные, четыре ветки меряют
@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 000 | 1,33 с · 1116 МБ | 2,00 с (1,50×) · 1237 МБ | 1,31 с (0,99×) · 1085 МБ |
| 3 000 | 2,86 с · 1167 МБ | 4,61 с (1,61×) · 1349 МБ | 2,85 с (1,00×) · 1136 МБ |
| 10 000 | 10,02 с · 1295 МБ | 15,45 с (1,54×) · 1509 МБ | 9,25 с (0,92×) · 1283 МБ |
| По девяти раундам | Диапазон | Медиана |
|---|---|---|
ручной vi.fn() | 0,84×–1,00× | 0,97× |
@bugsplat | 1,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) — не результат |
| @bugsplat | 15,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–6801 | 2,63 с |
| vitest-auto-spy по умолчанию | 2103 МБ | 1995–2167 | 2,17 с |
vitest-auto-spy, lazySpies: 'proxy' | 1851 МБ | 1835–1960 | 2,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 и меряют старый вариант.
Как выбрать настройку для своего набора тестов
- Сначала
isolate: false. Он один даёт примерно 4× — больше любой библиотеки или флага, измеренных здесь. Ограничением тогда становится память: всё, что выделил файл, живёт до конца прогона. См. Одно окружение вместо одного на файл. - Не задавайте
lazySpies. Ширина сама выбирает более лёгкую подмену: обычный объект до 8 методов, прокси от 8.lazySpies: true— только для спеки, которой нужен обычный объект или которая читает один член в горячем цикле. - Не ждите выигрыша по скорости против ручного
vi.fn()на целом прогоне. Приisolate: trueони идут вровень на 1 000 и 3 000 тестах. На 10 000 библиотека отстаёт примерно на 8 %. Доводы за библиотеку — память, ленивое умолчание, кэш обхода прототипа,calledWith, подмены по типу и глубокие подмены. - Против семейства jest-auto-spies выигрыш держится везде, где проверялся: примерно в 1,5 раза быстрее в каждом раунде, на любом размере набора и любой ширине класса.
Что осталось необъяснённым
Около 98 % отставания @bugsplat на целом прогоне не объясняется разницей на одну подмену:
| При 10 000 тестов | По часам |
|---|---|
измеренное отставание @bugsplat | около 5,4 с |
| разрыв на подмену (около 10 мкс) × 10 000 тестов | около 0,1 с |
Его пиковый RSS тоже выше: 1509 МБ против 1295 МБ при 10 000 тестов на классе из 20 методов. Больше работы GC согласуется с остатком разрыва. Эта связь не измерялась и не утверждается.
Как это воспроизвести
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-builder | Vitest 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/doubles | 21,4 кБ |
vitest-auto-spy/angular/diagnostics | 7,5 кБ |
vitest-auto-spy/angular/matchers | 3,2 кБ |
vitest-auto-spy/react / /vue / /svelte | 30,3 кБ |
vitest-auto-spy/setup | 31,1 кБ |
vitest-auto-spy/node | 29,5 кБ |
vitest-auto-spy/dom-stubs | 12,1 кБ |
vitest-auto-spy/rxjs | 2,3 кБ |
vitest-auto-spy/angular-router | 11,2 кБ |
vitest-auto-spy/angular-http | 4,3 кБ |
vitest-auto-spy/signal-forms | 1,3 кБ |
vitest-auto-spy/zone | 1,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-spy | 252 кБ | нет: это инструмент командной строки |
плагин ESLint (.cjs) | 180 кБ | нет: его грузит ESLint |
| корневая точка входа | 170 кБ | всегда |
точка входа /angular | 59 кБ | на Angular; ядро она импортирует из корня |
README.md | 417 кБ | нет |
AGENTS.md с agent-docs/ | 389 кБ | нет: справочник для кодовых агентов, читающих установленную копию |
/setup и /node собраны одним файлом каждый. Раньше каждый дотягивался через загрузчик до двенадцати– четырнадцати модулей. На файл спеки, Node 24, медиана 25 прогонов:
| Импорт | Раньше | Теперь |
|---|---|---|
/setup | 5,6 мс | 3,1 мс |
корень + /setup | 7,0 мс | 4,9 мс |
/react | 5,5 мс | 3,1 мс |
/node | 3,4 мс | 1,7 мс |
корень + /angular + /setup | 7,7 мс | 5,9 мс |
Та же Angular-форма стала 9,9 → 8,5 мс, когда /angular начал брать ядро из корня. Цена — +108 кБ в tarball и около 442 кБ на диске, один раз при установке. Проверка холодного импорта сторожит эти времена по зафиксированной базовой линии. Она считает и модули, и байты.
Двух из этих миллисекунд загрузка не касалась вовсе. Корневая точка входа и /setup при импорте форматировали стек, чтобы в сообщениях о месте вызова отличать фрейм вызывающего кода от фрейма библиотеки. Под Vitest первый отформатированный стек в файле спеки стоит почти миллисекунду. Теперь эта проба выполняется, только когда строгая подмена или подмена конструктора впервые сообщает место вызова. На файл спеки под Vitest 5, медиана 93 файлов по 31 прогону:
| Импорт | Раньше | Теперь |
|---|---|---|
корень + /setup | 8,6 мс | 6,8 мс |
| только корень | 5,4 мс | 4,0 мс |
Корневая точка входа и /angular к тому же доходят до загрузчика малым числом модулей. Загрузчик платит за модуль, а не за байт. Замер 04.09.2026:
| Точка входа | Модулей, до → после | Экономия на файл спеки |
|---|---|---|
| корень | 8 → 2 | около 0,8 мс |
/angular | 10 → 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.18 | 4.0 | |
|---|---|---|
| файлов в TypeScript-программе потребителя | 303 | 114 |
из них .d.ts из rxjs | 189 | 0 |
TS2307 при skipLibCheck: false без установленного rxjs | есть | нет |
Импорт только типов разрешается так же, как импорт значения, так что одного import type не хватило бы.
Цена линта у ESLint-плагина
Плагин тратит 56 мс на 173 файла спек и 68 мс на одну спеку в 1,7 МБ. Раньше большую часть цены давали три правила, которые задавали вопрос про весь файл на каждом узле. Теперь они спрашивают раз на файл или на блок:
| Правило | До | После | На чём |
|---|---|---|---|
no-inject-before-override, no-overridden-provider | 1 028 мс | 0,13 мс | спека в 249 кБ |
prefer-render-shallow | 2 528 мс | 12,8 мс | спека в 1,7 МБ |
no-redundant-smoke-test | 46 мс | 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.2026 | createAutoMock и узлы 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.2026 | mock.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 отвечает вручную, и проверка ссылки диалога по компоненту | /angular | 0,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-router | 8492 → 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 разобран на три точки входа | /angular | 30,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.0 | 241 кБ, 54 файла | тринадцать подпутей, без CLI, точка входа ESLint вшестеро меньше |
| 12.09.2026 | 584 кБ, 84 файла | против 1 560 кБ собранного кода |
| (то же дерево) | 751 кБ, 82 файла | после самостоятельных бандлов, до ремонтного раунда |
| 17.09.2026 | 818 кБ, 84 файла | |
| 20.09.2026 | 867 кБ, 96 файлов | двенадцать файлов от разбиения Angular и два JSON-отчёта CLI |
| 26.09.2026 | 1 019 кБ, 114 файлов | текущий |
Одна из старых редакций подавала цифры v2.0.0 как сокращение. Для того пакета они были верны, но это был другой пакет.
NG0101 под zone.js
NG0101: ApplicationRef.tick is called recursively может появиться, когда тест запускает проверку изменений под zone.js. Цепочка такая:
TestBed.createComponentсоздаёт компонент внутриngZone.run(…). Каждыйeffect()из его конструктора запоминает внутреннюю зону.- Тик, запущенный из тела теста, идёт в зоне раннера. Чтобы выполнить грязный эффект, Angular возвращается в зону через
effect.zone.run(() => effect.run()). - Выход из этого перехода делает зону стабильной. Об этом сообщает
NgZone.onMicrotaskEmpty. - Подписчик, которого ставит
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 | Файлов |
|---|---|
provideAutoSpy | 371 |
injectSpy | 308 |
mockReadonlyProp | 127 |
instanceMethodsToSpyOn | 103 |
observablePropsToSpyOn | 79 |
голый createSpyFromClass | 41 |
Провайдеры, написанные руками, по двум приватным Angular-проектам:
| Провайдер, написанный руками | Сколько | Обычная форма |
|---|---|---|
window | 95 | useValue: window (ничего не изолирует), срез вроде { screen: { … } } |
document | 70 | mockDocument с одним написанным руками 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.