vitest-auto-spy 4.0.1 → 4.2.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,19 +261,23 @@ 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
267
- `TestBed` instead. The only two settings that cost: `{ lazySpies: false }` gives up the laziness
268
- `provideAutoSpy` defaults to, and `autoSpyAccessors: true` walks the prototype chain uncached on
269
- every call — name the accessors instead.
264
+ Building a spy is not a thing to optimise. On a ten-method class, `npm run bench` on Node v24.19.0
265
+ and Vitest 4.1.11, measured 2026-09-04: `createSpyFromClass` plus two methods called **2.25 µs**,
266
+ plus all ten 5.83 µs, `createAutoMock` plus four members 1.79 µs, a `calledWith` lookup 0.21 µs —
267
+ five providers across two thousand tests is about two hundredths of a second. Call the factory in
268
+ `beforeEach` and look at `TestBed` instead. The only two settings that cost:
269
+ `{ lazySpies: false }` gives up the laziness `provideAutoSpy` defaults to, and
270
+ `autoSpyAccessors: true` walks the prototype chain uncached on every call — name the accessors
271
+ instead.
270
272
 
271
273
  Memory is the exception, and only on classes wide enough for it to matter. `lazySpies: true` still
272
274
  defines one accessor per method, and that placeholder is nearly all of what an untouched double
273
- retains: 101.6 kB on a 400-method class against 11.8 kB with `lazySpies: 'proxy'`, which answers
274
- every method from a single trap object instead (25 B per method against 253 B). It is opt-in because
275
- a `Proxy` cannot remove itself — +30 ns per read and +43 ns per call, forever — and it loses on a
276
- five-method service. Reach for it on generated API clients and ngrx facades under `isolate: false`,
275
+ retains: an untouched 100-method double holds 25 601 B against 4 097 B with `lazySpies: 'proxy'`,
276
+ which answers every method from a single trap object instead — 256 B per method against 41 B
277
+ (`npm run bench:memory`, 2026-09-04). It is opt-in because a `Proxy` cannot remove itself: the
278
+ default leaves a plain data property behind once a method materialises, while the proxy goes through
279
+ a trap on every read and every call for the life of the double, and on a narrow class it is also
280
+ slower to create. Reach for it on generated API clients and ngrx facades under `isolate: false`,
277
281
  where the doubles of a whole file are alive at once; leave the default everywhere else.
278
282
 
279
283
  ---
@@ -1053,6 +1057,48 @@ is therefore remembered and put back, in `beforeEach`, but only when it has gone
1053
1057
  returns how many went), `restoreLongLivedImplementations()` is that repair (it returns how many it
1054
1058
  put back) and `getMockRegistrySize()` reports what is left.
1055
1059
 
1060
+ One entry is never pruned, and it is not one of yours: the Vitest adapter registers a single
1061
+ `vi.fn()` whose `mockClear` sweeps this library's own spies. Dropping it would turn
1062
+ `vi.clearAllMocks()` into a silent no-op for every double in the run, so it carries a mark the
1063
+ pruner skips. Why the library needs a mock of its own for that is the next section.
1064
+
1065
+ ### What a method spy is, and the one thing that differs from `vi.fn()`
1066
+
1067
+ A method spy is **not** a `vi.fn()`. It is this library's own mock function: one shared prototype
1068
+ carrying the whole `Mock` surface, call state allocated on the first call rather than at creation,
1069
+ and no entry in any global registry. `vi.fn()` assigns some twenty-five closures as own properties
1070
+ of every mock it makes and allocates six arrays up front — per method, on every double a spec
1071
+ builds — and that cost is most of what a wide service used to pay for methods no test touches.
1072
+
1073
+ Everything a spec can observe is the same, and the suite pins it by putting the two side by side:
1074
+
1075
+ - `vi.isMockFunction(spy.load)`, `expect(spy.load).toHaveBeenCalledWith(…)`, `toHaveReturned`,
1076
+ `toHaveResolved`, `toHaveBeenNthCalledWith` — every matcher reads `_isMockFunction`, `mock.*` and
1077
+ `getMockName()`, all of which the spy implements with the runner's semantics.
1078
+ - `spy.load.mock.calls` / `.results` / `.settledResults` / `.instances` / `.contexts` /
1079
+ `.invocationCallOrder` / `.lastCall`, `mockReturnValue`, `mockResolvedValue`,
1080
+ `mockImplementation(Once)`, `withImplementation`, `mockClear` / `mockReset` / `mockRestore`,
1081
+ `mockName`, `using`.
1082
+ - `vi.clearAllMocks()`, `vi.resetAllMocks()`, and the `clearMocks` / `mockReset` config keys, which
1083
+ Vitest applies through those two functions.
1084
+
1085
+ **The one difference.** `mock.invocationCallOrder` counts on this library's own scale, so
1086
+ `expect(a).toHaveBeenCalledBefore(b)` is exact between two auto-spies and meaningless between an
1087
+ auto-spy and a hand-written `vi.fn()` — the two counters never met. Nothing else in the library or in
1088
+ Vitest reads that field. If a suite needs the comparison, switch the whole run back to the runner's
1089
+ factory:
1090
+
1091
+ ```ts
1092
+ // vitest.setup.ts
1093
+ import { setSpyEngine } from 'vitest-auto-spy/setup';
1094
+
1095
+ setSpyEngine('runner'); // every double built afterwards is `vi.fn()` per method, as before 4.1
1096
+ ```
1097
+
1098
+ `getSpyEngine()` reports the current one, `'auto-spy'` is the default, and doubles already built keep
1099
+ the engine they were built with. The switch is Vitest-only: on Bun and `node:test` the runner's own
1100
+ matchers recognise only the runner's own mocks, so those adapters keep using them.
1101
+
1056
1102
  Two more switches, both about the environment rather than the spies:
1057
1103
 
1058
1104
  ```ts
@@ -2058,20 +2104,30 @@ thing to check by hand after the swap: Suites' double answers every property nam
2058
2104
  stubbed a since-renamed method was passing over nothing and will now fail. Full mapping:
2059
2105
  <https://asdalexey.github.io/vitest-auto-spy/migrating-suites>.
2060
2106
 
2061
- - **`renderShallow` has no single speed number, and quoting one is a bug.** Per render it ranges
2062
- from 1.2× (no children) to 16.2× (400 child instances) — `docs-site/core/performance.md`,
2063
- measured 2026-08-26. Per spec file, where imports and the `TestBed` module are also on the clock,
2064
- the published conversion is 1.7× over three specs with one of them a 0.8× regression. The two are
2065
- not interchangeable: the per-render ratio is the upper bound on what a file can gain. Any surface
2066
- that mentions the helper's speed quotes the range and links to
2067
- `/core/performance#_2-rendering-the-child-subtree`. A figure of **4.1×** appears in
2068
- `PRIORITIES.md` and is backed by nothing in this repository — do not publish it until someone
2069
- produces the measurement.
2107
+ - **`renderShallow` has no single speed number, and quoting one is a bug.** This repository commits
2108
+ no harness for the helper, so no surface publishes a ratio for it and none may be invented.
2109
+ Describe the shape instead: `renderShallow` is flat in the number of children because it never
2110
+ builds the subtree, while `TestBed.createComponent` scales linearly with it — so the win is
2111
+ however much markup the component under test happens to own, nothing on a leaf component and a
2112
+ great deal on a table or a dashboard. A per-render ratio would in any case be the upper bound on
2113
+ what a spec file can gain rather than a prediction of it, because a file also pays for imports,
2114
+ the `TestBed` module and the assertions. Link to
2115
+ `/core/performance#_2-rendering-the-child-subtree`. A speed figure for the helper also sits in
2116
+ `PRIORITIES.md`, backed by nothing in this repository — do not publish it until someone produces
2117
+ the measurement.
2070
2118
  - **Competitor defects are quoted from the published tarball, with a file, a line and a date.** The
2071
2119
  `@testing-library/angular` `createMock` claims in `docs-site/comparison.md` are pinned to
2072
2120
  `fesm2022/testing-library-angular-vitest-utils.mjs` lines 14 and 18 at 19.4.2. Re-`npm pack` and
2073
2121
  re-read before restating them against a new version; the "Where the numbers come from" box records
2074
2122
  each re-verification date rather than replacing the previous one.
2123
+ - **Head-to-head benchmark numbers are quoted from the measurement, not from memory.** `npm run
2124
+ bench:vs` and `npm run bench:suite` (see `CONTRIBUTING.md` for setup) back the claims in
2125
+ `core/performance.md`; point at that page rather than retyping a figure here, so it lives in one
2126
+ place. Micro-benchmark ratios do not transfer to suite scale: double construction is on the order
2127
+ of 1% of a test's cost, and an advantage that looks like several times faster per double can be
2128
+ gone, or reversed, once a whole suite runs. Suite-scale wall-clock is noisy between invocations;
2129
+ peak RSS reproduces far more tightly and is the safer claim. Never state a magnitude the source
2130
+ marks as not established.
2075
2131
 
2076
2132
  `node:test` keeps every `mock.fn()` in one process-wide `MockTracker` for the life of the process.
2077
2133
  On a long suite that is real memory — 20 000 spies of a 10-method class retained 124.5 MB on
@@ -2521,6 +2577,35 @@ worth one run after any large edit to a test suite — especially after a codemo
2521
2577
  the eaten glob below came from. Full reference:
2522
2578
  <https://asdalexey.github.io/vitest-auto-spy/utilities/cli>.
2523
2579
 
2580
+ ### If you were asked why a suite is slow
2581
+
2582
+ ```bash
2583
+ npx vitest-auto-spy perf # runs the suite once and reports; always exits 0
2584
+ npx vitest-auto-spy perf src/some/dir # a path is passed through to Vitest as a file filter
2585
+ ```
2586
+
2587
+ It reads Vitest's own per-file phase timings through `TestModule.diagnostic()` — nothing here
2588
+ parses terminal output — and reports six phases: `environment`, `import`, `tests`, `setup`,
2589
+ `prepare` (all measured per file) and `transform` (measured once for the whole run). **The phase
2590
+ totals are CPU time summed across every worker, not wall clock** — a report showing `20.29s of CPU
2591
+ time` under `986ms wall clock` is not a bug, it is the work spread over workers.
2592
+
2593
+ When `environment` dominates, the `perf-environment` finding names spec files that could run under
2594
+ the `node` environment instead — but only the ones it could _prove_ reach no DOM: the spec, its
2595
+ configured setup files, and every repository module any of them imports were all read, none
2596
+ mentions a DOM name, and every package they import is on a short allowlist (`vitest`, `rxjs`,
2597
+ `date-fns`, `lodash`, `zod`, …). Anything it could not resolve is reported **undecided**, never
2598
+ assumed safe — do not treat an undecided file as a candidate, and do not add packages to that list
2599
+ yourself; a false positive breaks someone's suite on `document is not defined`. When `import`
2600
+ dominates, `perf-import` names specs that reach their subject through a barrel (`index`/`public-api`
2601
+ re-exporting a whole directory). When `environment` + `setup` + `prepare` together dominate,
2602
+ `perf-isolation` suggests `test.isolate: false` and links to this package's own memory measurements
2603
+ of that trade rather than restating them — read that page before recommending the flag, since it
2604
+ raises peak memory.
2605
+
2606
+ `--json <path>` re-analyses a report an earlier `--out <path>` run wrote, instead of running Vitest
2607
+ again. Full reference: <https://asdalexey.github.io/vitest-auto-spy/utilities/cli>.
2608
+
2524
2609
  ### Migrating a suite off `jest-auto-spies` — run the codemod, then verify it
2525
2610
 
2526
2611
  ```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)
@@ -53,7 +55,7 @@ identical API, with **RxJS** spies and **Angular / NestJS / React / Vue·Pinia /
53
55
  - 📡 First-class RxJS `Observable` spying (`nextWith`, `nextWithValues`, `throwWith`, …)
54
56
  - ⚙️ Getter / setter spies via `accessorSpies` — **including on Bun**, where `bun:test`'s own `spyOn(obj, 'prop', 'get')` throws _"does not support accessor properties yet"_ (verified on Bun 1.4.0); no library that generates a double from a class or a type has accessor spies on any runner
55
57
  - 🧰 DI & mocking utilities — `provideAutoSpy` / `injectSpy` (Angular, NestJS, Vue), `createFunctionSpy`, `mockReadonlyProp` for signals
56
- - ⚡ Angular speed & zoneless helpers — `renderShallow` (**1.7×** across three converted specs of a real suite; per render, **1.2×** on a childless leaf up to **16.2×** on a 400-child tree — [both measurements below](#shallow-component-rendering)), `createWithAutoSpies`, `stable` / `flushEffects`, `settleResource` for `httpResource()`, `toHaveSignalValue`, per-file `TestBed` timings
58
+ - ⚡ Angular speed & zoneless helpers — [`renderShallow`](#shallow-component-rendering), which brings a component up through the real `TestBed` without its child subtree, `createWithAutoSpies`, `stable` / `flushEffects`, `settleResource` for `httpResource()`, `toHaveSignalValue`, per-file `TestBed` timings
57
59
  - **`node:test` stops retaining every spy.** Node's built-in runner keeps every `mock.fn()` in one
58
60
  process-wide `MockTracker` and has no way to drop a single entry, so a long suite accumulates every
59
61
  spy it ever made along with every argument they recorded. `trackNodeMocks()` gives this library a
@@ -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)
@@ -217,7 +223,7 @@ all**, because Vite 7 calls `crypto.hash` (added in Node 20.12) and the run dies
217
223
  `TypeError: crypto.hash is not a function` before a spec loads. On Vitest ≤ 3 Node 18 is fine. Which
218
224
  version to actually run is measured in
219
225
  [Performance → Which Node version](https://vitest-auto-spy.dev/core/performance#which-node-version):
220
- the core is 9–15% faster from Node 24 on, and a cold import more than halves.
226
+ the break is between 22 and 24, where a cold import more than halves.
221
227
 
222
228
  Ships **ESM with bundled `.d.ts` types**. Two subpaths additionally ship a CommonJS build —
223
229
  `vitest-auto-spy/node` (a `node --test` suite written in CJS) and `vitest-auto-spy/eslint-plugin`
@@ -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,178 @@ 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 is the noisiest thing here, and the noise does not disappear inside a
1232
+ single invocation: the suite harness prints its own widest round-to-round spread, and in the run
1233
+ below that was **16%** of the cell's median — the three rounds of the 1 000-test cell came out at
1234
+ 1.35 / 1.33 / 1.32 s and the 10 000-test cell at 10.02 / 10.65 / 9.72 s. Peak RSS, by contrast,
1235
+ reproduces tightly and separates arms by multiples. Treat the ratios below as the durable figure and
1236
+ any single wall-clock number as describing one machine, not a promise about yours.
1237
+
1238
+ **The micro-benchmark below has the same problem, measured directly.** Every micro figure is the
1239
+ **median p75 of seven independent runs**, each its own process, at doubled iteration budgets, run
1240
+ with `npm run bench:vs:precise` — not a single run. What each row's ± column reports is how far that
1241
+ published median can be off: across the 47 rows of the 2026-09-04 run, a median of **±0.9%** and at
1242
+ worst **±6.3%** — on the `calledWith` dispatch row, the fastest and therefore the one closest to the
1243
+ stand's own floor. The blunter rule still holds for reading a single local run — **a difference under
1244
+ about 20% is not worth quoting** — and the narrowest margin below is 2.19×.
1245
+
1246
+ `jest-auto-spies@3.0.1`, `jasmine-auto-spies@8.0.1` and `@bugsplat/vitest-auto-spies@1.0.0` are all
1247
+ measured directly here — no more standing in for one another. All three depend on
1248
+ `@hirez_io/auto-spies-core@3.0.0` and differ only in the spy factory they hand it (`jest.fn()`,
1249
+ `jasmine.createSpy()`, `vi.fn()`), and on the micro-benchmark they land within a few per cent of each
1250
+ other on every case — see the 40-methods/3-called row in
1251
+ [Performance](https://asdalexey.github.io/vitest-auto-spy/core/performance#micro-benchmark).
1252
+ `jest-auto-spies` and `jasmine-auto-spies` run here under a minimal `jest` / `jasmine` global backed
1253
+ by `vi.fn()`, so every arm creates the same underlying mock and the runner's own per-mock cost is a
1254
+ shared constant — what these numbers describe is each library's own code, not what a real Jest or
1255
+ Jasmine suite would show.
1256
+
1257
+ The micro-benchmark runs every arm inside a block to the same iteration count (`n`), not the same
1258
+ time budget — these cases allocate doubles by the tens of thousands, and GC cost scales with objects
1259
+ created rather than elapsed time, so a fixed time budget would have handed a faster arm more
1260
+ allocations and made it pay for its own speed.
1261
+
1262
+ **Every micro-benchmark table is this library's**, and the margin is against the *best* other arm in
1263
+ that table. The three class sizes are measured project profiles, not round numbers — across four
1264
+ private Angular suites (~2 700 spec files, 2 742 doubles built from a class) the median service has
1265
+ 5-8 methods and the spec touches 1 of them:
1266
+
1267
+ | Case (micro) | 4.0 | 4.1 | best other arm | lead |
1268
+ | --- | ---: | ---: | ---: | ---: |
1269
+ | small project — 6 methods, 1 called | — | **1.42 µs** | 6.83 µs hand-written | 4.82x |
1270
+ | medium project — 14 methods, 2 called | — | **2.67 µs** | 15.92 µs hand-written | 5.97x |
1271
+ | large project — 45 methods, 2 called | — | **5.79 µs** | 52.79 µs hand-written | 9.11x |
1272
+ | 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 |
1273
+ | 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 |
1274
+ | 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 |
1275
+ | deep double, 3 levels, leaf called | 8.83 µs (0.61x, a loss) | **2.29 µs** | 5.46 µs vitest-mock-extended | 2.38x |
1276
+ | 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 |
1277
+ | `calledWith` dispatch, 2 configured + 1 miss | 0.54 µs (parity) | **0.17 µs** | 0.54 µs vitest-mock-extended | 3.25x |
1278
+
1279
+ Six of those rows were losses and one was parity. What changed is that a method spy is no longer a
1280
+ `vi.fn()` — see [The spy engine](#the-spy-engine) — plus one thing that is not the engine: a
1281
+ `calledWith(x)` of a single primitive argument used to be rendered into a string key on *every call*
1282
+ of that spy, where a `Map` keyed by the value does the same lookup with no allocation.
1283
+
1284
+ The three class sizes changed in the same release, from round numbers to measured profiles, so the
1285
+ first two columns are the same operation on a slightly different class where the row says so. Part
1286
+ of the lead is that this library no longer pays the runner's per-mock cost while every other arm
1287
+ still does — a difference in the product rather than in the measurement, and the `hand-written
1288
+ vi.fn() per method` arm is in the table precisely so its size stays visible.
1289
+
1290
+ **Where it still loses, same weight, same table** (`npm run bench:suite`, re-measured 2026-09-04 on
1291
+ the 4.1 build, 20-method class, `isolate: true`, coverage on, 3 rounds per cell after a discarded
1292
+ warm-up, medians of the three):
1293
+
1294
+ | Comparison | 1 000 tests | 3 000 tests | 10 000 tests |
1295
+ | --- | ---: | ---: | ---: |
1296
+ | vitest-auto-spy | 1.33 s | 2.86 s | 10.02 s |
1297
+ | hand-written `vi.fn()` | 1.31 s (0.99×) | 2.85 s (1.00×) | 9.25 s (0.92×) |
1298
+ | @bugsplat/vitest-auto-spies | 2.00 s (1.50×) | 4.61 s (1.61×) | 15.45 s (1.54×) |
1299
+
1300
+ Hand-written doubles are still cheaper — **about 3% at the median**, down from 10-15% before 4.1,
1301
+ with the nine per-round ratios spread 0.84-1.00 — and that row is the proof that micro-benchmark
1302
+ multipliers do not transfer to suite scale: building a double is on the order of 1% of a test's cost,
1303
+ so a 10× win on the double shows up as a few per cent on the run, and nearly everything else in a
1304
+ suite swamps it.
1305
+
1306
+ **Memory, `isolate: false`, 100-method class, 10 000 tests, peak RSS:**
1307
+
1308
+ | Arm | Peak RSS |
1309
+ | --- | ---: |
1310
+ | hand-written `vi.fn()` | 6366 MB |
1311
+ | vitest-auto-spy, default | 2103 MB |
1312
+ | vitest-auto-spy, `lazySpies: 'proxy'` | 1851 MB |
1313
+
1314
+ Hand-written doubles use 3.0x the default's peak RSS here — the difference between finishing and an
1315
+ OOM'd worker on a large suite.
1316
+
1317
+ That gap traces down to a single double: on the same 100-method class, untouched, `lazySpies:
1318
+ 'proxy'` retains **4 097 B** per double against a hand-written double's **414 462 B** — the
1319
+ per-double number that gets multiplied by every double a file holds alive, and the setting that
1320
+ decides whether a large suite survives `isolate: false`. Full retained-memory tables:
1321
+ [Performance](https://asdalexey.github.io/vitest-auto-spy/core/performance#retained-memory-per-double).
1322
+
1323
+ **Guidance keyed to suite size:** `isolate: false` is worth about 4x on its own — more than any
1324
+ library choice measured here — and it makes memory, not wall-clock, the binding constraint. On a
1325
+ wide class under `isolate: false`, add `lazySpies: 'proxy'` for roughly another 12% off peak RSS. Do
1326
+ not turn `'proxy'` on under normal isolation (`isolate: true`): there it measurably does nothing — a
1327
+ median of exactly 1.00× across five rounds.
1328
+
1329
+ Full write-up, methodology and the complete suite-scale tables:
1330
+ [Performance](https://asdalexey.github.io/vitest-auto-spy/core/performance).
1331
+
1332
+ ### Reproducing the numbers
1333
+
1334
+ Node **>=18** (the project floor); the numbers above were captured on Node v24.19.0. The steps are
1335
+ identical on Windows, macOS and Linux — same Node, same npm, same commands, nothing OS-specific to
1336
+ call out:
1337
+
1338
+ ```bash
1339
+ git clone https://github.com/ASDAlexey/vitest-auto-spy.git
1340
+ cd vitest-auto-spy
1341
+ npm ci
1342
+ npm ci --prefix bench
1343
+ npm run bench:vs
1344
+ ```
1345
+
1346
+ Two installs are intentional: the competitor packages live in `bench/package.json`, kept out of the
1347
+ root so it carries nothing it doesn't ship or test with, and `bench/.npmrc` pins
1348
+ `legacy-peer-deps=true` so npm doesn't install a second copy of `vitest` beside the root one — two
1349
+ copies would mean two mock registries and the run dies out of memory.
1350
+
1351
+ `npm run bench:vs` is the micro-benchmark, a single run in about a minute — the right form for
1352
+ local iteration. `npm run bench:vs:precise` is what produced the median p75 figures published
1353
+ above, about eleven minutes for seven independent runs in seven processes. For the suite-scale tables, see
1354
+ `npm run bench:suite --help` — it takes **tens of minutes at 10 000 tests**, so budget for that
1355
+ before running it locally.
1356
+
1357
+ The same benchmark also runs in CI — on a monthly schedule, on any pull request touching `bench/**`
1358
+ or `src/lib/**`, and on demand — so a run you didn't trigger yourself is checkable in
1359
+ [Actions → Benchmarks](https://github.com/ASDAlexey/vitest-auto-spy/actions/workflows/bench.yml).
1360
+
1132
1361
  ## Migrating from jest-auto-spies
1133
1362
 
1134
1363
  The public API is intentionally identical. In most projects the migration is a
@@ -1648,9 +1877,9 @@ beforeEach(() => {
1648
1877
 
1649
1878
  > **Lazy by default, everywhere.** Every factory builds each method spy on first access
1650
1879
  > (`lazySpies: true`), since Angular tests typically spy a wide service but call
1651
- > only a few of its methods — roughly **4× faster** spy assembly (≈8× on a
1652
- > 20-method service). Behaviour is unchanged; pass `{ lazySpies: false }` to build
1653
- > every spy eagerly.
1880
+ > only a few of its methods — and the margin widens as the class does: **1.4×** on a ten-method
1881
+ > class a test calls twice, **1.9×** on a forty-method one it calls three times. Behaviour is
1882
+ > unchanged; pass `{ lazySpies: false }` to build every spy eagerly.
1654
1883
 
1655
1884
  #### Signal / readonly property mocking (bonus)
1656
1885
 
@@ -1717,46 +1946,24 @@ const { fixture, component } = renderShallow(TaskListComponent, {
1717
1946
 
1718
1947
  `fixture` is a real `ComponentFixture`; nothing here replaces `@angular/core/testing`.
1719
1948
 
1720
- **What it saves, measured.** On a private Angular 22 zoneless suite (784 specs, the AOT
1721
- `@angular/build:unit-test` builder), three of its most expensive component specs were converted and
1722
- the ten-file batch re-run three times — medians, same batch, same machine:
1723
-
1724
- | Spec (479 tests in the batch, all still green) | Before | After | Change |
1725
- | ---------------------------------------------- | ------ | ------ | -------- |
1726
- | a container with a deep child tree (34 tests) | 129 ms | 61 ms | **2.1×** |
1727
- | a list rendering 58 fixtures | 133 ms | 75 ms | **1.8×** |
1728
- | a small leaf component (20 tests) | 29 ms | 38 ms | **0.8×** |
1729
- | the three together | 291 ms | 174 ms | **1.7×** |
1730
-
1731
- The third row is the honest half of the result: a leaf component has almost no subtree to remove, so
1732
- the per-test `overrideComponent` costs more than it saves. **Shallow rendering
1733
- pays where there is a real child tree to skip** — the seven untouched files in the same batch moved
1734
- by ±10%, which is this suite's run-to-run noise, so the two wins are outside it and the one
1735
- regression is only just outside.
1736
-
1737
- **Why 1.7× and not more.** That row is a whole spec file's wall clock — imports, the `TestBed`
1738
- module, the assertions — of which rendering is only a part. Isolated to the render itself, the
1739
- saving tracks the size of the subtree removed, measured on 2026-08-26 on a component holding two
1740
- `@for` tables whose row count is varied:
1741
-
1742
- | Child instances | `TestBed.createComponent` | `renderShallow` | Ratio |
1743
- | --------------: | ------------------------: | --------------: | ----: |
1744
- | 0 | 0.65 ms | 0.55 ms | 1.2× |
1745
- | 10 | 1.00 ms | 0.55 ms | 1.8× |
1746
- | 100 | 2.72 ms | 0.48 ms | 5.7× |
1747
- | 400 | 8.52 ms | 0.53 ms | 16.2× |
1748
-
1749
- `renderShallow` is flat at ~0.5 ms because it never builds the subtree; `createComponent` scales
1750
- linearly with it. So there is no single "renderShallow is N× faster" number — a leaf component has
1751
- nothing to save, and a table or a dashboard is an order of magnitude. Treat the ratios as the
1752
- result, not the absolute times, and pick files to convert with the diagnostics below rather than by
1753
- guessing. `keepTemplate: true` is the middle rung — the component's own template with an empty
1754
- subtree, 1.933 ms → 1.074 ms (1.8×), measured 2026-08-30. Full write-up:
1949
+ **What it saves.** The shape is the point: `renderShallow` is flat in the number of children,
1950
+ because it never builds the subtree, while `TestBed.createComponent` scales linearly with it. So the
1951
+ win is not a fixed percentage and there is no single "renderShallow is N× faster" number — on a leaf
1952
+ component with no children there is nothing to remove, and the per-test `overrideComponent` can cost
1953
+ more than it saves; on a table or a dashboard the two curves are nowhere near each other. A
1954
+ per-render ratio is also the upper bound on what a whole spec file can gain rather than a prediction
1955
+ of it: a file's wall clock pays for imports, the `TestBed` module and the assertions too, none of
1956
+ which shallow rendering touches.
1957
+
1958
+ `keepTemplate: true` is the middle rung — the component's own template with an empty subtree.
1959
+ `buildOverride` applies `imports: keepChildren ?? []` whether or not the template is kept, so with
1960
+ `keepTemplate: true` the template renders while every child in it resolves to nothing under
1961
+ `NO_ERRORS_SCHEMA`. Reach for it when the spec reads something the template creates, and keep
1962
+ `keepChildren` for the handful of children it genuinely needs resolvable. Full write-up:
1755
1963
  [Performance](https://asdalexey.github.io/vitest-auto-spy/core/performance#_2-rendering-the-child-subtree).
1756
1964
 
1757
1965
  Use [the diagnostics](#where-a-spec-spends-its-time) to find the files worth converting rather than
1758
- guessing: across those ten files `TestBed` accounted for 820 ms of 3231 ms (25%), but per file the
1759
- share ranged from 13% to 66%.
1966
+ guessing.
1760
1967
 
1761
1968
  #### Building a class with auto-spied dependencies
1762
1969
 
@@ -2140,11 +2347,12 @@ Reach for `provideAutoSpy` on Angular and `createSpyFromClass` everywhere else;
2140
2347
  whose members live on the instance) and `createMock<T>(partial)` for a data shape the code only
2141
2348
  reads.
2142
2349
 
2143
- None of it is worth optimising. On a ten-method class: `provideAutoSpy` ~8 µs per call,
2144
- `createSpyFromClass` ~29 µs, `createAutoMock` ~33 µs, a `calledWith` lookup ~0.7 µs — five
2145
- providers across two thousand tests come to under a tenth of a second for the whole suite.
2146
- `provideAutoSpy` leads because it defaults to `lazySpies: true`, and prototype discovery is
2147
- cached per class either way. Full numbers and the two settings that do cost something are in
2350
+ None of it is worth optimising. On a ten-method class a test calls two methods of:
2351
+ `createSpyFromClass` **2.25 µs** per call, `createAutoMock` plus four accesses **1.79 µs**, a
2352
+ `calledWith` dispatch of three configured calls **0.21 µs** — five providers across two thousand
2353
+ tests is ten thousand calls, about two hundredths of a second for the whole run. `provideAutoSpy`
2354
+ is the same factory with the same lazy default, and prototype discovery is cached per class either
2355
+ way. Full numbers and the two settings that do cost something are in
2148
2356
  [Performance](https://asdalexey.github.io/vitest-auto-spy/core/performance).
2149
2357
 
2150
2358
  ## Utilities
@@ -2211,6 +2419,7 @@ single-purpose utility you can pick up independently — they all ride on the sa
2211
2419
  | `restoreTimerGlobals()` | `/setup` | Put back timer globals that uninstalling the fakes deleted rather than restored |
2212
2420
  | `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
2421
  | `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 |
2422
+ | `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
2423
  | `errorHandler` | core | The `mustBeCalledWith` argument-mismatch reporter — swap it to customize failure output |
2215
2424
 
2216
2425
  A taste of the DI pair — provide the spy, inject it back fully typed:
@@ -2871,7 +3080,7 @@ as `methodsToSpyOn`, named for callables that live on the instance — `signal()
2871
3080
  `gettersToSpyOn`, `settersToSpyOn`, `autoSpyAccessors` (discover every getter/setter),
2872
3081
  `fillMissing` (answer a name the prototype never carried with a spy — for a **partially** abstract
2873
3082
  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)
3083
+ `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
3084
 
2876
3085
  `ValueConfig` (for `nextWithValues`): `{ value, delay? }` | `{ errorValue, delay? }` | `{ complete?, delay? }`.
2877
3086