vitest-auto-spy 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -261,9 +261,10 @@ xhr.send.mockImplementation(() => respond(asInstance(xhr)));
261
261
 
262
262
  ### Cost, so it stops being a question
263
263
 
264
- Building a spy is not a thing to optimise. On a ten-method class: `provideAutoSpy` ~8 µs,
265
- `createSpyFromClass` ~29 µs, `createAutoMock` ~33 µs, a `calledWith` lookup ~0.7 µs — five providers
266
- across two thousand tests is under a tenth of a second. Call the factory in `beforeEach` and look at
264
+ Building a spy is not a thing to optimise. On a ten-method class, Node 24, `npm run bench`:
265
+ `createSpyFromClass` plus two methods called ~2.3 µs, plus all ten ~6 µs, `createAutoMock` plus four
266
+ members ~1.7 µs, a `calledWith` lookup ~0.2 µs — five providers across two thousand tests is a
267
+ hundredth of a second. Call the factory in `beforeEach` and look at
267
268
  `TestBed` instead. The only two settings that cost: `{ lazySpies: false }` gives up the laziness
268
269
  `provideAutoSpy` defaults to, and `autoSpyAccessors: true` walks the prototype chain uncached on
269
270
  every call — name the accessors instead.
@@ -1053,6 +1054,48 @@ is therefore remembered and put back, in `beforeEach`, but only when it has gone
1053
1054
  returns how many went), `restoreLongLivedImplementations()` is that repair (it returns how many it
1054
1055
  put back) and `getMockRegistrySize()` reports what is left.
1055
1056
 
1057
+ One entry is never pruned, and it is not one of yours: the Vitest adapter registers a single
1058
+ `vi.fn()` whose `mockClear` sweeps this library's own spies. Dropping it would turn
1059
+ `vi.clearAllMocks()` into a silent no-op for every double in the run, so it carries a mark the
1060
+ pruner skips. Why the library needs a mock of its own for that is the next section.
1061
+
1062
+ ### What a method spy is, and the one thing that differs from `vi.fn()`
1063
+
1064
+ A method spy is **not** a `vi.fn()`. It is this library's own mock function: one shared prototype
1065
+ carrying the whole `Mock` surface, call state allocated on the first call rather than at creation,
1066
+ and no entry in any global registry. `vi.fn()` assigns some twenty-five closures as own properties
1067
+ of every mock it makes and allocates six arrays up front — per method, on every double a spec
1068
+ builds — and that cost is most of what a wide service used to pay for methods no test touches.
1069
+
1070
+ Everything a spec can observe is the same, and the suite pins it by putting the two side by side:
1071
+
1072
+ - `vi.isMockFunction(spy.load)`, `expect(spy.load).toHaveBeenCalledWith(…)`, `toHaveReturned`,
1073
+ `toHaveResolved`, `toHaveBeenNthCalledWith` — every matcher reads `_isMockFunction`, `mock.*` and
1074
+ `getMockName()`, all of which the spy implements with the runner's semantics.
1075
+ - `spy.load.mock.calls` / `.results` / `.settledResults` / `.instances` / `.contexts` /
1076
+ `.invocationCallOrder` / `.lastCall`, `mockReturnValue`, `mockResolvedValue`,
1077
+ `mockImplementation(Once)`, `withImplementation`, `mockClear` / `mockReset` / `mockRestore`,
1078
+ `mockName`, `using`.
1079
+ - `vi.clearAllMocks()`, `vi.resetAllMocks()`, and the `clearMocks` / `mockReset` config keys, which
1080
+ Vitest applies through those two functions.
1081
+
1082
+ **The one difference.** `mock.invocationCallOrder` counts on this library's own scale, so
1083
+ `expect(a).toHaveBeenCalledBefore(b)` is exact between two auto-spies and meaningless between an
1084
+ auto-spy and a hand-written `vi.fn()` — the two counters never met. Nothing else in the library or in
1085
+ Vitest reads that field. If a suite needs the comparison, switch the whole run back to the runner's
1086
+ factory:
1087
+
1088
+ ```ts
1089
+ // vitest.setup.ts
1090
+ import { setSpyEngine } from 'vitest-auto-spy/setup';
1091
+
1092
+ setSpyEngine('runner'); // every double built afterwards is `vi.fn()` per method, as before 4.1
1093
+ ```
1094
+
1095
+ `getSpyEngine()` reports the current one, `'auto-spy'` is the default, and doubles already built keep
1096
+ the engine they were built with. The switch is Vitest-only: on Bun and `node:test` the runner's own
1097
+ matchers recognise only the runner's own mocks, so those adapters keep using them.
1098
+
1056
1099
  Two more switches, both about the environment rather than the spies:
1057
1100
 
1058
1101
  ```ts
@@ -2072,6 +2115,14 @@ stubbed a since-renamed method was passing over nothing and will now fail. Full
2072
2115
  `fesm2022/testing-library-angular-vitest-utils.mjs` lines 14 and 18 at 19.4.2. Re-`npm pack` and
2073
2116
  re-read before restating them against a new version; the "Where the numbers come from" box records
2074
2117
  each re-verification date rather than replacing the previous one.
2118
+ - **Head-to-head benchmark numbers are quoted from the measurement, not from memory.** `npm run
2119
+ bench:vs` and `npm run bench:suite` (see `CONTRIBUTING.md` for setup) back the claims in
2120
+ `core/performance.md`; point at that page rather than retyping a figure here, so it lives in one
2121
+ place. Micro-benchmark ratios do not transfer to suite scale: double construction is on the order
2122
+ of 1% of a test's cost, and an advantage that looks like several times faster per double can be
2123
+ gone, or reversed, once a whole suite runs. Suite-scale wall-clock is noisy between invocations;
2124
+ peak RSS reproduces far more tightly and is the safer claim. Never state a magnitude the source
2125
+ marks as not established.
2075
2126
 
2076
2127
  `node:test` keeps every `mock.fn()` in one process-wide `MockTracker` for the life of the process.
2077
2128
  On a long suite that is real memory — 20 000 spies of a 10-method class retained 124.5 MB on
@@ -2521,6 +2572,35 @@ worth one run after any large edit to a test suite — especially after a codemo
2521
2572
  the eaten glob below came from. Full reference:
2522
2573
  <https://asdalexey.github.io/vitest-auto-spy/utilities/cli>.
2523
2574
 
2575
+ ### If you were asked why a suite is slow
2576
+
2577
+ ```bash
2578
+ npx vitest-auto-spy perf # runs the suite once and reports; always exits 0
2579
+ npx vitest-auto-spy perf src/some/dir # a path is passed through to Vitest as a file filter
2580
+ ```
2581
+
2582
+ It reads Vitest's own per-file phase timings through `TestModule.diagnostic()` — nothing here
2583
+ parses terminal output — and reports six phases: `environment`, `import`, `tests`, `setup`,
2584
+ `prepare` (all measured per file) and `transform` (measured once for the whole run). **The phase
2585
+ totals are CPU time summed across every worker, not wall clock** — a report showing `20.29s of CPU
2586
+ time` under `986ms wall clock` is not a bug, it is the work spread over workers.
2587
+
2588
+ When `environment` dominates, the `perf-environment` finding names spec files that could run under
2589
+ the `node` environment instead — but only the ones it could *prove* reach no DOM: the spec, its
2590
+ configured setup files, and every repository module any of them imports were all read, none
2591
+ mentions a DOM name, and every package they import is on a short allowlist (`vitest`, `rxjs`,
2592
+ `date-fns`, `lodash`, `zod`, …). Anything it could not resolve is reported **undecided**, never
2593
+ assumed safe — do not treat an undecided file as a candidate, and do not add packages to that list
2594
+ yourself; a false positive breaks someone's suite on `document is not defined`. When `import`
2595
+ dominates, `perf-import` names specs that reach their subject through a barrel (`index`/`public-api`
2596
+ re-exporting a whole directory). When `environment` + `setup` + `prepare` together dominate,
2597
+ `perf-isolation` suggests `test.isolate: false` and links to this package's own memory measurements
2598
+ of that trade rather than restating them — read that page before recommending the flag, since it
2599
+ raises peak memory.
2600
+
2601
+ `--json <path>` re-analyses a report an earlier `--out <path>` run wrote, instead of running Vitest
2602
+ again. Full reference: <https://asdalexey.github.io/vitest-auto-spy/utilities/cli>.
2603
+
2524
2604
  ### Migrating a suite off `jest-auto-spies` — run the codemod, then verify it
2525
2605
 
2526
2606
  ```bash
package/README.md CHANGED
@@ -11,17 +11,19 @@ skip the class entirely and mock straight from a **type or interface** with `cre
11
11
  recursive `mockDeep<T>()`. Runs on **Vitest**, **Bun** (`bun:test`) and **`node:test`** behind one
12
12
  identical API, with **RxJS** spies and **Angular / NestJS / React / Vue·Pinia / Svelte** recipes
13
13
  ([availability](#availability)). A drop-in replacement for
14
- [`jest-auto-spies`](https://www.npmjs.com/package/jest-auto-spies) — same API — and for
14
+ [`jest-auto-spies`](https://www.npmjs.com/package/jest-auto-spies) — same API, and roughly 1.5x
15
+ faster at suite scale ([benchmarks](#benchmarks)) — and for
15
16
  [`jasmine-auto-spies`](https://www.npmjs.com/package/jasmine-auto-spies) through
16
17
  [`vitest-auto-spy/jasmine`](#migrating-from-jasmine-auto-spies), which keeps `.and`, `.calls` and
17
18
  `.withArgs` working while you land the suite green.
18
19
 
19
20
  [![npm version](https://img.shields.io/npm/v/vitest-auto-spy?color=brightgreen&logo=npm)](https://www.npmjs.com/package/vitest-auto-spy)
21
+ [![downloads per day](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.npmjs.org%2Fdownloads%2Fpoint%2Flast-day%2Fvitest-auto-spy&query=%24.downloads&color=brightgreen&logo=npm&label=downloads%2Fday)](https://www.npmjs.com/package/vitest-auto-spy)
20
22
  [![downloads per week](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.npmjs.org%2Fdownloads%2Fpoint%2Flast-week%2Fvitest-auto-spy&query=%24.downloads&color=brightgreen&logo=npm&label=downloads%2Fweek)](https://www.npmjs.com/package/vitest-auto-spy)
21
23
  [![downloads per month](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.npmjs.org%2Fdownloads%2Fpoint%2Flast-month%2Fvitest-auto-spy&query=%24.downloads&color=brightgreen&logo=npm&label=downloads%2Fmonth)](https://www.npmjs.com/package/vitest-auto-spy)
22
24
  [![downloads over 18 months](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.npmjs.org%2Fdownloads%2Fpoint%2F2026-06-21%3A2030-01-01%2Fvitest-auto-spy&query=%24.downloads&color=brightgreen&logo=npm&label=downloads%2F18mo)](https://www.npmjs.com/package/vitest-auto-spy)
23
25
  [![CI](https://github.com/ASDAlexey/vitest-auto-spy/actions/workflows/ci.yml/badge.svg)](https://github.com/ASDAlexey/vitest-auto-spy/actions/workflows/ci.yml)
24
- [![minzipped size](https://img.shields.io/badge/minzip-12.9%20kB-brightgreen)](#install)
26
+ [![minzipped size](https://img.shields.io/badge/minzip-14.3%20kB-brightgreen)](#install)
25
27
  [![types](https://img.shields.io/npm/types/vitest-auto-spy?logo=typescript&logoColor=white)](https://www.npmjs.com/package/vitest-auto-spy)
26
28
  [![coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/ASDAlexey/vitest-auto-spy/actions/workflows/ci.yml)
27
29
  [![license](https://img.shields.io/npm/l/vitest-auto-spy?color=blue)](./LICENSE)
@@ -75,7 +77,8 @@ identical API, with **RxJS** spies and **Angular / NestJS / React / Vue·Pinia /
75
77
  - 🚚 A migration you can verify — `vitest-auto-spy/diagnostics`: `compareTestRuns` on the two JSON reports, `summarizeTestRun` / `formatTestRunComparison` to read the answer, `diffByField` for the assertion the reporter collapses
76
78
  - 📏 Lint rules and one-line test-run hygiene — nineteen rules in `vitest-auto-spy/eslint-plugin` (three `--fix`, seven suggestions, four of them for a suite mid-migration off jasmine), `setupAutoSpy()`
77
79
  - 🩺 [Editor diagnostics](#editor-diagnostics--webstorm--vs-code) — the same anti-patterns underlined while you type: native ESLint inspections in **WebStorm** and the other JetBrains IDEs, the ESLint extension in **VS Code**, no extra plugin either way
78
- - 🔎 [`npx vitest-auto-spy doctor`](#the-cli--doctor-codemod-and-init) — suite-level defects **that never fail a run**: a `tsconfig` `include` matching no file, a production module importing a spec, a `@jest-environment` pragma the runner never reads, config left behind for a runner that is gone. Read-only, no config, exits 1 in CI
80
+ - 🔎 [`npx vitest-auto-spy doctor`](#the-cli--doctor-perf-codemod-and-init) — suite-level defects **that never fail a run**: a `tsconfig` `include` matching no file, a production module importing a spec, a `@jest-environment` pragma the runner never reads, config left behind for a runner that is gone. Read-only, no config, exits 1 in CI
81
+ - ⏱️ [`npx vitest-auto-spy perf`](#perf--where-the-cpu-time-actually-goes) — where a suite's CPU time actually goes, phase by phase, and which spec files to act on: the ones that reach no DOM and could run under `node`, the ones that import a barrel. Runs Vitest once with a reporter this package ships, reads `TestModule.diagnostic()`, names files, states the rule behind each finding
79
82
  - 🚚 [`npx vitest-auto-spy codemod`](#codemod--migrating-a-suite-off-jest-auto-spies) — thirteen transforms that move a suite off `jest-auto-spies` and Jest, or off `jasmine-auto-spies` and jasmine (`--from jasmine`), dry-run by default, with a `--verify` pass that also checks a file somebody edited by hand
80
83
  - 🔇 Console spies — `import { consoleInfoSpy } from 'vitest-auto-spy/console'` silences `console` and asserts its calls
81
84
  - 🧭 [**Spec patterns**](https://asdalexey.github.io/vitest-auto-spy/recipes) — the shapes a ~370-file Angular suite converged on, and the traps that only surface at scale
@@ -104,8 +107,9 @@ includes — that one import is what keeps `returnSubject()` typed as rxjs's own
104
107
  - [Install](#install)
105
108
  - [Requirements](#requirements)
106
109
  - [Peer dependencies](#peer-dependencies)
107
- - [The CLI — `doctor`, `codemod` and `init`](#the-cli--doctor-codemod-and-init)
110
+ - [The CLI — `doctor`, `perf`, `codemod` and `init`](#the-cli--doctor-perf-codemod-and-init)
108
111
  - [`doctor` — defects that never fail](#doctor--defects-that-never-fail)
112
+ - [`perf` — where the CPU time actually goes](#perf--where-the-cpu-time-actually-goes)
109
113
  - [`codemod` — migrating a suite off `jest-auto-spies`](#codemod--migrating-a-suite-off-jest-auto-spies)
110
114
  - [`init` — the pointer an agent reads](#init--the-pointer-an-agent-reads)
111
115
  - [Using this library with an AI agent](#using-this-library-with-an-ai-agent)
@@ -138,6 +142,8 @@ includes — that one import is what keeps `returnSubject()` typed as rxjs's own
138
142
  - [Angular on Bun (`bun:test`)](#angular-on-bun-buntest)
139
143
  - [Comparison](#comparison)
140
144
  - [@testing-library/angular /vitest-utils](#testing-libraryangular-vitest-utils)
145
+ - [Benchmarks](#benchmarks)
146
+ - [Reproducing the numbers](#reproducing-the-numbers)
141
147
  - [Migrating from jest-auto-spies](#migrating-from-jest-auto-spies)
142
148
  - [Migrating from jasmine-auto-spies](#migrating-from-jasmine-auto-spies)
143
149
  - [Migrating from @ngneat/spectator](https://asdalexey.github.io/vitest-auto-spy/migrating-spectator)
@@ -238,12 +244,13 @@ them only for the matching entry point. The package itself has **zero runtime de
238
244
  | `rxjs` | `vitest-auto-spy/rxjs` observable spies — `>=7`, **no upper bound** (the rxjs 8 line included); not needed to type-check a spy since 4.0.0 | yes |
239
245
  | `@angular/core` | `vitest-auto-spy/angular` helpers | yes |
240
246
 
241
- ## The CLI — `doctor`, `codemod` and `init`
247
+ ## The CLI — `doctor`, `perf`, `codemod` and `init`
242
248
 
243
249
  The package ships one executable, with no dependencies and nothing to configure:
244
250
 
245
251
  ```bash
246
252
  npx vitest-auto-spy doctor # read-only. Exits 1 when it finds something
253
+ npx vitest-auto-spy perf # where the suite's CPU time goes. Always exits 0
247
254
  npx vitest-auto-spy codemod # prints the migration diff. Writes nothing without --write
248
255
  npx vitest-auto-spy init # writes the agent instructions pointer
249
256
  ```
@@ -291,6 +298,56 @@ spec tsconfigs still covered their specs.
291
298
 
292
299
  `doctor` never writes. There is no `--fix`.
293
300
 
301
+ ### `perf` — where the CPU time actually goes
302
+
303
+ Vitest prints one summary line per run with a phase breakdown; it is the only place those numbers
304
+ surface, and it is for the whole suite, not a file. `perf` reads the same numbers per file — through
305
+ `TestModule.diagnostic()`, Vitest's own public accessor, via a reporter this package ships — and
306
+ turns whichever phase dominates into a list of files and the rule that put them there. Nothing here
307
+ parses terminal output, and it always exits 0: a slow suite is not a failing one.
308
+
309
+ ```bash
310
+ npx vitest-auto-spy perf # run the whole suite once and report
311
+ npx vitest-auto-spy perf src/cli # path passed through to Vitest as a file filter
312
+ npx vitest-auto-spy perf --json out/perf.json # re-analyse a report instead of running Vitest
313
+ ```
314
+
315
+ ```
316
+ $ npx vitest-auto-spy perf src/cli
317
+ vitest-auto-spy perf — /path/to/repo
318
+ 16 test files, 860ms wall clock, 17.30s of CPU time summed over the workers
319
+
320
+ phase time share
321
+ prepare 6.34s 36.7%
322
+ environment 5.46s 31.6%
323
+ setup 3.01s 17.4%
324
+ transform 1.19s 6.9%
325
+ import 879ms 5.1%
326
+ tests 411ms 2.4%
327
+
328
+ info perf-environment
329
+ Environment setup is 31.6% of the measured CPU time, against 2.4% in the test bodies. No
330
+ spec file could be proved DOM-free, so this names none; 109 were left undecided.
331
+ → Move what does not need a DOM to the `node` environment. …
332
+
333
+ 0 errors, 0 warnings, 2 notes
334
+ ```
335
+
336
+ That is this repository's own suite: `perf` names zero DOM-free candidates and leaves the rest
337
+ undecided, because `src/test-setup.ts` builds an Angular `TestBed` before every spec. The rule is
338
+ deliberately one-sided — a spec is a candidate only when it, the setup files, and every module they
339
+ import were read, none mentions a DOM name, and every package they import is on a short
340
+ DOM-free allowlist. Anything the rule cannot resolve is **undecided**, never assumed safe: a false
341
+ positive is somebody's suite failing on `document is not defined`.
342
+
343
+ **The phase totals are CPU time summed across workers**, which is why 860ms of wall clock reads as
344
+ 17.30s of CPU above — the six phases are `environment`, `prepare`, `import`, `setup`, `tests` (all
345
+ per file) and `transform` (whole run). Where the isolation finding suggests `test.isolate: false`,
346
+ it links to this package's own memory measurements rather than repeating the numbers here — that
347
+ flag trades per-file cleanup for memory that grows with the suite.
348
+
349
+ Full reference, phase by phase and finding by finding: **[The CLI](https://asdalexey.github.io/vitest-auto-spy/utilities/cli)**.
350
+
294
351
  ### `codemod` — migrating a suite off `jest-auto-spies`
295
352
 
296
353
  ```bash
@@ -1129,6 +1186,175 @@ since 19.2.0 (2026-03-17), present in the tarball's `exports` map. That is a rea
1129
1186
  favour. Full write-up, including ng-mocks and Spectator:
1130
1187
  [Comparison → Angular](https://asdalexey.github.io/vitest-auto-spy/comparison#angular).
1131
1188
 
1189
+ ## The spy engine
1190
+
1191
+ A method spy is not a `vi.fn()`. It is this library's own mock function — one shared prototype
1192
+ carrying the whole `Mock` surface, call state allocated on the first call rather than at creation,
1193
+ and no entry in any global registry. `vi.fn()` assigns some twenty-five closures as own properties
1194
+ of every mock it creates and allocates six arrays up front, per method, on every double a spec
1195
+ builds; on a service whose methods a test mostly never calls, that is the bill.
1196
+
1197
+ Everything a spec can observe is unchanged, and the suite pins it by putting a spy and a `vi.fn()`
1198
+ through the same steps and comparing their state:
1199
+
1200
+ - `vi.isMockFunction`, every `expect` matcher (`toHaveBeenCalledWith`, `toHaveReturned`,
1201
+ `toHaveResolved`, `toHaveBeenNthCalledWith`, …), and the failure messages that name the mock.
1202
+ - `spy.method.mock.calls` / `.results` / `.settledResults` / `.instances` / `.contexts` /
1203
+ `.lastCall`, the whole `mockReturnValue` / `mockResolvedValue` / `mockImplementation` family,
1204
+ `withImplementation`, `mockClear` / `mockReset` / `mockRestore`, `mockName`, `using`.
1205
+ - `vi.clearAllMocks()`, `vi.resetAllMocks()`, and the `clearMocks` / `mockReset` config keys, which
1206
+ Vitest applies through those two.
1207
+
1208
+ It is measured in bytes as well as microseconds. A materialised method went from **5 445 B to
1209
+ 1 929 B** — the hand-written control, which is the runner's own mock with no library in the way,
1210
+ retains 5 169 B — and an eagerly built double that nothing calls went from 4 418 to **632 B per
1211
+ method**, because a spy that is never called now allocates none of the six arrays `vi.fn()`
1212
+ allocates up front. Full tables:
1213
+ [Performance → retained memory per double](https://asdalexey.github.io/vitest-auto-spy/core/performance#retained-memory-per-double).
1214
+
1215
+ **The one difference.** `mock.invocationCallOrder` counts on this library's own scale, so
1216
+ `expect(a).toHaveBeenCalledBefore(b)` is exact between two auto-spies and meaningless between an
1217
+ auto-spy and a hand-written `vi.fn()`. If a suite needs that comparison, switch the run back:
1218
+
1219
+ ```ts
1220
+ // vitest.setup.ts
1221
+ import { setSpyEngine } from 'vitest-auto-spy/setup';
1222
+
1223
+ setSpyEngine('runner'); // every double built afterwards uses vi.fn() per method
1224
+ ```
1225
+
1226
+ The switch is Vitest-only. On Bun and `node:test` the runner's own matchers recognise only the
1227
+ runner's own mocks, so those entries keep building spies from `mock()` and `t.mock.fn()`.
1228
+
1229
+ ## Benchmarks
1230
+
1231
+ Wall-clock at suite scale varies **15-20% between invocations** of the identical configuration —
1232
+ the same cell measured 1.56 s and 1.33 s at 1 000 tests, 10.94 s and 12.51 s at 10 000. Peak RSS, by
1233
+ contrast, reproduces tightly and separates arms by multiples. Treat the ratios below as the durable
1234
+ figure and any single wall-clock number as describing one machine, not a promise about yours.
1235
+
1236
+ **The micro-benchmark below has the same problem, measured directly.** Every micro figure is the
1237
+ **median p75 of seven independent runs**, each its own process, at doubled iteration budgets, run
1238
+ with `npm run bench:vs:precise` — not a single run. What each row's ± column reports is how far that
1239
+ published median can be off: across the 47 rows of the canonical run, a median of **±1.1%** and at
1240
+ worst **±3.8%**. The blunter rule still holds for reading a single local run — **a difference under
1241
+ about 20% is not worth quoting** — and the narrowest margin below is 2.19×.
1242
+
1243
+ `jest-auto-spies@3.0.1`, `jasmine-auto-spies@8.0.1` and `@bugsplat/vitest-auto-spies@1.0.0` are all
1244
+ measured directly here — no more standing in for one another. All three depend on
1245
+ `@hirez_io/auto-spies-core@3.0.0` and differ only in the spy factory they hand it (`jest.fn()`,
1246
+ `jasmine.createSpy()`, `vi.fn()`), and on the micro-benchmark they land within a few per cent of each
1247
+ other on every case — see the 40-methods/3-called row in
1248
+ [Performance](https://asdalexey.github.io/vitest-auto-spy/core/performance#micro-benchmark).
1249
+ `jest-auto-spies` and `jasmine-auto-spies` run here under a minimal `jest` / `jasmine` global backed
1250
+ by `vi.fn()`, so every arm creates the same underlying mock and the runner's own per-mock cost is a
1251
+ shared constant — what these numbers describe is each library's own code, not what a real Jest or
1252
+ Jasmine suite would show.
1253
+
1254
+ The micro-benchmark runs every arm inside a block to the same iteration count (`n`), not the same
1255
+ time budget — these cases allocate doubles by the tens of thousands, and GC cost scales with objects
1256
+ created rather than elapsed time, so a fixed time budget would have handed a faster arm more
1257
+ allocations and made it pay for its own speed.
1258
+
1259
+ **Every micro-benchmark table is this library's**, and the margin is against the *best* other arm in
1260
+ that table. The three class sizes are measured project profiles, not round numbers — across four
1261
+ private Angular suites (~2 700 spec files, 2 742 doubles built from a class) the median service has
1262
+ 5-8 methods and the spec touches 1 of them:
1263
+
1264
+ | Case (micro) | 4.0 | 4.1 | best other arm | lead |
1265
+ | --- | ---: | ---: | ---: | ---: |
1266
+ | small project — 6 methods, 1 called | — | **1.42 µs** | 6.83 µs hand-written | 4.82x |
1267
+ | medium project — 14 methods, 2 called | — | **2.67 µs** | 15.92 µs hand-written | 5.97x |
1268
+ | large project — 45 methods, 2 called | — | **5.79 µs** | 52.79 µs hand-written | 9.11x |
1269
+ | worst case — all 14 of 14 methods called | 18.92 µs (0.66x, a loss) | **8.17 µs** | 17.92 µs hand-written | 2.19x |
1270
+ | worst case — all 45 of 45 methods called | 75.33 µs (0.71x, a loss) | **26.12 µs** | 62.04 µs hand-written | 2.37x |
1271
+ | double from a type — 2 / 10 / 40 members | 3.58 / 18.17 / 72.88 µs (a loss) | **1.00 / 4.67 / 18.92 µs** | 2.79 / 13.83 / 56.79 µs vitest-mock-extended | 2.79-3.00x |
1272
+ | deep double, 3 levels, leaf called | 8.83 µs (0.61x, a loss) | **2.29 µs** | 5.46 µs vitest-mock-extended | 2.38x |
1273
+ | configure a return + 3 calls — class / type | 3.29 / 2.08 µs (type a loss) | **2.21 / 0.71 µs** | 16.38 µs hand-written / 1.58 µs @golevelup | 7.41x / 2.24x |
1274
+ | `calledWith` dispatch, 2 configured + 1 miss | 0.54 µs (parity) | **0.17 µs** | 0.54 µs vitest-mock-extended | 3.25x |
1275
+
1276
+ Six of those rows were losses and one was parity. What changed is that a method spy is no longer a
1277
+ `vi.fn()` — see [The spy engine](#the-spy-engine) — plus one thing that is not the engine: a
1278
+ `calledWith(x)` of a single primitive argument used to be rendered into a string key on *every call*
1279
+ of that spy, where a `Map` keyed by the value does the same lookup with no allocation.
1280
+
1281
+ The three class sizes changed in the same release, from round numbers to measured profiles, so the
1282
+ first two columns are the same operation on a slightly different class where the row says so. Part
1283
+ of the lead is that this library no longer pays the runner's per-mock cost while every other arm
1284
+ still does — a difference in the product rather than in the measurement, and the `hand-written
1285
+ vi.fn() per method` arm is in the table precisely so its size stays visible.
1286
+
1287
+ **Where it still loses, same weight, same table** (`npm run bench:suite`, re-measured 2026-09-03 on
1288
+ the 4.1 build, 20-method class, `isolate: true`, coverage on, two runs of 3 rounds each, medians of
1289
+ all six):
1290
+
1291
+ | Comparison | 1 000 tests | 3 000 tests | 10 000 tests |
1292
+ | --- | ---: | ---: | ---: |
1293
+ | vitest-auto-spy | 1.33 s | 3.19 s | 10.66 s |
1294
+ | hand-written `vi.fn()` | 1.33 s (0.99×) | 3.08 s (0.97×) | 10.07 s (0.94×) |
1295
+ | @bugsplat/vitest-auto-spies | 2.21 s (1.66×) | 5.50 s (1.72×) | 17.40 s (1.63×) |
1296
+
1297
+ Hand-written doubles are still cheaper — **about 5% at the median**, down from 10-15% before 4.1,
1298
+ with the 18 per-round ratios spread 0.81-1.01 — and that row is the proof that micro-benchmark
1299
+ multipliers do not transfer to suite scale: building a double is on the order of 1% of a test's cost,
1300
+ so a 10× win on the double shows up as a few per cent on the run, and nearly everything else in a
1301
+ suite swamps it.
1302
+
1303
+ **Memory, `isolate: false`, 100-method class, 10 000 tests, peak RSS:**
1304
+
1305
+ | Arm | Peak RSS |
1306
+ | --- | ---: |
1307
+ | hand-written `vi.fn()` | 6733 MB |
1308
+ | vitest-auto-spy, default | 2475 MB |
1309
+ | vitest-auto-spy, `lazySpies: 'proxy'` | 2109 MB |
1310
+
1311
+ Hand-written doubles use 2.7x the default's peak RSS here — the difference between finishing and an
1312
+ OOM'd worker on a large suite.
1313
+
1314
+ That gap traces down to a single double: on the same 100-method class, untouched, `lazySpies:
1315
+ 'proxy'` retains **4 097 B** per double against a hand-written double's **414 462 B** — the
1316
+ per-double number that gets multiplied by every double a file holds alive, and the setting that
1317
+ decides whether a large suite survives `isolate: false`. Full retained-memory tables:
1318
+ [Performance](https://asdalexey.github.io/vitest-auto-spy/core/performance#retained-memory-per-double).
1319
+
1320
+ **Guidance keyed to suite size:** `isolate: false` is worth about 3x on its own — more than any
1321
+ library choice measured here — and it makes memory, not wall-clock, the binding constraint. On a
1322
+ wide class under `isolate: false`, add `lazySpies: 'proxy'` for about 15% less peak RSS. Do not turn
1323
+ `'proxy'` on under normal isolation (`isolate: true`): there it measurably does nothing (5/7 rounds
1324
+ either side of parity).
1325
+
1326
+ Full write-up, methodology and the complete suite-scale tables:
1327
+ [Performance](https://asdalexey.github.io/vitest-auto-spy/core/performance).
1328
+
1329
+ ### Reproducing the numbers
1330
+
1331
+ Node **>=18** (the project floor); the numbers above were captured on Node v24.19.0. The steps are
1332
+ identical on Windows, macOS and Linux — same Node, same npm, same commands, nothing OS-specific to
1333
+ call out:
1334
+
1335
+ ```bash
1336
+ git clone https://github.com/ASDAlexey/vitest-auto-spy.git
1337
+ cd vitest-auto-spy
1338
+ npm ci
1339
+ npm ci --prefix bench
1340
+ npm run bench:vs
1341
+ ```
1342
+
1343
+ Two installs are intentional: the competitor packages live in `bench/package.json`, kept out of the
1344
+ root so it carries nothing it doesn't ship or test with, and `bench/.npmrc` pins
1345
+ `legacy-peer-deps=true` so npm doesn't install a second copy of `vitest` beside the root one — two
1346
+ copies would mean two mock registries and the run dies out of memory.
1347
+
1348
+ `npm run bench:vs` is the micro-benchmark, a single run in about 55 seconds — the right form for
1349
+ local iteration. `npm run bench:vs -- --repeat 5` is what produced the median p75 figures published
1350
+ above, about 4.5 minutes for five independent runs in five processes. For the suite-scale tables, see
1351
+ `npm run bench:suite --help` — it takes **tens of minutes at 10 000 tests**, so budget for that
1352
+ before running it locally.
1353
+
1354
+ The same benchmark also runs in CI — on a monthly schedule, on any pull request touching `bench/**`
1355
+ or `src/lib/**`, and on demand — so a run you didn't trigger yourself is checkable in
1356
+ [Actions → Benchmarks](https://github.com/ASDAlexey/vitest-auto-spy/actions/workflows/bench.yml).
1357
+
1132
1358
  ## Migrating from jest-auto-spies
1133
1359
 
1134
1360
  The public API is intentionally identical. In most projects the migration is a
@@ -2211,6 +2437,7 @@ single-purpose utility you can pick up independently — they all ride on the sa
2211
2437
  | `restoreTimerGlobals()` | `/setup` | Put back timer globals that uninstalling the fakes deleted rather than restored |
2212
2438
  | `trackMockRegistry()` / `keepMockRegistered(mock)` / `restoreLongLivedImplementations()` | `/setup` | Keep @vitest/spy's mock registry to the mocks that outlive a file; mark one the split would miss; put back an implementation a cross-file `vi.resetAllMocks()` dropped ([details](#test-run-hygiene)) |
2213
2439
  | `trackNodeMocks()` / `pruneNodeMocks()` / `countNodeMocks()` | `/node` | Give this library its own `node:test` `MockTracker` so a dropped spy is freed — 21× less retained heap; sweep by hand, and read the count back |
2440
+ | `setSpyEngine(engine)` / `getSpyEngine()` | `/setup` | Build method spies from this library's own mock (`'auto-spy'`, the default) or from `vi.fn()` (`'runner'`) ([details](#the-spy-engine)) |
2214
2441
  | `errorHandler` | core | The `mustBeCalledWith` argument-mismatch reporter — swap it to customize failure output |
2215
2442
 
2216
2443
  A taste of the DI pair — provide the spy, inject it back fully typed:
@@ -2871,7 +3098,7 @@ as `methodsToSpyOn`, named for callables that live on the instance — `signal()
2871
3098
  `gettersToSpyOn`, `settersToSpyOn`, `autoSpyAccessors` (discover every getter/setter),
2872
3099
  `fillMissing` (answer a name the prototype never carried with a spy — for a **partially** abstract
2873
3100
  class, where `abstract` members are erased and the empty-prototype fallback no longer fires),
2874
- `lazySpies` (materialize method spies on first access — cheaper for wide classes; the `provideAutoSpy` default on Angular. `'proxy'` keeps the laziness and drops the per-method placeholder: 11.8 kB retained against 101.6 kB on a 400-method class, at +30 ns per read — opt-in, and worth it only above ~20 methods)
3101
+ `lazySpies` (materialize method spies on first access — cheaper for wide classes; the `provideAutoSpy` default on Angular. `'proxy'` keeps the laziness and drops the per-method placeholder — no memory crossover at any width measured, lighter than the default from 10 methods up (4 097 B vs the default's 25 601 B per double retained, untouched, on a 100-method class); it costs +30 ns per read once a method has materialised, which only pays back in **time** on classes above ~20 methods — see [Performance](https://asdalexey.github.io/vitest-auto-spy/core/performance#retained-memory-per-double))
2875
3102
 
2876
3103
  `ValueConfig` (for `nextWithValues`): `{ value, delay? }` | `{ errorValue, delay? }` | `{ complete?, delay? }`.
2877
3104