vitest-auto-spy 5.35.0 → 5.37.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.
Files changed (71) hide show
  1. package/AGENTS.md +58 -1831
  2. package/CHANGELOG.md +177 -3281
  3. package/README.md +885 -872
  4. package/agent-docs/adapters.md +58 -0
  5. package/agent-docs/angular.md +7 -1
  6. package/agent-docs/anti-patterns.md +115 -0
  7. package/agent-docs/checklist.md +273 -0
  8. package/agent-docs/configuration.md +318 -0
  9. package/agent-docs/doubles.md +212 -0
  10. package/agent-docs/errors.md +3 -2
  11. package/agent-docs/eslint.md +2 -2
  12. package/agent-docs/factories.md +381 -0
  13. package/agent-docs/migration-jasmine.md +1 -1
  14. package/agent-docs/observables.md +192 -0
  15. package/agent-docs/properties.md +93 -0
  16. package/agent-docs/return-helpers.md +194 -0
  17. package/agent-docs/setup.md +24 -18
  18. package/agent-docs/waiting.md +70 -0
  19. package/dist/angular-diagnostics.js +10 -10
  20. package/dist/angular-doubles.js +8 -8
  21. package/dist/angular-http.js +4 -4
  22. package/dist/angular-matchers.js +9 -6
  23. package/dist/angular-router.js +5 -5
  24. package/dist/angular.d.ts +1 -1
  25. package/dist/angular.js +86 -3499
  26. package/dist/bun-angular.d.ts +1 -1
  27. package/dist/bun-angular.js +16 -16
  28. package/dist/bun.js +9 -9
  29. package/dist/{chunk-GHP66ADY.js → chunk-2KBRDNIB.js} +4 -4
  30. package/dist/{chunk-ABJTCB5O.js → chunk-2TZCJ3MM.js} +2 -2
  31. package/dist/{chunk-7C6PVODW.js → chunk-3ZBRBPOL.js} +1 -1
  32. package/dist/{chunk-UMBYWKKX.js → chunk-6KP5QMMV.js} +8 -4
  33. package/dist/{chunk-4HLOO2VK.js → chunk-6VPSS4BA.js} +12 -8
  34. package/dist/{chunk-4YZ7PIXM.js → chunk-B6C3K27C.js} +187 -57
  35. package/dist/{chunk-ON4SQZQL.js → chunk-BS432KB2.js} +1 -1
  36. package/dist/{chunk-NVCLX43M.js → chunk-DBC6526L.js} +1 -1
  37. package/dist/{chunk-HCBVNAMT.js → chunk-FOL5RH22.js} +1 -1
  38. package/dist/{chunk-IH7NDMVG.js → chunk-FWXDSPZD.js} +3 -3
  39. package/dist/{chunk-C5H7JCPE.js → chunk-GG7BQKEK.js} +1 -1
  40. package/dist/{chunk-4XDGO2IR.js → chunk-GKSVYPZQ.js} +1 -1
  41. package/dist/{chunk-TQXPN6GV.js → chunk-Q7L4AWI7.js} +1 -1
  42. package/dist/{chunk-4AQIDYTD.js → chunk-QDBRZQJO.js} +1 -1
  43. package/dist/{chunk-LGGUCVT5.js → chunk-RYFF55PV.js} +13 -5
  44. package/dist/{chunk-YVH6M22H.js → chunk-TXDBDMIL.js} +1 -1
  45. package/dist/chunk-YNXQ2WFH.js +23 -0
  46. package/dist/cli.js +40 -14
  47. package/dist/console.js +7 -7
  48. package/dist/dom-stubs.js +202 -131
  49. package/dist/eslint-plugin.cjs +2165 -2143
  50. package/dist/eslint-plugin.d.cts +1 -0
  51. package/dist/eslint-plugin.d.ts +15 -3
  52. package/dist/index.js +266 -143
  53. package/dist/jasmine-compat.js +2 -2
  54. package/dist/jasmine.js +10 -10
  55. package/dist/nestjs.js +10 -10
  56. package/dist/node.cjs +22 -6
  57. package/dist/node.js +22 -6
  58. package/dist/react.js +4 -4423
  59. package/dist/rstest.js +10 -55
  60. package/dist/rxjs.js +2 -2
  61. package/dist/setup.d.ts +106 -117
  62. package/dist/setup.js +1064 -741
  63. package/dist/signal-forms.js +1 -1
  64. package/dist/svelte.js +4 -4423
  65. package/dist/{track-signal-runs-BuJk_Yi8.d.ts → track-signal-runs-C7SrW3Ub.d.ts} +3 -1
  66. package/dist/vue.js +6 -4423
  67. package/dist/zone.js +1 -1
  68. package/package.json +5 -3
  69. package/skills/vitest-auto-spy/SKILL.md +8 -9
  70. package/dist/chunk-DMW7ROVM.js +0 -82
  71. /package/dist/{chunk-DN4MV4FB.js → chunk-5FQZGJLJ.js} +0 -0
package/AGENTS.md CHANGED
@@ -36,23 +36,24 @@ type is the authority when this file and the code disagree.
36
36
 
37
37
  ## How to read this file
38
38
 
39
- No task needs all of it. This file is the core; five long sections live next to it in `agent-docs/`,
40
- each with a stub here that says when to read it. Read by section, not whole:
39
+ This file is the map, small enough to read whole. Sections §1, §3, §6, §7 and §14 are here; every
40
+ other section sits in its own file under `agent-docs/`, with a stub below that says when to read it
41
+ and lists its subsections. Section numbers are the same in every file, so "§5" means
42
+ `agent-docs/configuration.md` wherever it is cited.
41
43
 
42
44
  ```bash
43
45
  D=node_modules/vitest-auto-spy
44
- grep -n '^## ' "$D/AGENTS.md" # the map: every section with its line number
45
- sed -n '<start>,<end>p' "$D/AGENTS.md" # one section, from its heading to the next one
46
- grep -n '^### ' "$D/agent-docs/angular.md" # the map of a topic file
46
+ cat "$D/agent-docs/factories.md" # one topic file, whole
47
+ grep -n '^### ' "$D/agent-docs/angular.md" # the map of a long topic file
47
48
  grep -n -F '<text of the error>' "$D/agent-docs/errors.md" # a failure: its row, with the fix
48
49
  ```
49
50
 
50
51
  | Read | Sections |
51
52
  | --- | --- |
52
- | Always, before writing a spec | §1 entry point, §2 factory, §3 the 90% recipe, §18 do not write this, §19 before you report success |
53
- | When the task touches it | §4 return-type helpers, §5 `createSpyFromClass` configuration, §6 `Spy<T>` vs `T`, §7 resetting, §8 observables, §9 patching properties, §10 setup file (`agent-docs/setup.md`), §11 waiting, §12 doubles for what the code builds, §15 other adapters |
53
+ | Always, before writing a spec | §1 entry point, §2 factory (`agent-docs/factories.md`), §3 the 90% recipe, §18 do not write this (`agent-docs/anti-patterns.md`), §19 before you report success (`agent-docs/checklist.md`) |
54
+ | When the task touches it | §4 return-type helpers (`agent-docs/return-helpers.md`), §5 `createSpyFromClass` configuration (`agent-docs/configuration.md`), §6 `Spy<T>` vs `T`, §7 resetting, §8 observables (`agent-docs/observables.md`), §9 patching properties (`agent-docs/properties.md`), §10 setup file (`agent-docs/setup.md`), §11 waiting (`agent-docs/waiting.md`), §12 doubles for what the code builds (`agent-docs/doubles.md`), §15 other adapters (`agent-docs/adapters.md`) |
54
55
  | Only for that stack | §13 Angular (`agent-docs/angular.md`), §14 `fakeAsync`, §16 ESLint plugin (`agent-docs/eslint.md`), §20 migrating off `jasmine-auto-spies` (`agent-docs/migration-jasmine.md`) |
55
- | Never front to back | §17 Error → fix (`agent-docs/errors.md`): grep the message instead |
56
+ | Never front to back | §17 Error → fix (`agent-docs/errors.md`), §10 setup file and §13 Angular: grep the message or list the `###` headings first |
56
57
 
57
58
  ---
58
59
 
@@ -93,7 +94,7 @@ Add-ons, orthogonal to the runner:
93
94
  | Zone patch | `import 'vitest-auto-spy/zone'` | `fakeAsync` / `waitForAsync` on Vitest (§14) |
94
95
  | jasmine compat | `vitest-auto-spy/jasmine` | `.and` / `.calls` / `.withArgs`, the `jasmine` namespace (§20) |
95
96
 
96
- `vitest-auto-spy/jasmine` is Vitest-only, because it registers the Vitest adapter. On `bun test` and
97
+ `vitest-auto-spy/jasmine` is Vitest-only, because its types are Vitest's. On `bun test` and
97
98
  `node --test` call `enableJasmineCompat()` from `vitest-auto-spy/jasmine-compat` instead.
98
99
 
99
100
  `vitest-auto-spy/setup` is Vitest-only as well — `setupAutoSpy()` registers Vitest hooks. On
@@ -138,381 +139,15 @@ want it as a devDependency anyway; freeing that is a type change and waits for a
138
139
 
139
140
  ## 2. Pick the factory
140
141
 
141
- ```
142
- Do you have a real class at runtime?
143
- ├── yes → createSpyFromClass(Class, config?) → Spy<T>
144
- │ (an `abstract class` DI token counts — see below)
145
- └── no → Is the double CALLED by the code under test?
146
- ├── yes, and it is INJECTED (DI, a field) → createAutoMock<T>(overrides?) → Spy<T>
147
- ├── yes, and it is an ARGUMENT of the function under test, asserted on
148
- │ → autoMocked<T>(overrides?) → T & Spy<T>
149
- ├── yes, and reads chain (a.b.c()) → mockDeep<T>(overrides?) → DeepMockProxy<T>
150
- ├── yes, and CALLS chain (a.b().c()) → mockDeep<T>({}, { selfReturning: true })
151
- └── no, it is only READ (DTO, config, route snapshot)
152
- ├── one spec, a couple of fields → createMock<T>(partial?) → T (no spies)
153
- └── many specs, one shared model → createFixtureFactory<T>(defaults) → (overrides?) => T
154
-
155
- Object already built, other code already holds it?
156
- → createSpyFromInstance(obj, config?) → Spy<T>, patched IN PLACE
157
- One standalone function? → createFunctionSpy<Fn>('name') → FunctionSpy<Fn>
158
- Code under test does `new Foo()`? → a real class? createSpyClass(Foo)
159
- → only a shape? mockConstructor<T>(() => instance)
160
- → on a global? stubConstructor(globalThis, 'Image', factory)
161
- (a vi.fn() rejects `new` — see §12)
162
- ```
163
-
164
- `createAutoMock` and `autoMocked` build the same object; they differ only in the type you get back,
165
- and the question that decides it is **how the double travels**. Through DI, it arrives as `Spy<T>`
166
- and is only ever asserted on — `createAutoMock`. Handed to the function under test as an argument
167
- (`checkEndpoint(url, logger)`, `applyPreferences(target, …)`, `setLocalOverrides(storage, …)`),
168
- it has to satisfy `T` at the call site _and_ expose the spy helpers at the assertion, and
169
- `autoMocked<T>()` is that intersection — otherwise every call site needs an `asInstance()` and the
170
- noise scales with the number of them. Both take the same second argument (`returns`, `name`, `strict`,
171
- `observablePropsToSpyOn`). A `let` assigned in `beforeEach` is declared `AutoMocked<T>`,
172
- not with the intersection spelt out. `returns` also takes an optional method written as a property,
173
- `getMinZoom?: (() => number) | undefined` — the way Leaflet and other third-party typings declare
174
- them.
175
-
176
- `createMock<T>()` is the one to reach for on data shapes — it returns a plain `T`, so it satisfies a
177
- `no-type-assertion` lint rule without an `eslint-disable` on every fixture. `createMock<T>(undefined)`
178
- is the same call as `createMock<T>()` and answers `{}`, **not** `undefined` — a forwarding helper
179
- with an optional `overrides` parameter relies on that. A fixture that means "no value" passes
180
- `undefined` itself: `getters.profile.mockReturnValue(undefined)`, never
181
- `mockReturnValue(createMock<Profile>(undefined))`.
182
-
183
- A fixture **outside** its type on purpose — the `null` or array a backend sends where the type says
184
- object, a payload that has to reach a runtime guard — is `outOfType<T>(value)`: nothing is checked,
185
- the call site says so, and `prefer-create-mock` / `no-ts-expect-error-on-double` accept it where they
186
- would report `as unknown as T` or a `@ts-expect-error`. It takes its type from the slot, so
187
- `mockValueProp(job, 'status', outOfType('UNKNOWN'))` stays on the key-checked overload. Everything else
188
- stays `createMock<T>()`.
189
- On a strict double, `spy.m.mockReturnValue(outOfType(undefined))` reaches a defensive
190
- `?? of(null)` branch of a method typed to return an `Observable`, and counts as configured.
191
-
192
- Under `strict`, `returns: { open: undefined }` marks a method configured whose answer nobody reads
193
- (an ngrx `rxMethod` ref, a snack-bar ref) — accepted for any return type. It is also how a
194
- type-only double under `strict` takes its void calls, one key each:
195
- `createAutoMock<CanvasRenderingContext2D>(undefined, { strict: true, returns: { save: undefined, restore: undefined } })`.
196
-
197
- **`createSpyFromInstance(instance, config?)` is the one case where nothing is constructed.** Every
198
- factory above builds the double, which is no help once the object exists and other code already
199
- points at it — a service a factory built, a third-party client, a half-real `TestBed.inject(X)`. It
200
- patches that object in place and hands the same object back typed as `Spy<T>`, so a closure, a DI
201
- container or a live subscription that captured it first sees the spies:
202
-
203
- ```ts
204
- const client = new PaymentsClient(config); // real, already wired into the code under test
205
- const spy = createSpyFromInstance(client);
206
-
207
- spy.charge.calledWith(100).resolveWith({ ok: true });
208
- await checkout.pay(100);
209
-
210
- restoreSpiedInstance(client); // client.charge is the real method again
211
- ```
212
-
213
- Discovery is the object's own function-valued fields **plus** every prototype method up to but not
214
- including `Object.prototype` — so an arrow-function property needs no `instanceMethodsToSpyOn` here,
215
- and `hasOwnProperty` / `toString` are never replaced. The configuration is `createSpyFromClass`',
216
- minus the two options that describe a double being built rather than an object being patched:
217
- `lazySpies` (the members already exist) and `fillMissing` (an instance is not an erased `abstract`
218
- declaration). Every write is journaled through the same mechanism as the `mock*Prop` helpers, so
219
- three ways back compose — `restoreSpiedInstance(instance)` for one object mid-test,
220
- `restoreMockedProps()` (which `setupAutoSpy()` already runs) for the sweep, and
221
- `using spy = createSpyFromInstance(client)`, whose dispose **restores** rather than merely resets:
222
- the only sense disposal can have for an object the consumer owns. A frozen or sealed instance, and a
223
- member declared `configurable: false`, come back as this package's diagnostic rather than a bare
224
- `TypeError` (§17) — except a writable, enumerable one, whose value is simply replaced in place. Nothing else in the field does this — `vi.mockObject` is Vitest-only,
225
- `sinon.createStubInstance` builds a new object from a constructor instead of patching the one you
226
- hold, and `bun:test` and `node:test` have nothing.
227
-
228
- **`passthrough: true` keeps the patched object working.** Unconfigured methods run the real
229
- implementation (with the instance as `this`) and are still recorded; any configuration takes the
230
- whole method over and `resetAutoSpy` hands it back. The Angular shape is
231
- `createSpyFromInstance(TestBed.inject(CartService), { passthrough: true })` — dependencies, `signal()`
232
- fields, `ɵprov` and the real `ngOnDestroy` keep working. Lifecycle hooks, discovered callables with an
233
- API of their own (signals) and discovered classes are left real rather than spied. Do **not** combine
234
- it with `strict: true` or `onUnstubbedCall` on the same call — that throws; a suite-wide strict yields
235
- to it. A `calledWith(1)` miss answers `undefined`, not the real method: configuring a method hands the
236
- whole method over. Use it to assert an interaction on a real collaborator; use a plain double when
237
- the test must not touch the real one.
238
-
239
- **`spyOnOwnMethod(sut, 'method')` is that whitelist-plus-passthrough shape packed into one call.**
240
- `createSpyFromInstance(sut, { onlyMethodsToSpyOn: ['method'], passthrough: true }).method` is
241
- verbose for the most common thing it says — _observe one method of the object under test and let it
242
- run_ — so the helper says exactly that and hands back the single spy. It is also the drop-in for a
243
- bare `vi.spyOn(component, 'method')` where a preset's `no-restricted-properties` bans `vi.spyOn`:
244
- same default semantics, record and call through, and `restoreSpiedInstance` puts it back. It is a
245
- core export — `import { spyOnOwnMethod } from 'vitest-auto-spy'`, or the runner entry on
246
- `bun test` / `node --test` — while `vitest-auto-spy/angular` and `vitest-auto-spy/nestjs` have no
247
- core re-export, so those suites import it from the root; `spyOnVoidMethod` below is the same.
248
-
249
- **Do not `createSpyFromInstance` a DOM node that must keep living a DOM life.** Discovery walks the
250
- node's prototype chain, and past the component's own class that chain is the engine's: happy-dom's
251
- `Node.removeChild` calls an internal Symbol-keyed method on the child, and once discovery has
252
- patched it the strict guard refuses that call — the node cannot be removed from `document.body` at
253
- all, and the leak then fails later tests in the file far from the cause. Without `onlyMethodsToSpyOn`
254
- on a live `Node`, the global `window` or another engine-provided event target (`XMLHttpRequest`,
255
- `AbortSignal`; a class of your own that extends `EventTarget` does not count), the call now reports
256
- this itself, before patching anything — `warn` by default, or a throw right there under
257
- `setMisconfigurationReaction('throw')`.
258
- The bare-array shorthand does not silence it: `createSpyFromInstance(el, ['addEventListener'])`
259
- merges as `methodsToSpyOn`, the _additive_ list, so discovery still walks the whole node — only
260
- `{ onlyMethodsToSpyOn: [...] }` skips it. For a still-attached element, spy one property with
261
- `mockValueProp(el, 'addEventListener', vi.fn())` (§9) — the node stays otherwise real and removable.
262
- For a native void method the handler is meant to call (`preventDefault`, `stopPropagation`, `focus`),
263
- `spyOnVoidMethod(event, 'preventDefault')` packs the whitelist and the
264
- `returns: { preventDefault: undefined }` seed a strict suite otherwise needs, naming the method once
265
- instead of twice.
266
-
267
- **`createFixture<T>(defaults, overrides?)` / `createFixtureFactory<T>(defaults)` are for the model
268
- that more than one spec builds.** The difference from `createMock` is the `defaults` argument: it is
269
- a **complete** `T`, checked in full, in one place — so a field the model dropped fails there instead
270
- of in eight copies of a hundred-line literal. Overrides are deep-partial-checked and merge leaf by
271
- leaf; an overridden array replaces the default one. Every call hands back a fresh object and the
272
- defaults are copied at build time, which is what keeps one test's mutation out of the next test —
273
- across files, under `isolate: false`. The copy is deep through plain objects and arrays only: a
274
- `Date`, a `Map` or a class instance travels by reference, because rebuilding it would strip its
275
- prototype. Defaults that are a class instance with getters go through `withOverrides()` first.
276
-
277
- **`mockDeep` builds depth on property access, not on calls** — the distinction the tree now spells
278
- out, and the one that costs an afternoon otherwise. `mock.repo.user.find()` chains because every hop
279
- but the last is a _read_. A node that is **called** returns what it was configured to return, and by
280
- default that is `undefined`, so `mockDeep<AppLogger>().channel('app').info('x')` is a `TypeError` at
281
- the second call — while `DeepMockProxy<AppLogger>` types it perfectly, so nothing warns. Pass
282
- `{ selfReturning: true }` for a fluent API, or use
283
- `createAutoMock<T>(undefined, { selfReturning: ['channel'] })` when only one method chains:
284
-
285
- ```ts
286
- const logger = mockDeep<AppLogger>({}, { selfReturning: true });
287
-
288
- logger.channel('app').info('started');
289
- expect(logger.channel('app').info).toHaveBeenCalledWith('started');
290
- ```
291
-
292
- Both bridges exist, and which one you need depends on the direction. What a self-returning **call**
293
- hands back is typed as the _declared_ return type, not as a spy — `asSpy<T>(…)` when the helpers are
294
- needed. The **whole mock** is a `DeepMockProxy<T>`, which is not assignable to `T` for the same
295
- reason `Spy<T>` is not (a mapped type cannot see private members) — `asInstance(…)` when it has to
296
- go somewhere typed against the real thing:
297
-
298
- ```ts
299
- const logger = mockDeep<AppLogger>({}, { selfReturning: true });
300
-
301
- boot(asInstance(logger)); // → AppLogger, for the API under test
302
- asSpy<AppLogger>(logger.channel('app')).info.mockReturnValue(undefined); // → the helpers
303
- ```
304
-
305
- **Arrays and unmocked calls on `mockDeep`.** A member read by a numeric index (`api.items[0]`) is a
306
- real `Array` of deep mocks from then on, and `map`, iteration and `toEqual` work on it. Read the
307
- member again after the first index: a handle taken before the first index is still a node. To make a
308
- call nobody configured fail, use `mockDeep<T>({}, { fallbackMockImplementation: () => { throw … } })`.
309
- Options go in the **second** argument, not the first as in vitest-mock-extended. The precedence is
310
- configuration > fallback > `selfReturning`. A `calledWith` miss answers `undefined`, not the
311
- fallback; use `mustBeCalledWith` for "other arguments fail". For a callback API (`$transaction`), use
312
- `method.mockImplementation((run) => run(asInstance(mock)))`; there is no option for it.
313
- `vi.spyOn(mock.repo, 'find')` works on a member nobody has read and returns the node's own spy.
314
-
315
- `asInstance` did not take a deep mock before 3.5.0, which left it with nowhere to go: this tree
316
- sends you to `mockDeep` when the calls chain, and the result then fitted nothing that expected `T`.
317
-
318
- **A deep node asks what the spy surface is on every read**, rather than remembering the answer from
319
- the first deep mock of the worker. That matters under `isolate: false`, where the surface grows
320
- mid-run: `import 'vitest-auto-spy/rxjs'` in a later file adds `nextWith` and friends, and
321
- `setSpyEngine` swaps the whole prototype. With the answer cached, `deep.feed.items.nextWith(1)` in
322
- every double built after that resolved to a **child node** — callable, recorded, emitting nothing —
323
- while the spec waited on a stream that was never fed.
324
-
325
- **`selfReturning: true` chains a factory, not a `return this` builder**, and the difference decides
326
- where the calls are recorded. A called node answers _itself_, not the object the method was read
327
- off, so every hop moves one level deeper: `editor.chain().focus().insertContent('text')` records
328
- `insertContent` on `chain.focus`, while the `chain` handle the spec is holding still has none — and
329
- `expect(chain.insertContent).toHaveBeenCalled()` reports nothing although the chain ran. Either walk
330
- the same path in the assertion, or, for an API where every command answers the **same** object
331
- (tiptap's `ChainedCommands`, a query builder, a `mockReturnThis()` chain), build that object with
332
- `createAutoMock` instead, where `selfReturning` names methods that answer one double:
333
-
334
- ```ts
335
- const chain = createAutoMock<ChainedCommands>(undefined, { selfReturning: ['focus', 'insertContent'] });
336
- const editor = createAutoMock<Editor>(undefined, { returns: { chain: asInstance(chain) } });
337
-
338
- editor.chain().focus().insertContent('text').run();
339
- expect(chain.insertContent).toHaveBeenCalledWith('text'); // one double, so the assertion is the obvious one
340
- ```
341
-
342
- **An `abstract class` is a class.** `abstract class LocalStorage extends AbstractStorage {}`,
343
- provided in production as `{ provide: LocalStorage, useClass: BrowserLocalStorage }`, is the
344
- standard Angular DI-token idiom, and `provideAutoSpy(LocalStorage)` / `createSpyFromClass(LocalStorage)`
345
- take it — type and runtime both. Abstract members are erased before they reach a prototype, so there
346
- is nothing to read there; when discovery comes back empty the factory hands back the `createAutoMock`
347
- proxy instead of an empty object, and every method answers. Nothing to configure, and no reason to
348
- reach for `{ provide: X, useValue: createAutoMock<X>() }` by hand any more.
349
-
350
- That holds while the class is **fully** abstract. One concrete member — a helper, a getter — and
351
- discovery is no longer empty, the fallback does not fire, and every `abstract` member is missing
352
- while `Spy<T>` types it as present: the read is `undefined` and the call dies as
353
- `… is not a function` in production code. Pass `{ fillMissing: true }` there
354
- (`provideAutoSpy(LocalStorage, { fillMissing: true })`), which answers a name the prototype never
355
- carried with a spy. It is opt-in because `abstract` is erased at runtime — filling every unknown key
356
- by default would silence a real typo on every concrete class.
357
-
358
- **`overrides: { key: undefined }` is a seed, not an omission**, and the difference is load-bearing.
359
- `createAutoMock` reads its seed with `Reflect.ownKeys`, so a key written out with an explicit
360
- `undefined` **is** in the store: reading it answers `undefined`. Leave it out and the same read
361
- materialises a _function spy_ — which is truthy, and sends `if (this.lastFocus)` down the branch the
362
- spec was trying to close:
363
-
364
- ```ts
365
- createAutoMock<NavigationService>({ currentFocus: undefined, navRoot: undefined, selectors: 'button, a' });
366
- // ^ "this member is data, and there is none" — not the same as omitting it
367
- ```
368
-
369
- This is the way to say "the member exists and is empty", and it is worth writing even when it looks
370
- redundant.
371
-
372
- **A getter in `overrides` stays a getter.** The seed is kept as a descriptor rather than read once
373
- while the double is assembled, so it runs at every read, with the double as `this` — and a seed
374
- written to **throw** ("this global is missing on this platform") fails where the code under test
375
- reads the member, not where the provider literal is evaluated. A `{ set }` seed is kept the same way
376
- and takes the write. Both used to be flattened as the double was built, which turned a throwing
377
- seed into a failure during `TestBed.configureTestingModule`, three frames from the branch under test.
378
-
379
- A **registration** is no exception, and it was the half that stayed broken one release longer: the
380
- merge that puts `registerAutoSpyDefaults` under a call site copied both sides with a spread, so a
381
- seeded getter was flattened on any class or token the registry knows — whether or not the
382
- registration names that key — while the same seed stayed live on one it does not. The merge copies
383
- descriptors now, and the seed behaves the same either way.
384
-
385
- ### What a Proxy-backed double cannot do
386
-
387
- `createAutoMock` and `mockDeep` build a Proxy, not an object, and there is one place where the
388
- difference shows: a Proxy answers only the operations its handler traps. Three of them used to be
389
- missing, and each produced a _silent_ wrong answer rather than an error — the worst failure mode
390
- this library can have, because a checking test becomes a non-checking one and only the proxy's
391
- source says so. Two are fixed; the third cannot be:
392
-
393
- | Operation | Before 3.5.0 | Now |
394
- | --- | --- | --- |
395
- | `mockValueProp` & the other three | patch landed on the target; the double ignored it | works, and `restoreMockedProps()` undoes it |
396
- | `delete mock.optionalMethod` | deleted nothing; the next read remade the spy | the member is absent, until something writes to it again |
397
- | `Object.assign(real, mock)` | copies only the keys already **read** | still does — see below |
398
-
399
- `ownKeys` cannot be completed: a type has no key list at runtime, which is the whole premise of
400
- these two factories. So a spec that installs a double by **copying it onto a real instance** —
401
- `Object.assign(player, engineDouble)` — gets whichever members happened to be touched first, and
402
- every other call goes to the real implementation, silently. Use `createSpyFromClass` there: it
403
- returns an ordinary object whose method keys are enumerable (lazy accessors, but enumerable), so
404
- the copy is complete.
405
-
406
- ### It answers everything, so it must not answer _these_
407
-
408
- The same premise cuts the other way. A library that is handed an object and has to decide **what
409
- kind of thing it is** asks by probing a key — and a double that answers every property answers the
410
- probe too, at which point it stops being a double of `T` and becomes whatever was being looked for.
411
- Four names are therefore answered with `undefined` unless the spec seeds them, alongside `then` and
412
- every symbol, which always were:
413
-
414
- | Key | Probed by | The double became |
415
- | --- | --- | --- |
416
- | `schedule` | `popScheduler` in `of` / `from` / `merge` / … | a scheduler |
417
- | `lift` | `isObservable`, with `subscribe` | an Observable |
418
- | `@@observable` | `isInteropObservable` in `innerFrom` | an interop stream |
419
- | `getReader` | `isReadableStreamLike` in `innerFrom` | a ReadableStream |
420
-
421
- The one that cost an afternoon reads like nothing at all:
422
-
423
- ```ts
424
- of(autoMocked<AnimationItem>()); // an Observable that never emits
425
- ```
142
+ In [`agent-docs/factories.md`](./agent-docs/factories.md). Read it before writing any double: the decision tree from "what do you have" to the factory, and what a Proxy-backed `createAutoMock` cannot do.
426
143
 
427
- `of(...)` takes its **last argument** for a scheduler when `typeof x.schedule === 'function'`, so
428
- the whole double was eaten as one, `of()` was left with an empty argument list, and the emission was
429
- scheduled onto a spy that does nothing. The component under test kept its `null`, and what failed
430
- was an assertion about an unrelated `emit()` three concerns away — nothing in the failure mentions
431
- `of`. The workaround people find is `from([double])`; it is not needed any more.
144
+ <!-- agents-outline: agent-docs/factories.md -->
432
145
 
433
- **`subscribe` is deliberately not on that list.** It is an ordinary method name — a store, an
434
- Angular `OutputEmitterRef`, an event bus — and `expect(store.subscribe).toHaveBeenCalledWith(cb)` is
435
- a real assertion. Denying `lift` and `@@observable` already breaks the impersonation, so `subscribe`
436
- on its own fools nothing: `from(double)` now fails with rxjs's own _"You provided an invalid object
437
- where a stream was expected"_, loudly and in the right file.
146
+ - What a Proxy-backed double cannot do
147
+ - It answers everything, so it must not answer _these_
148
+ - Cost, so it stops being a question
438
149
 
439
- If your type genuinely has one of the four, say so once and it comes back — the list is consulted
440
- after the seed store:
441
-
442
- ```ts
443
- createAutoMock<TaskScheduler>({ schedule: vi.fn() });
444
- ```
445
-
446
- That is the trade the deny-list makes: without a seed the member is absent and the failure is an
447
- immediate `TypeError: … is not a function` at the call site, instead of a silent one in another
448
- file. A key is only added to that list with an observed mechanic behind it — never because the name
449
- sounds protocol-ish — because every entry costs somebody the ability to mock a member of that name
450
- without seeding it.
451
-
452
- The tree asks whether the double is _called_, and there is a second question worth asking: whether
453
- the code under test **writes to it**. `createAutoMock` is a proxy with a `set` trap over the same
454
- cache its `get` trap answers from, so an assignment sticks and is read back — which makes it the
455
- double for a DOM-ish object a library drives by assigning handlers, where a hand-written fake is
456
- otherwise the only option:
457
-
458
- ```ts
459
- const xhr = createAutoMock<XhrLike>({ status: 0, timeout: 0, onload: null, onerror: null });
460
-
461
- xhr.send.mockImplementation(() => respond(asInstance(xhr)));
462
- // production does `xhr.onload = () => resolve(xhr.status !== 0)` — the proxy remembers it
463
- ```
464
-
465
- ---
466
-
467
- ### Cost, so it stops being a question
468
-
469
- Building a spy is not a thing to optimise. On a ten-method class, `npm run bench` on Node v24.19.0
470
- and Vitest 4.1.11, measured 2026-09-04: `createSpyFromClass` plus two methods called **2.25 µs**,
471
- plus all ten 5.83 µs, `createAutoMock` plus four members 1.79 µs, a `calledWith` lookup 0.21 µs —
472
- five providers across two thousand tests is about two hundredths of a second. Call the factory in
473
- `beforeEach` and look at `TestBed` instead. The only two settings that cost:
474
- `{ lazySpies: false }` gives up the laziness `provideAutoSpy` defaults to, and
475
- `autoSpyAccessors: true` walks the prototype chain uncached on every call — name the accessors
476
- instead.
477
-
478
- Memory used to be the exception, and it no longer is for the default. `lazySpies: true` still
479
- defines one property per method, but the accessor pair behind it is now **shared by method name**
480
- across every double in the process, so the placeholder is no longer what an untouched double
481
- retains: an untouched 100-method double holds **215 B** where it held 25 593, and a 300-method one
482
- 284 B where it held 70 165 (`npm run bench:memory`, 2026-09-17, Node 24). The price is paid at
483
- construction on very wide classes — building a 300-method double costs about 28 % more — and
484
- materialising every method of one is about 26 % cheaper in exchange.
485
-
486
- That inverts the case for `lazySpies: 'proxy'`, which answers every method from a single trap
487
- object: it now retains **4 090 B** against the default's 215 B at 100 methods — 19× more — and still
488
- pays a trap on every read (53 ns against 7 ns) for the life of the double, because a `Proxy` cannot
489
- remove itself while the default leaves a plain data property behind once a method materialises. What
490
- is left of its advantage is build time on a wide class. The option is unchanged and still supported;
491
- there is no longer a memory reason to reach for it.
492
-
493
- A frozen or sealed double is fine now. `Object.freeze(spy)` — a deep-freeze fixture helper, a
494
- dev-mode state guard — used to make the first read of any method throw `Cannot redefine property`
495
- from inside the placeholder's getter; the spy is kept beside the double instead, so the read answers
496
- a stable mock and `mockReturnValue` on it works. On a merely sealed double an assignment still
497
- reaches the member.
498
-
499
- `vi.spyOn(double, 'load')` on a method nobody has read yet works again. Vitest reads an accessor by
500
- calling its getter with no receiver, and 5.19.0's shared getter answered that with
501
- `TypeError: Invalid value used as weak map key`. The call now wraps a forwarder: a configured
502
- `mockReturnValue` answers, an unconfigured call reaches the double's own spy (strict guard included),
503
- and `mockRestore()` / `vi.restoreAllMocks()` hand back that same spy with the calls it recorded. It
504
- is still redundant — the member already is a spy, so `double.load.mockReturnValue(…)` is the line to
505
- write — and a wrapped method called off its double (`const { load } = double; load()`) throws a
506
- message saying so.
507
-
508
- **Symbol-keyed methods are discovered and spied.** A method under a symbol the project owns —
509
- `[SERIALIZE]()`, `Symbol.for('app.render')` — is a method like any other, resets with the rest and
510
- shows up in `Reflect.ownKeys`. The runtime's own protocol symbols are deliberately left alone
511
- (everything on `Symbol` itself — `Symbol.iterator`, `Symbol.dispose`, `Symbol.toPrimitive` and the
512
- rest — plus `Symbol.for('nodejs.util.inspect.custom')`), because a double that answers those stops
513
- being a double of `T` and starts impersonating an iterable or a disposable. Discovery covers methods
514
- only: a symbol-keyed **getter or setter** is not found by `autoSpyAccessors`, so patch one with
515
- `mockAccessorsProp` (§9).
150
+ <!-- /agents-outline -->
516
151
 
517
152
  ---
518
153
 
@@ -593,511 +228,24 @@ Four conventions that carry most of the value:
593
228
 
594
229
  ## 4. Helpers a spied method earns from its return type
595
230
 
596
- Every spied method is a real runner mock, so `mockReturnValue`, `mockImplementation`,
597
- `toHaveBeenCalledWith` and the rest all work as usual. On top of that:
598
-
599
- | Return type | Helpers added |
600
- | --- | --- |
601
- | anything | `calledWith(...args)` → `.mockReturnValue(v)` / `.returnValue(v)` / `.failWith(err)`, `mustBeCalledWith(...args)` → same, `failWith(err)`. On a `Promise` or `Observable` method the handle carries that row's helpers instead — `calledWith(id).nextWith(v)`, `.resolveWith(v)`; `.mockReturnValue` there is a `TS2339` |
602
- | `Promise<T>` | `resolveWith(v)`, `rejectWith(v)`, `resolveWithPerCall([{ value }, …])` |
603
- | `Observable<T>` | `nextWith(v)`, `nextOneTimeWith(v)`, `nextWithValues(configs)`, `nextWithPerCall(configs)`, `throwWith(v)`, `complete()`, `returnSubject()` |
604
-
605
- `Observable` **properties** (not just methods) get the same helpers — list them in
606
- `observablePropsToSpyOn`. The prop spy is a plain `Observable`: a `BehaviorSubject` member loses
607
- `.value` / `getValue()`, and code that reads them gets `undefined`. Seed a real one instead —
608
- `overrides: { isActive$: new BehaviorSubject(false) }` — and drive it with `.next()`; the same holds
609
- for a stream the test pushes into itself.
610
-
611
- **What counts as an `Observable` is structural, and no declaration names rxjs (4.0.0).** A member
612
- earns the observable bundle when its type satisfies the exported `ObservableLike<T>` — `subscribe`
613
- plus a promise-returning `forEach(next)`:
614
- rxjs's `Observable`, every `Subject`, Angular's `EventEmitter` — and, new in 4.0.0, an `Observable`
615
- from a _second copy_ of rxjs in the tree, which used to fall through to the plain-spy branch and
616
- produce `nextWith is not a function` with nothing pointing at the duplicate. `Promise`, arrays,
617
- `Signal` and Angular's `OutputEmitterRef` are not observables and do not earn it.
618
-
619
- `returnSubject()` and `nextWithPerCall()` return `SubjectOf<T>` — rxjs's own `Subject<T>` wherever
620
- `import 'vitest-auto-spy/rxjs'` is in the TypeScript program, the structural `SubjectLike<T>`
621
- (`next` / `error` / `complete` / `asObservable` / `closed`) where it is not. The switch is one
622
- augmentable interface, `AutoSpyRxjsTypes<T>`, which `/rxjs` fills in with `subject: Subject<T>`;
623
- augment it yourself only to plug in a different subject type. If
624
- `const s: Subject<T> = spy.m.returnSubject()` fails to compile, the import is missing from the
625
- program the specs are checked in — usually a Vitest `setupFiles` entry that no `tsconfig`
626
- `include` covers, or, from `@angular/build:unit-test` 22.2.0, a plain `.ts` that only the spec
627
- `tsconfig`'s `include` lists: that builder's program is the specs, the setup files and the `.d.ts`
628
- files (angular-cli#34134), so put the import in one of those. That is the _only_ breaking change in
629
- 4.0.0; the reason for it is that `dist/types-*.d.ts` used to open with
630
- `import { Observable, Subject } from 'rxjs'` and load 189 rxjs `.d.ts` files into every consumer's
631
- program (303 files against 114 without it), `import type` included — TypeScript resolves a type-only import the same way.
632
-
633
- ```ts
634
- // argument dispatch — other arguments return undefined
635
- users.getName.calledWith(1).mockReturnValue('Ada');
636
- // argument enforcement — other arguments throw
637
- users.getName.mustBeCalledWith(1).mockReturnValue('Ada');
638
- // asymmetric matchers work in both, at any depth
639
- users.save.calledWith(expect.objectContaining({ id: 1 })).mockReturnValue(true);
640
- users.save.calledWith({ id: expect.any(Number), tags: [expect.any(String)] }).mockReturnValue(true);
641
- // re-registering the same arguments replaces the answer — matcher arguments included
642
- users.save.calledWith(expect.objectContaining({ id: 1 })).mockReturnValue(false);
643
-
644
- // promises
645
- users.load.resolveWith({ id: 1 });
646
- users.load.rejectWith('FAKE ERROR');
647
- users.load.resolveWithPerCall([{ value: a }, { value: b }]);
648
- expect(users.load.mock.settledResults).toEqual([{ type: 'fulfilled', value: { id: 1 } }]);
649
-
650
- // observables — requires `import 'vitest-auto-spy/rxjs'` once
651
- feed.items$.nextWith([item]); // emit, stream stays open
652
- feed.items$.nextOneTimeWith([item]); // emit once, then complete
653
- feed.items$.nextWithValues([{ value: a }, { value: b, delay: 100 }, { complete: true }]);
654
- const [first$, second$] = feed.watch$.nextWithPerCall([{ value: 'a' }, { value: 'b', doNotComplete: true }]);
655
- feed.items$.throwWith('FAKE ERROR');
656
- const subject = feed.items$.returnSubject(); // ReplaySubject, for anything the helpers miss
657
-
658
- // throwing — `failWith`, on a spy of any return type
659
- cart.checkout.failWith(new HttpErrorResponse({ status: 500 })); // every call throws
660
- cart.checkout.calledWith(BAD_ID).failWith(new Error('unknown cart')); // only these arguments throw
661
- ```
662
-
663
- `failWith` is the sync counterpart of `rejectWith`, and it is **not** called `throwWith` — that name
664
- belongs to the observable helper above, which errors the stream. At runtime every spy carries every
665
- bundle; only the return type in `Spy<T>` tells them apart, so one name for both would mean whichever
666
- is attached last silently wins.
667
-
668
- On Vitest, `mockThrow` / `mockThrowOnce` (4.1) do the spy-level half of this too. `failWith` exists
669
- because Bun and `node:test` ship neither, and because **no** runtime can make one `calledWith` chain
670
- throw while its siblings answer normally — `mockImplementation` replaces the whole dispatch, which is
671
- the opposite of configuring one set of arguments. A `failWith` supersedes a `resolveWith` /
672
- `nextWith` / per-call batch configured before it, and is superseded by one configured after, so the
673
- outcome never depends on the order the spec happens to be written in.
674
-
675
- An exact argument list is matched before the asymmetric configs, and those are tried in
676
- registration order — a narrow config written first keeps its calls. Two matchers count as the same
677
- argument when they accept the same values (same matcher class, sample and inversion), which is what
678
- makes a second `calledWith(1, expect.anything())` an override rather than a second config sitting
679
- behind the first. A hand-rolled `{ asymmetricMatch }` object is compared by identity instead: its
680
- verdict is a closure, so only re-registering that same instance overrides.
681
-
682
- **A matcher counts wherever it sits, not only at the top level.** A config argument holding a
683
- matcher — or a function — anywhere inside it is compared structurally rather than as data:
684
- `calledWith({ id: expect.any(Number) })`, `calledWith([expect.any(String)])`, a matcher inside a
685
- `Map` value or a `Set` member. The same comparison decides the rest of an argument's shape, so it is
686
- worth knowing what it treats as equal: `Map` and `Set` are compared **without regard to insertion
687
- order**, a `Date` by its time, a `RegExp` by its source and flags, an `Error` by its `name` and
688
- `message` plus its own enumerable fields, a function by identity, and symbol-keyed properties
689
- participate like string ones. Cycles are handled, so a component graph with a back-edge is a legal
690
- argument. `mustBeCalledWith` uses the same map, and its `Wanted:` line renders a matcher as
691
- `Any<Number>` rather than as the object it serialises to. With one config set up, its first line
692
- names the first argument that differs — `argument 2: expected 'eu', got 'us'` — and it names the
693
- class only on a strict double (`UserService.getName`), the method alone otherwise.
694
-
695
- Each `calledWith(...)` / `mustBeCalledWith(...)` call hands back **its own** handle, so a chain kept
696
- in a variable stays attached to the arguments it was written with:
697
-
698
- ```ts
699
- const found = users.load.calledWith(1);
700
- users.load.calledWith(2).mockReturnValue(undefined);
701
-
702
- found.mockReturnValue({ id: 1 }); // configures 1, not 2
703
- ```
704
-
705
- `new` on a method spy works: `new sdk.Client()` — the shape `createAutoMock<{ Client: typeof Client }>()`
706
- and `mockDeep` produce — hands back the instance, or the object a `calledWith(...).mockReturnValue(...)`
707
- configured for those arguments.
708
-
709
- `mock.settledResults` is native on Vitest and polyfilled on Bun / `node:test`, so it is identical on
710
- all three. Entries are `{ type: 'fulfilled' | 'incomplete' | 'rejected', value }`.
711
-
712
- When the argument worth asserting on is one the **code under test built** — a callback, a config
713
- object, an `AbortSignal` — describing its shape is the wrong tool. `expect.any(Function)` says a
714
- function was passed; `captureArg` hands it to you so the test can call it:
715
-
716
- ```ts
717
- import { captureArg } from 'vitest-auto-spy';
718
-
719
- const onDone = captureArg<() => void>();
720
-
721
- expect(notifier.subscribe).toHaveBeenCalledWith('ready', onDone);
722
-
723
- onDone.value(); // and now exercise what was passed
724
- expect(component.finished()).toBe(true);
725
- ```
726
-
727
- `.values` holds every value the captor was **offered**, oldest first — candidates, not matches. A
728
- captor in position 0 of `toHaveBeenCalledWith(captor, 3)` is asked about the first argument of every
729
- call the runner tries, including the ones the `3` then rejects, so `.values` can be longer than the
730
- set of calls the assertion accepted. `.captured` asks whether anything was recorded without
731
- triggering the "nothing was captured" throw that reading `.value` raises; `.reset()` lets one captor
732
- serve two phases.
733
-
734
- `captureArg({ where })` narrows both halves at once — the filter decides what is recorded **and**
735
- whether that position matches at all, so a rejected candidate leaves no entry in `.values` and the
736
- whole expectation fails on it:
737
-
738
- ```ts
739
- const config = captureArg<RequestInit>({ where: (value) => (value as RequestInit)?.method === 'POST' });
740
-
741
- expect(fetchSpy).toHaveBeenCalledWith(url, config); // only the POST call satisfies this
742
- expect(config.value.headers).toEqual({ 'x-trace': '1' });
743
- ```
744
-
745
- The filter receives the raw argument as `unknown`, so narrow it yourself. **Assertions only** — a
746
- captor without `where` matches every value, so putting one in `calledWith` would configure a return
747
- for every call, which is `mockReturnValue` spelled less clearly, and `calledWith` is typed to the
748
- method's own parameters so it will not compile anyway.
231
+ In [`agent-docs/return-helpers.md`](./agent-docs/return-helpers.md). Read it when stubbing a return value: `calledWith`, `resolveWith`, `nextWith`, `returnSubject` and the rest, by return type.
749
232
 
750
- **The observable helpers are backed by a `ReplaySubject(1)` that belongs to the spy, and it is
751
- configuration — so it must be reset with the rest of it.** Two failures used to come out of that
752
- buffer outliving the test that filled it, and both were silent:
753
-
754
- ```ts
755
- // test 1
756
- service.createTransition.nextWith(uri); // buffered
757
-
758
- // test 2 — the failure path is the point of this test
759
- service.createTransition.throwWith(error); // subscriber gets `uri` FIRST, then the error
760
- ```
761
-
762
- The code under test therefore ran the **success** branch on stale data, and the error branch arrived
763
- one emission late. The second: `error()` and `complete()` close a Subject permanently, so a later
764
- `nextWith` on that spy pushed into a dead subject and emitted nothing at all. Both are fixed —
765
- `resetAutoSpy(spy)` now drops the subject, and a terminated one is replaced on the next
766
- configuration. That holds for a subject a spec closed **itself**, too:
767
- `spy.items$.returnSubject().complete()` marks the stream closed, so the next `nextWith` opens a new
768
- one rather than disappearing.
769
-
770
- `nextWithValues` on an observable **property** builds a new stream, which a subscriber that already
771
- holds the old one never sees — the spec's values go nowhere and the assertion below reads the
772
- initial state. That case is reported rather than passing silently —
773
- `Feed.items$.nextWithValues() ran after something subscribed to Feed.items$` on a strict double, the
774
- member name alone otherwise — once per property as a warning, and at every such call as a throw under
775
- `setupAutoSpy({ misconfiguration: 'throw' })`. `nextWith` pushes into the subject the current
776
- subscriber is on and is the one to reach for mid-test.
777
-
778
- What that does **not** change: `vi.clearAllMocks()` and `clearMocks: true` still cannot reach it,
779
- for the same reason they cannot reach a `calledWith` chain — that state lives in this library's
780
- closures, not on the runner's mock. So when a spy outlives a test — a TestBed built in `beforeAll`,
781
- a spy hoisted to `describe` scope — put `resetAutoSpy(spy)` in `beforeEach`. Inside one test the
782
- sequence `nextWith(a)` then `throwWith(e)` still means "emit a, then fail"; only a reset or a
783
- terminal call starts a new stream.
233
+ <!-- agents-outline: agent-docs/return-helpers.md -->
234
+ <!-- /agents-outline -->
784
235
 
785
236
  ---
786
237
 
787
238
  ## 5. `createSpyFromClass` configuration
788
239
 
789
- ```ts
790
- createSpyFromClass(MyService); // every method on the prototype chain
791
- createSpyFromClass(MyService, ['reload', 'count']); // those two ADDED to the discovered ones
792
- createSpyFromClass(MyService, {
793
- methodsToSpyOn: ['reload'], // ADDS (jest-auto-spies semantics)
794
- onlyMethodsToSpyOn: ['getName'], // RESTRICTS — skips prototype discovery
795
- instanceMethodsToSpyOn: ['reload'], // ADDS; same behaviour, clearer name
796
- observablePropsToSpyOn: ['products$'],
797
- gettersToSpyOn: ['userName'],
798
- settersToSpyOn: ['userName'],
799
- autoSpyAccessors: true, // discover every accessor on the prototype chain
800
- lazySpies: true, // build each method spy on first access ('proxy' for very wide classes)
801
- returns: { getProducts: of([]) }, // what a spied METHOD answers
802
- selfReturning: ['where'], // a method that answers the double itself, for a chained call
803
- overrides: { products$: subject }, // a member that is not a method result
804
- });
805
- ```
806
-
807
- | Key | Semantics |
808
- | --- | --- |
809
- | `methodsToSpyOn` | **Additive**, as in `jest-auto-spies`. Same behaviour as `instanceMethodsToSpyOn`. |
810
- | `onlyMethodsToSpyOn` | **Exhaustive whitelist.** Skips discovery; anything not listed is absent. |
811
- | `instanceMethodsToSpyOn` | **Additive.** The name to prefer in new code (see below). |
812
- | `autoSpyAccessors` | Merged with the explicit getter/setter lists. |
813
- | `lazySpies` | Behaviour-identical; only changes _when_ each spy is built. `'proxy'` also changes _what holds the name_. |
814
- | `strict` | Throw on a method nobody configured, instead of answering `undefined` (below). |
815
- | `onUnstubbedCall` | The general form of `strict`; its return value becomes the call's return value. |
816
- | `selfReturning` | The named methods answer the double itself — a default like `returns`; a name in both answers `returns`. |
817
-
818
- **`instanceMethodsToSpyOn` is not an edge case — it is a top-5 option** (103 of ~370 spec files in
819
- the reference suite). Method discovery walks the _prototype chain_; a callable assigned to an
820
- **instance field** is invisible to it:
821
-
822
- - an Angular `signal()` / `computed()` field — the dominant case in a signals codebase
823
- - an arrow-function property — `readonly reload = (): void => {}`
824
- - anything on an ngrx `signalStore()`, which puts **everything** on the instance
825
- - **members Angular's own classes moved onto the instance** — `Router.currentNavigation` in
826
- Angular 20 is `currentNavigation = this.navigationTransitions.currentNavigation.asReadonly()`
827
-
828
- ```ts
829
- createSpyFromClass(TaskStore, { instanceMethodsToSpyOn: ['count', 'reload'] });
830
- provideAutoSpy(ProjectStore, { instanceMethodsToSpyOn: ['current', 'isEmpty'] });
831
- provideAutoSpy(Router, { instanceMethodsToSpyOn: ['currentNavigation'] });
832
- ```
833
-
834
- **The failure this produces says nothing about any of it.** The member is simply not on the spy, so
835
- the next line reads `undefined` and configuring it throws:
836
-
837
- ```
838
- TypeError: Cannot read properties of undefined (reading 'mockReturnValue')
839
- ```
840
-
841
- There is no better message to be had at runtime, and it is worth saying why rather than leaving it
842
- looking like an oversight. Instance fields do not exist until a constructor has run, and this
843
- library never constructs the class — that is what makes a spy safe to build from a service whose
844
- constructor talks to the network. The only alternative would be to answer an unknown member with
845
- _something_, and that something would be truthy: `if (service.optionalThing)` in the code under test
846
- would then take the wrong branch, silently, which is the exact failure mode the protocol deny-list
847
- in §2 exists to remove. A loud `TypeError` on the spec's own line is the better of the two.
848
-
849
- For an ngrx `signalStore()`, prefer `createAutoMock<T>()` over listing every member: it mocks from
850
- the type, needs no prototype, and the list cannot fall behind the store. Its **signal** members want
851
- `mockSignalProp`, not a method spy — a store's state is read during the first render, so a method spy
852
- there answers `undefined` to the template.
853
-
854
- The symptom of getting this wrong is **a spy that is never called and no warning at all**: the
855
- additive lists exist precisely to name things the prototype does not have, so a typo in one cannot
856
- be told apart from an instance field and stays silent.
857
-
858
- Only `onlyMethodsToSpyOn` warns, because only a restricting list can be silently destructive — a
859
- misspelling there leaves the real method unspied, and the code under test then calls something that
860
- is not there:
861
- `[vitest-auto-spy] createSpyFromClass(CartService): onlyMethodsToSpyOn names 'lod' (did you mean 'load'?), not a method of CartService.`
862
- A name with nothing close gets `instanceMethodsToSpyOn: ['x']` as the fix instead.
863
-
864
- Also true, and worth not re-deriving:
865
-
866
- - **Inherited methods are spied** — discovery walks the whole chain (`Object.prototype` excluded).
867
- - **Constructor bodies never run.** The spy is assembled from the prototype.
868
- - **Abstract classes are accepted**, type and runtime both — `ClassType<T>` carries an abstract
869
- construct signature, and when the prototype turns out to be empty (abstract members are erased
870
- before emit) the factory hands back the `createAutoMock` proxy instead of an empty object. Do
871
- **not** pass a concrete subclass instead; this file used to say so, and it was wrong twice over.
872
- - **An overloaded method is not collapsed.** The worry that `Spy<T>` types every generated
873
- `api-gateway` client against its last signature does not hold: a four-overload
874
- `ContentApiService.getItemsBySlug` types as it should, and hand-written `{ m: vi.fn() }` doubles
875
- for those services convert with no changes to the assertions. When the _first_ signature is the
876
- useful one, name it on the **declaration only** — the factory's result assigns to it, so the type
877
- argument is not written twice:
878
-
879
- ```ts
880
- let mapping: Spy<ContentMappingService, { overload: 'first' }>;
881
-
882
- mapping = createSpyFromClass(ContentMappingService); // no second type argument here
883
- ```
884
-
885
- **The symptom that leads here says nothing about overloads**, which is why the option is hard to
886
- find from the error. A stub of the real response shape is rejected on the spec's own line —
887
- `TS2345: Argument of type 'Page' is not assignable to parameter of type 'HttpEvent<Page>'` — on
888
- `nextWith(body)`, `resolveWith(body)`, `calledWith(…).returnValue(body)` or
889
- `mockReturnValue(of(body))` against a generated `observe` client. Neither the double nor the stub
890
- is wrong; both are being checked against the signature nobody calls. Reach for `overload`, never
891
- for `@ts-expect-error`: one migration wrote sixty of those across twenty-five files before anyone
892
- found this option, and each one stops checking the response shape that line exists to describe.
893
-
894
- **Name the method rather than the whole double.** `'first'` on the type moves _every_ overloaded
895
- member, and on a wide type that breaks the ones nobody was fixing — `Spy<Response, { overload:
896
- 'first' }>` for one method collected five `TS2769`s on `download`. `overload` also takes a map:
897
-
898
- ```ts
899
- let perf: Spy<Performance, { overload: { getEntriesByType: 'first' } }>;
900
- ```
901
-
902
- A name the type does not have never matches, so a rename leaves a dead entry rather than a red
903
- build. The default stays `'last'` because "the useful signature" is not decidable from the type: on
904
- a generated `observe` client it is the first, on a four-overload `api-gateway` client the last, and both
905
- live in one suite. And **overload order is not always the author's** — `declare global` in a
906
- third-party package appends to a global interface (`web-vitals` does exactly this to
907
- `Performance.getEntriesByType`), so which signature is last depends on which packages are in the
908
- program and can move on a dependency bump.
909
-
910
- **A getter that returns a `Signal<T>` goes in `instanceMethodsToSpyOn`**, not in `gettersToSpyOn`.
911
- `get isSafeMode(): Signal<boolean> { return this._isSafeMode.asReadonly(); }` is read as a property
912
- and called as a function, and the accessor route makes you write
913
- `accessorSpies.getters.isSafeMode.mockReturnValue(signal(false))` — two levels deeper than the value
914
- in question. Naming it as an instance method puts a plain spy at that key (the spy object has no
915
- class prototype, so nothing is being shadowed), and
916
- `service.isSafeMode.mockReturnValue(false)` reads like every other member. `mockSignalProp` is the
917
- other answer when the value has to change during the test.
918
-
919
- ### The composition belongs to the class — `registerAutoSpyDefaults`
920
-
921
- A class is doubled the same way in every file that doubles it, so register that once instead of
922
- repeating it:
923
-
924
- ```ts
925
- // vitest.setup.ts, once
926
- registerAutoSpyDefaults(Router, { observablePropsToSpyOn: ['events'], gettersToSpyOn: ['url'] });
927
-
928
- // every spec, from then on
929
- provideAutoSpy(Router);
930
- provideAutoSpy(Router, { instanceMethodsToSpyOn: ['currentNavigation'] }); // ADDS to the registration
931
- ```
932
-
933
- The call site is **merged** into the registration, not substituted for it: lists unioned
934
- (registration first, no repeats), `returns` / `overrides` merged key by key with the call site
935
- winning, scalars decided by the call site when it names the key. The bare-array form counts as
936
- `{ methodsToSpyOn: [...] }`. Registration is by class identity, so a subclass inherits nothing —
937
- walking the prototype chain would let one registration change doubles in files nobody was looking at.
938
- A second registration for the same class replaces the first, because two of them in one suite is the
939
- drift this removes rather than a merge to perform. `clearAutoSpyDefaults(Class)` drops one,
940
- `clearAutoSpyDefaults()` the lot. `createSpyFromInstance(obj)` reads the registration of the class
941
- `obj.constructor` names, merged the same way; an object literal resolves none. One exception, because
942
- an instance is real: when the call site lists `onlyMethodsToSpyOn`, the rest of the object stays real,
943
- so the registration contributes only `strict`, `onUnstubbedCall`, `onUnstubbedRead` and the
944
- `returns` / `selfReturning` entries of the listed methods — not its accessor lists, its other method
945
- lists or its `overrides`. `registerAutoSpyDefaults(Router, { gettersToSpyOn: ['url'] })` therefore
946
- leaves `router.url` live under `createSpyFromInstance(router, { onlyMethodsToSpyOn: ['navigateByUrl'] })`.
947
- A `returns` or `selfReturning` name the call site itself wrote for a method it left real is reported
948
- as a misconfiguration and skipped.
949
-
950
- A setup file that registers more than a handful of classes can say them as one table instead of one
951
- call each. Rows apply in order, and each is checked against **its own** class — a key `Router` does
952
- not carry fails on that row, naming `Router`'s members and nothing else:
953
-
954
- ```ts
955
- registerAutoSpyDefaults([
956
- [Router, { observablePropsToSpyOn: ['events'], gettersToSpyOn: ['url'] }],
957
- [AppEventsService, { instanceMethodsToSpyOn: ['announce'] }],
958
- [BaseLocalStorage, { instanceMethodsToSpyOn: ['getItem', 'setItem'] }],
959
- ]);
960
- ```
961
-
962
- A later row for a class an earlier row already named replaces it, exactly as a second call would.
963
- `AutoSpyDefaultEntry<T>` is the row type, for a row built outside the literal.
964
-
965
- **An `InjectionToken` is a key too — import `registerAutoSpyDefaults` from `vitest-auto-spy/angular`
966
- for it.** Same registry, same merge; the core export takes a class only, because the core entry may
967
- not mention Angular's types. `provideAutoSpyForToken(TOKEN)` reads the registration exactly as
968
- `provideAutoSpy(Class)` reads a class's — both of its arguments are merged over it, and a registered
969
- `returns` stays a default a later `calledWith` / `resolveWith` wins over:
970
-
971
- ```ts
972
- import { registerAutoSpyDefaults } from 'vitest-auto-spy/angular';
973
-
974
- registerAutoSpyDefaults(LOGGER, { returns: { info: undefined, err: undefined }, selfReturning: ['channel'] });
975
- registerAutoSpyDefaults(NAVIGATION, { overrides: { activeRow$: of({}) }, returns: { setFocus: undefined } });
976
-
977
- providers: [provideAutoSpyForToken(LOGGER), provideAutoSpyForToken(NAVIGATION, { activeRow$: rows$ })];
978
- ```
979
-
980
- A token row holds `AutoSpyTokenDefaults<T>`: what `createAutoMock` takes (`returns`, `selfReturning`,
981
- `observablePropsToSpyOn`, `strict`, `name`) plus `overrides`, the seeds `provideAutoSpyForToken` takes
982
- second. Keys are checked against the token's `T`; a table may mix class rows and token rows;
983
- `clearAutoSpyDefaults(TOKEN)` from the same entry drops one. Handing a token to the **core**
984
- `registerAutoSpyDefaults` fails with `TS2345 … 'InjectionToken<X>' is not assignable to parameter of
985
- type 'ClassType<unknown>'` — the fix is the import, not a cast.
986
-
987
- **`selfReturning` names the methods that answer the double itself.** `returns` cannot say it — the
988
- double does not exist when the literal is written — and without it a chained call dies on
989
- `undefined`. It works on every factory and in a registration; a registered chain takes a link back out
990
- through the call site's `returns`, since lists only ever union.
991
-
992
- ### `strict` — a method nobody configured throws instead of answering `undefined`
993
-
994
- ```ts
995
- const users = createSpyFromClass(UserService, { strict: true });
996
-
997
- users.load.resolveWith([]);
998
- users.currentTenant(); // throws here, not four frames later inside the component
999
- ```
1000
-
1001
- ```
1002
- [vitest-auto-spy] Cart.checkout(1, 'now') was called; this strict double has nothing configured for it.
1003
- Called from src/app/cart.component.ts:41:12
1004
- Configure it in the test: cart.checkout.calledWith(1, 'now').mockReturnValue(…) for these arguments, or .mockReturnValue(…) for any — .resolveWith(…) / .nextWith(…) when it returns a Promise / Observable.
1005
- Docs: https://asdalexey.github.io/vitest-auto-spy/core/strict-mode#the-message
1006
- ```
1007
-
1008
- Also on `createAutoMock`, `provideAutoSpy`, and suite-wide as `setupAutoSpy({ strict: true })` — which
1009
- reaches a double whichever bundle of the package built it: the default lives on `globalThis`. Before
1010
- this release it lived in module scope, and a setup file's `strict: true` reached no double a spec built.
1011
- Precedence, first one set wins: the double's `onUnstubbedCall` → the double's explicit **`strict: false`**
1012
- (the only way to exempt one double from a suite-wide default, a global handler included) → the global
1013
- `onUnstubbedCall` → the double's `strict: true` → the global `strict`.
1014
-
1015
- - **It is not argument-level.** A `calledWith(1, 'now')` chain says the method is stubbed, so
1016
- `checkout(9, 'later')` still answers `undefined`. Use `mustBeCalledWith` for that.
1017
- - **`returns:` is a default in the spy's own container**: a `calledWith` / `resolveWith` / `failWith`
1018
- configured later wins for its arguments or supersedes it, every other call still gets it, and
1019
- `undefined` counts as configured. `returns: { save: undefined }` is how a `void` call is expected.
1020
- `selfReturning: ['channel']` is the same kind of default, and counts as configured too.
1021
- - **`mockReturnValue` / `mockImplementation` replace the dispatch**, so they never reach the guard —
1022
- and a `calledWith` configured after them is never consulted. After the last `mockReturnValueOnce`
1023
- the queue empties back onto the library dispatch and the next call **is** reported as unstubbed —
1024
- seed `mockReturnValue(...)` too when a `Once` sequence is meant to run out.
1025
- - **That pair is reported, in both orders.** `mockReturnValue` after a `calledWith` erases it;
1026
- `calledWith` after a `mockReturnValue` is dead on arrival. Neither fails on its own — the spec goes
1027
- green on a branch nobody configured — so the library says so as a misconfiguration (warn, or throw
1028
- under the `strict` preset). The whole family replaces: `mockImplementation`, `mockReturnValue`,
1029
- `mockReturnThis`, `mockThrow`, `mockResolvedValue`, `mockRejectedValue`. When both a fallback and a
1030
- per-argument value are wanted, the fallback goes in the spy's container — `returns:` where the
1031
- double is built, or `resolveWith` / `nextWith` / `failWith` — which a `calledWith` still wins over.
1032
- The report is this library's own spy engine's, so Vitest and Rstest have it; Bun, `node:test` and
1033
- `setSpyEngine('runner')` do not. The `Once` family is never reported.
1034
- - **A throw something swallowed still fails the test.** Under `setupAutoSpy({ strict: true })` every
1035
- strict throw is recorded, and one that a `try`/`catch` in the code under test or an RxJS error with
1036
- no handler (its rethrow waits on a fake clock) kept from the test fails it after the test
1037
- (`swallowedStrictCalls`, `'throw'` by default with `strict: true`). The report quotes each call
1038
- with its arguments and the line that made it, and says what swallowed it — a `catch` in the code
1039
- under test, or an RxJS subscriber with no error callback. Provoking one on purpose:
1040
- `expect(() => cart.total()).toThrow(…)` then `expect(takeStrictViolations()).toHaveLength(1)`.
1041
- - **It does not reach** accessor spies, observable-property spies, `mockDeep` nodes, `console-spy`,
1042
- `mockResourceProp`'s `reload` or a standalone `createFunctionSpy`. A strict double still answers
1043
- `undefined` for an unconfigured getter or `items$`. `fillMissing` members **are** covered.
1044
- - **The getter and the stream are reported after the test instead** — a read cannot throw, because a
1045
- failure diff that prints the double reads it. `setupAutoSpy({ unconfiguredReads: 'warn' | 'throw' })`
1046
- (default `'off'`, not in the preset) reports a strict double's getter read that reached no
1047
- configuration and a subscription to a stream nothing fed **by the end of the test** — subscribe in
1048
- `beforeEach`, `nextWith` in the test is fine. Counted from setup's `beforeEach` to its `afterEach`,
1049
- so the spec's own hooks count. Configured means `accessorSpies.getters.x.mockReturnValue(…)` /
1050
- `mockImplementation`, `overrides: { x }` (call site or registration — a registered _list_ alone is
1051
- not), `mockReadonlyProp`; for a stream any of `nextWith` / `throwWith` / `complete` /
1052
- `returnSubject`. `undefined` meant: `mockReturnValue(undefined)`. `onUnstubbedRead({ className,
1053
- member, kind, count })` — suite-wide or per double, same precedence as `onUnstubbedCall` — takes the
1054
- findings instead of the report, from every double not `strict: false`: survey with it first.
1055
- - **Angular lifecycle hooks are exempt.** `ngOnDestroy`, `ngOnInit`, `ngOnChanges`, `ngDoCheck` and the
1056
- `ngAfter…` hooks answer `undefined` on a strict double: Angular calls `ngOnDestroy` itself on every
1057
- provided value at teardown (a `createAutoMock` / `provideAutoSpyForToken` double always has one), and
1058
- a throw there broke the teardown for every test after it. The calls are still recorded.
1059
- - A class instance or DOM node in the call the message quotes prints as `[ClassName]`, data up to 200
1060
- characters per argument; `provideAutoSpyForToken` names the token (`createAutoMock` takes `name`).
1061
- - Use `onUnstubbedCall` to survey a suite before turning the throw on — it is handed
1062
- `{ className, method, args }` and whatever it returns is what the call answers, so a handler that
1063
- pushes to an array records the gap without failing anything.
1064
-
1065
- ### Getters and setters live in `accessorSpies`
1066
-
1067
- ```ts
1068
- const settings = createSpyFromClass(SettingsService, { gettersToSpyOn: ['theme'], settersToSpyOn: ['theme'] });
1069
-
1070
- settings.accessorSpies.getters.theme.mockReturnValue('dark');
1071
- expect(settings.theme).toBe('dark'); // the property itself stays typed as `string`
1072
-
1073
- settings.theme = 'light';
1074
- expect(settings.accessorSpies.setters.theme).toHaveBeenCalledWith('light');
1075
- ```
1076
-
1077
- **Naming one half gets you the pair, when the class declares a pair.** `gettersToSpyOn: ['theme']`
1078
- on a class with both a getter and a setter installs both spies — mirroring reads the prototype
1079
- descriptor, so it only ever adds what the class already has, and a read-only member stays read-only.
1080
- Before 3.5.0 the assignment landed on the no-op setter the scaffolding installs: the write vanished,
1081
- `accessorSpies.setters.theme` was `undefined`, and the failure read
1082
- `Cannot read properties of undefined` three steps from the configuration behind it.
1083
-
1084
- The bag is typed over every key of `T` unless the lists are repeated in the options type argument —
1085
- `createSpyFromClass<Settings, { gettersToSpyOn: ['theme'] }>(Settings, { gettersToSpyOn: ['theme'] })`
1086
- keys both halves by exactly the configured names, so `accessorSpies.setters.other` is a compile error
1087
- instead of an `undefined` at run time. Use it when a spec reaches into the bag by name; a
1088
- non-literal `string[]` falls back to the every-key bag.
240
+ In [`agent-docs/configuration.md`](./agent-docs/configuration.md). Read it when passing a config to `createSpyFromClass`: which lists add and which restrict, `registerAutoSpyDefaults`, `strict`, getters and setters.
1089
241
 
1090
- Only spy a getter when the spec asserts that it was **read**. To make one _answer_ something, on a
1091
- spy that already exists, the pair above is one line — and it needs no `gettersToSpyOn` at the
1092
- factory, which is the part that is otherwise found by trial:
242
+ <!-- agents-outline: agent-docs/configuration.md -->
1093
243
 
1094
- ```ts
1095
- mockReadonlyProp(settings, 'theme', 'dark'); // no gettersToSpyOn, no accessorSpies
1096
- ```
244
+ - The composition belongs to the class — `registerAutoSpyDefaults`
245
+ - `strict` — a method nobody configured throws instead of answering `undefined`
246
+ - Getters and setters live in `accessorSpies`
1097
247
 
1098
- For a signal-valued property that is not merely convenience: a spied getter answers `undefined`
1099
- until it is configured, while `mockReadonlyProp(component, 'items', signal([]))` keeps every
1100
- `computed()` and `effect()` downstream of it reactive (§9).
248
+ <!-- /agents-outline -->
1101
249
 
1102
250
  ---
1103
251
 
@@ -1239,284 +387,25 @@ across realms.
1239
387
 
1240
388
  ## 8. Observable assertions (core entry — no rxjs needed)
1241
389
 
1242
- `expect()` inside a `subscribe()` callback is the classic green-but-empty test: if the stream never
1243
- emits, the callback never runs and nothing is asserted. Invert it — **the assertion is the `await`**:
390
+ In [`agent-docs/observables.md`](./agent-docs/observables.md). Read it when asserting what a stream emitted, and for `createLog()`, the order between spies.
1244
391
 
1245
- ```ts
1246
- import { expectAllEmissions, expectCompletion, expectEmission, expectEmissions, expectError, expectNoEmission } from 'vitest-auto-spy';
1247
-
1248
- await expect(expectEmission(component.visible$)).resolves.toBe(true); // the first VALUE, not a list
1249
- await expect(expectEmission(tasks$)).resolves.toEqual({ id: 1 }); // the task itself, not `[task]`
1250
- await expect(expectEmissions(source$, 3)).resolves.toEqual([1, 2, 3]); // the list is this one
1251
- await expectNoEmission(source$, { timeout: 50 });
1252
- await expectCompletion(service.purgeCache()); // "it finished" — the value is not the point
1253
- await expect(expectAllEmissions(source$)).resolves.toEqual([1, 2]); // every value, and none after
1254
- ```
392
+ <!-- agents-outline: agent-docs/observables.md -->
1255
393
 
1256
- Options: `{ timeout, label }`. `timeout` defaults to `1000` ms (`0` for `expectNoEmission`, whose
1257
- wait is a quiet window rather than a watchdog). `0` and `Infinity` both mean **no watchdog** — the
1258
- wait then runs to the runner's own test timeout, which is the trade. The source is duck-typed, so rxjs `Observable`s,
1259
- `Subject`s, Angular `toObservable()` results, Angular `output()` (`OutputEmitterRef`, whose
1260
- `subscribe` takes a bare callback) and hand-rolled subscribables all work — and every helper infers
1261
- the emitted type, so `expectEmission(of(1))` is a `Promise<number>`.
1262
-
1263
- **A `void` stream calls its listener with one argument, `undefined`.** `output<void>().emit()` and
1264
- `Subject<void>.next()` both do, so a `vi.fn()` subscribed to one records `[undefined]`, and
1265
- `expect(listener).toHaveBeenCalledExactlyOnceWith()` fails on `[] vs [undefined]` — the form a
1266
- `prefer-called-with` autofix used to write. The assertion is the emission itself:
1267
- `await expect(expectEmission(component.closed)).resolves.toBeUndefined()`, or
1268
- `subscribeSpyTo(subject$).getValuesLength()` to count. A listener kept anyway is typed
1269
- `vi.fn<() => void>()` (`(value: void) => void` trips `no-invalid-void-type`) and asserted
1270
- `toHaveBeenCalledExactlyOnceWith(undefined)`.
1271
-
1272
- `expectCompletion` is the one to reach for on a stream whose value is not the point — a save, a
1273
- purge, an `Observable<void>`, a `Subject` a teardown closes. `firstValueFrom` rejects such a stream
1274
- with rxjs's `EmptyError`, and the workaround people arrive at,
1275
- `lastValueFrom(x, { defaultValue: undefined })`, reads as though the default were the interesting
1276
- part. Emissions do not fail it: it asserts termination, nothing about what came before.
1277
-
1278
- **To assert that production code pushed into a stream, do not use `observablePropsToSpyOn`.** That
1279
- option points the other way: it gives the spec `nextWith` so it can _feed_ the double. When the
1280
- question is whether the code under test called `next` on a property, the double needs a real
1281
- `Subject` and a spy on its method:
1282
-
1283
- ```ts
1284
- const forceRequery$ = new Subject<number>();
1285
-
1286
- mockValueProp(state, 'reloadAndSeekTo$', forceRequery$);
1287
- const next = spyOnOwnMethod(forceRequery$, 'next');
1288
-
1289
- service.seek(1000);
1290
- expect(next).toHaveBeenCalledWith(1000);
1291
- ```
1292
-
1293
- `spyOnOwnMethod` is the same record-and-call-through a bare `vi.spyOn` gives, and it is the form to
1294
- reach for when a preset bans `vi.spyOn` outright (`no-restricted-properties`): the emission still
1295
- reaches subscribers, because the real `next` runs.
1296
-
1297
- `Spy<T>` types an Observable property as `AddObservableSpyMethods<O> & T[K]`, so `next` is there on
1298
- the type either way — which is exactly why this is worth saying: the code compiles against the spy
1299
- surface and asserts nothing.
1300
-
1301
- **When the error _is_ the assertion, use `expectError`.** The other helpers wrap a stream failure in
1302
- a new `Error` whose message names the stream — right for reporting an unexpected failure, useless
1303
- when the failure is the subject. `expectError` resolves _with_ the error, exactly as it was thrown:
1304
-
1305
- ```ts
1306
- await expect(expectError(service.load())).resolves.toBe(originalError);
1307
- expect(await expectError(process$)).toBeInstanceOf(UpstreamStatusError);
1308
- expect((await expectError(account$)) as Error).toHaveProperty('message', 'websso fail');
1309
- ```
1310
-
1311
- It waits for the error however late it arrives, and fails — naming the stream — if the stream
1312
- completes or stays quiet instead. The wrapped failures of the other helpers now also carry the
1313
- original on `cause`, so `rejects.toMatchObject({ cause: original })` works; prefer `expectError`,
1314
- which needs no unwrapping. `firstValueFrom(source$).rejects` remains fine too.
1315
-
1316
- **Which emission counts** — `skip` and `until`, for the stream whose first value is always stale:
1317
-
1318
- ```ts
1319
- await expect(expectEmission(isXl$, { skip: 1 })).resolves.toBe(true); // a shareReplay / BehaviorSubject
1320
- await expect(expectEmission(currentParams$, { until: (p) => p.channelId === expected })).resolves.toEqual(…);
1321
- ```
1322
-
1323
- Both say in the assertion what `source$.pipe(skip(1))` / `pipe(filter(…))` say in the source, and
1324
- they keep the diagnosis: emissions that do not match are still counted, so a failure reads
1325
- `4 emission(s) received` rather than `0` and tells "the wrong thing fired" apart from "nothing
1326
- fired".
1327
-
1328
- **`advance` closes the window between subscribing and awaiting.** A stream driven by a
1329
- `debounceTime`, a retry or a poll needs the clock moved _after_ something is listening, and `await`
1330
- gives control away before the next statement runs:
1331
-
1332
- ```ts
1333
- await expect(expectEmission(purchased$, { advance: () => vi.runAllTimers() })).resolves.toBe(false);
1334
- ```
394
+ - `createLog()` — the order between the spies
1335
395
 
1336
- That replaces the fragile shape people arrive at — hold the promise, advance, then await — which
1337
- breaks silently the moment somebody adds an `await` one line above it. It is a callback rather than
1338
- an `advanceTimers: true` flag because these helpers are in the core entry, which contains no test
1339
- runner: only the spec knows whether it is on `vi`, `bun:test` or `node:test`. A throw out of
1340
- `advance` — or out of an `until` predicate — is reported as itself and tears the subscription down,
1341
- rather than being lost while the wait runs on to the timeout.
1342
-
1343
- **These helpers subscribe as a subscriber, so a synchronous source stops at the value that settles
1344
- the wait.** `expectEmission(from([1, 2, 3]).pipe(tap(spy)))` calls `tap` once, not three times, so
1345
- `expect(spy).toHaveBeenCalledTimes(1)` is honest and a `finalize` runs at the stop; before, the
1346
- producer ran to completion before anything could unsubscribe, and an endless synchronous source
1347
- (`of(1).pipe(repeat())`) hung the worker instead of resolving. `expectEmissions(source$, 3)` stops
1348
- at the third. A source that cannot be subscribed to at all is reported by name now, with a separate
1349
- hint when what was passed is a promise.
1350
-
1351
- `expectEmissions(source$, 0)` throws at the call — a count below 1 is not something a stream can
1352
- satisfy, and the message names `expectNoEmission` instead. A suite that wrote
1353
- `expectEmissions(s, expected.length)` with an empty expectation is the one this changes.
1354
-
1355
- **The watchdog runs on real time, on purpose — even under fake timers, and even under zone.js.** A
1356
- virtual one would race the timers the spec advances: `expectEmission(source$, { timeout: 200 })`
1357
- followed by `vi.advanceTimersByTime(5_000)` would fire at 200 virtual ms and reject the stream the
1358
- spec was about to advance into. Inside `fakeAsync` that used to happen anyway, because zone.js
1359
- patches the global `setTimeout` and the watchdog was scheduled onto the virtual queue: a
1360
- `tick(1_500)` towards a `debounceTime(2_000)` rejected the wait it was advancing. The timer is taken
1361
- from `__zone_symbol__setTimeout` now, so it is outside the zone and `tick()` cannot reach it.
1362
-
1363
- The cost is that in a suite with global fake timers a _failing_ assertion spends a real second. Do
1364
- **not** answer that with `{ timeout: 0 }` at every call site — that disables the watchdog, and the
1365
- next silent stream hangs to the runner's own timeout with nothing useful in the message. Lower the
1366
- default once instead:
1367
-
1368
- ```ts
1369
- // vitest.setup.ts
1370
- import { setEmissionTimeout } from 'vitest-auto-spy';
1371
- import { setupAutoSpy } from 'vitest-auto-spy/setup';
1372
-
1373
- setupAutoSpy({ globalFakeTimers: true });
1374
- setEmissionTimeout(100); // the clock is frozen; a real second buys nothing
1375
- ```
1376
-
1377
- `setEmissionTimeout` takes `0` (no watchdog) and `Infinity` (the same), and **throws** on `NaN` and
1378
- on a negative number rather than installing a default that fails every wait. The commonest way to
1379
- reach it is arithmetic: `setEmissionTimeout(Number(process.env.EMISSION_TIMEOUT))` with the variable
1380
- unset.
1381
-
1382
- **The code frame these failures open is the spec line, not this package.** The helpers build their
1383
- error inside a `subscribe` or timer callback, so the stack is captured at helper entry and pinned on
1384
- when the failure is finally built; only errors these helpers make themselves are re-anchored, and
1385
- the error `expectError` resolves with keeps the stack of the code under test. Do not wrap them in
1386
- `vi.defineHelper` — its `__VITEST_HELPER__` frame lands last and Vitest then drops the stack whole.
1387
-
1388
- **`expectEmission` subscribes when you call it, not when you await it**, and that is load-bearing
1389
- rather than an implementation detail. It is what converts the test whose source has to be poked
1390
- _after_ somebody is listening — a router event, a `Subject` the spec pushes into, anything that
1391
- does not replay:
1392
-
1393
- ```ts
1394
- const breadcrumbs = expectEmission(service.buildDynamicBreadcrumbs({ root })); // subscribed already
1395
-
1396
- router.events.nextWith(navigationEnd); // …so this emission is not missed
1397
-
1398
- await expect(breadcrumbs).resolves.toEqual([…]);
1399
- ```
1400
-
1401
- `firstValueFrom` cannot do this half: it also subscribes eagerly, but there is nowhere to put the
1402
- line that triggers the source, because the `await` is the same statement as the subscription — so
1403
- the test deadlocks against a source that only emits once something pokes it. Hold the promise
1404
- first, poke, then await.
1405
-
1406
- ### `createLog()` — the order between the spies
1407
-
1408
- ```ts
1409
- const log = createLog<'drop-cache' | 'flush-telemetry' | 'stop-engine'>();
1410
-
1411
- engine.onShutdown(log.fn('drop-cache'));
1412
- engine.onShutdown(log.fn('stop-engine'));
1413
- engine.onShutdown(log.fn('flush-telemetry'));
1414
-
1415
- engine.shutdown();
1416
-
1417
- expect(log.result()).toBe('drop-cache; flush-telemetry; stop-engine'); // fails with the real order
1418
- ```
1419
-
1420
- Emission helpers answer a sequence _within one source_. The order of calls **across** collaborators
1421
- is the gap `toHaveBeenCalled` papers over — three green checks that would accept the sequence
1422
- backwards — and `toHaveBeenCalledBefore` covers pairwise. One journal the code under test writes into
1423
- (`add`, `fn(value)` for a labelled callback, `clear`, `items`, `result()`) makes the sequence a
1424
- single comparable value; `T` is a string union so a step nobody declared is a compile error. In
1425
- Angular, provide it and let the component report its own lifecycle:
1426
- `providers: [{ provide: PANEL_LOG, useValue: log }]`. Ported from Angular's own `Log`, which the
1427
- framework keeps three copies of.
396
+ <!-- /agents-outline -->
1428
397
 
1429
398
  ---
1430
399
 
1431
400
  ## 9. Patching properties (and putting them back)
1432
401
 
1433
- ```ts
1434
- import { mockAccessorsProp, mockReadonlyProp, mockReadonlyPropGetter, mockValueProp, restoreMockedProps } from 'vitest-auto-spy';
1435
-
1436
- mockReadonlyProp(service, 'isReady', true); // static value, signals included
1437
- mockReadonlyPropGetter(service, 'label', () => 'A'); // dynamic getter
1438
- mockValueProp(service, 'retries', 3); // plain writable value
1439
- mockAccessorsProp(service, 'theme'); // spied get + set
1440
-
1441
- restoreMockedProps(); // put every patch back; each helper also returns its own undo
1442
- ```
1443
-
1444
- `vi.restoreAllMocks()` does **not** undo these — it knows about spies, not about redefined
1445
- properties. Never use bare `Object.defineProperty` in a spec: nothing restores the original
1446
- descriptor, and under `isolate: false` the patch leaks into the next file.
1447
-
1448
- **They work on `createAutoMock` and `mockDeep` doubles too** — which they did not until 3.5.0.
1449
- Both are Proxies, all four helpers are built on `Object.defineProperty`, and neither Proxy trapped
1450
- it: the patch landed on the Proxy's own target, the `get` trap never looked there, nothing threw,
1451
- and the test carried on reading the old value. If you have seen a spec build a double by hand —
1452
- real getters plus a `createFunctionSpy` per method — this is usually why.
1453
-
1454
- **The second overload is a normal tool, not a last resort.** Each helper has a checked overload
1455
- (`K extends keyof T`) and a `(object, property: PropertyKey, value: unknown)` one behind it, and
1456
- the JSDoc calls the latter the one for members the public type does not describe. In practice it
1457
- carries about half of the real calls, all of them legitimate. It cannot reach a JS `#private` field —
1458
- no property key can — and `'x' as keyof T` over one patches a new, unrelated key that nothing reads:
1459
-
1460
- ```ts
1461
- mockValueProp(router, 'routerState', { snapshot: { url: '/home' } }); // a partial fixture of a fat type
1462
- mockValueProp(window, 'AudioContext', undefined); // "this platform does not ship the API"
1463
- mockValueProp(transitionEvent, 'propertyName', 'opacity'); // a field a synthetic DOM event lacks
1464
- mockValueProp(spy, 'products$', new Subject()); // a member the double does not have at all
1465
- ```
1466
-
1467
- The last one is worth knowing on its own: patching a key the object never had **works and is undone
1468
- correctly** — the journal records the _absence_ of a descriptor and puts it back by deleting the
1469
- property. That is how you add an Observable member that `provideAutoSpy` did not create because
1470
- `observablePropsToSpyOn` was not passed.
1471
-
1472
- What the second overload costs is the property-name check, so a typo in the name compiles. Nothing
1473
- checks the _value_ on either overload; that is deliberate, and the partial fixture above is why.
1474
-
1475
- **A read the constructor does cannot be seeded by any of these.** They patch an object that already
1476
- exists, so the earliest they can run is after the double is built — and a component that reads
1477
- `service.paymentParams.offer` in a field initializer or in its constructor has already read it by the
1478
- time `TestBed.createComponent` returns:
1479
-
1480
- ```ts
1481
- fixture = TestBed.createComponent(PaymentComponent); // ← TypeError thrown here, inside the component
1482
- mockReadonlyProp(paymentService, 'paymentParams', params); // never reached
1483
- ```
1484
-
1485
- Nothing warns, because nothing of this library runs: the throw is a plain `TypeError` from the
1486
- component's own line, and the stack names the component rather than the seeding that is missing. The
1487
- only place early enough is the registration:
402
+ In [`agent-docs/properties.md`](./agent-docs/properties.md). Read it when a test overrides a getter, a readonly property, a signal property or a DOM object property and has to put it back.
1488
403
 
1489
- ```ts
1490
- providers: [provideAutoSpy(PaymentService, { overrides: { paymentParams: params } })];
1491
- providers: [provideAutoSpyForToken(STATE_TOKEN, { snapshot })]; // a token's second argument does the same
1492
- ```
1493
-
1494
- Three migration shards found this independently, each of them after the failure had pointed at the
1495
- service. A `mock*Prop` after the render is right for everything a template or a method reads later —
1496
- which is most of them — and wrong only for a constructor-time read.
1497
-
1498
- ### Properties of DOM objects — the same helpers, and the reason to look for them
1499
-
1500
- `document.fullscreenElement`, `document.visibilityState`, `document.cookie`, `navigator.userAgent`,
1501
- `element.scrollHeight`: half the patching a browser suite does is on objects, not on `globalThis`,
1502
- so the "globals go through `stubGlobal`" rule does not cover it. `mockValueProp` does — it is the
1503
- port of `jest.replaceProperty`, and a project that never used that one walks straight past it.
404
+ <!-- agents-outline: agent-docs/properties.md -->
1504
405
 
1505
- ```ts
1506
- mockValueProp(document, 'fullscreenElement', videoElement);
1507
- mockValueProp(navigator, 'userAgent', 'Tizen 6.0');
1508
- mockValueProp(element, 'scrollHeight', 400);
1509
- ```
1510
-
1511
- The hand-written form fails in three ways that all surface in **someone else's file**:
406
+ - Properties of DOM objects — the same helpers, and the reason to look for them
1512
407
 
1513
- - `Object.defineProperty(obj, key, { value })` defaults `configurable` to `false`, so the property
1514
- can never be changed or removed again — for the rest of the worker;
1515
- - the undo is written as the last line of the test, so a failing assertion skips it;
1516
- - the real property is an **accessor on the prototype** (`document.fullscreenElement` is one), the
1517
- patch writes a `value` over it, and "put the old descriptor back" is not the correct undo —
1518
- deleting the own property is. `mockValueProp` records what was actually there and does the right
1519
- one.
408
+ <!-- /agents-outline -->
1520
409
 
1521
410
  ---
1522
411
 
@@ -1544,281 +433,25 @@ In [`agent-docs/setup.md`](./agent-docs/setup.md). Read it when the task touches
1544
433
 
1545
434
  ## 11. Waiting: four queues, and which tool drives each
1546
435
 
1547
- Under Jest these were hard to tell apart; under Vitest with a real bundler they are four separate
1548
- mechanisms, and a test that waits on the wrong one fails with a message that names none of them.
1549
-
1550
- | What is pending | What drives it | What does **not** |
1551
- | --- | --- | --- |
1552
- | change detection | `fixture.detectChanges()` | anything `await`ed |
1553
- | effects + `afterNextRender` + CD | `await stable(fixture)` (`…/angular`) | `detectChanges()` alone |
1554
- | timers, debounces, polling | `await advanceTimers(ms)` (`…/setup`) | `await Promise.resolve()` |
1555
- | a dynamic `import()`, native `async` in a dep | `await flushEventLoop()` / `settleDynamicImport()` | `tick()`, `flushMicrotasks()`, microtasks |
1556
- | an `httpResource()` / `resource()` / `rxResource` | `await settleResource(r)` (`…/angular`) | `flushEventLoopUntil` — it never ticks |
1557
-
1558
- ```ts
1559
- import { flushEventLoop, settleDynamicImport } from 'vitest-auto-spy';
1560
-
1561
- fixture.debugElement.query(By.css('.open')).nativeElement.click(); // production code: await import(…)
1562
- await settleDynamicImport(() => import('./profile-select.modal'));
1563
- ```
436
+ In [`agent-docs/waiting.md`](./agent-docs/waiting.md). Read it when a test waits for something: change detection, effects, timers, microtasks and dynamic imports are four queues, each with its own tool.
1564
437
 
1565
- Three rules worth stating outright, because each of them cost a day somewhere:
1566
-
1567
- - **`afterNextRender` does not run on `detectChanges()`.** A component that fills a form there is
1568
- still empty when the assertion reads it. `await stable(fixture)` (or `await fixture.whenStable()`)
1569
- is what runs the after-render phase.
1570
- - **`fixture.whenRenderingDone()` is not a stronger `whenStable()`.** With an animation renderer
1571
- installed it degrades to `Promise.resolve()`. Use `stable(fixture)`.
1572
- - **`fakeAsync` / `tick()` / `flushMicrotasks()` never reach the module loader.** Spinning
1573
- `await Promise.resolve()` ten times looks like it works and instead lands the continuation after
1574
- teardown — a green run with `NG0205: Injector has already been destroyed` in "Unhandled Errors"
1575
- and a non-zero exit code.
1576
-
1577
- **`stable()` and `flushEffects()` are not zoneless-only, and their tick runs inside the `NgZone` so
1578
- that they are not.** `TestBed.createComponent` builds the component inside `ngZone.run(…)`, so an
1579
- `effect()` its constructor registers records the zone's inner zone as its own. A bare `TestBed.tick()`
1580
- from a test body runs in the runner's zone, so Angular hops back into the recorded one to run a
1581
- **dirty** effect, and leaving that hop takes the zone from unstable to stable.
1582
- `provideZoneChangeDetection()` — which `@angular/build:unit-test` installs for every suite that loads
1583
- zone.js — answers `NgZone.onMicrotaskEmpty` with `ApplicationRef._tick()`, guarded against its own
1584
- scheduler but not against `ApplicationRef._runningTick`, so the tick already on the stack is
1585
- re-entered: `NG0101: ApplicationRef.tick is called recursively`. Angular hands that to `ErrorHandler`
1586
- instead of throwing it at the call site, so a suite that does not fail on console output stays green
1587
- with the change detection it asked for unfinished. Measured on an Angular 22 suite of 1771 spec files:
1588
- 451 `componentRef.setInput` calls rewritten to `setInputs` turned **57 green files red** on `NG0101`,
1589
- and none of them after the tick moved inside the zone. Under zoneless `NgZone` is a `NoopNgZone` whose
1590
- `run` is a straight call, so it costs nothing there. One thing is genuinely new for a zone-based
1591
- consumer: leaving the zone reports it stable, so the same subscriber ticks once more — counted on
1592
- `ApplicationRef.afterTick`, one `flushEffects()` is 1 → 2 application ticks, which is what
1593
- `fixture.detectChanges()` has always done in the same position. Do not "simplify" either helper back
1594
- to a bare `TestBed.tick()`; `src/zone-tests/stable.zone-test.ts` is the guard.
1595
-
1596
- `flushEventLoopUntil(isDone, { turns, label })` is the same thing with a condition and a budget —
1597
- for a chunk becoming reachable, an SDK reporting itself ready, a queue draining. Use it instead of a
1598
- hand-tuned turn count: the count depends on the dependency, not on the spec, and a condition that
1599
- never holds fails naming the `label` rather than hanging until the runner's timeout. For real I/O —
1600
- a socket round-trip, a child process — pass `{ timeoutMs, label }` instead of `turns`: it polls the
1601
- real clock every 10 ms, unaffected by fake timers, and replaces a hand-rolled `waitFor(predicate, ms)`.
1602
-
1603
- **Not for an Angular `resource()` / `httpResource()`.** Those need a change-detection _tick_, and
1604
- this helper only takes event-loop turns — a resource awaited through it finishes the whole budget
1605
- having issued zero requests. `settleResource(resource, { turns, label, allowIdle })` from
1606
- `vitest-auto-spy/angular` is that wait.
1607
-
1608
- `flushEventLoop(turns?)` takes real event-loop turns even while the timers are faked, without
1609
- touching the clock. It is the honest name for the `await vi.advanceTimersByTimeAsync(0)` trick,
1610
- which reads as "move the timers" in a test that has no timers and gets deleted as noise.
438
+ <!-- agents-outline: agent-docs/waiting.md -->
439
+ <!-- /agents-outline -->
1611
440
 
1612
441
  ---
1613
442
 
1614
443
  ## 12. Doubles for what the code builds itself
1615
444
 
1616
- Production code that does `new Foo()` cannot be served by a `vi.fn()`. Vitest only forwards `new` to
1617
- an implementation that is itself constructible, and **an arrow function is not**: the call is
1618
- recorded, the body never runs, `new` hands back an empty object. The warning Vitest prints
1619
- ("the mock did not use 'function' or 'class'") is nowhere near the failure, which arrives as
1620
- `TypeError: (cb) => {…} is not a constructor` with a stack **in production code** — or as a green
1621
- test for the wrong reason, when the resulting `undefined` is swallowed by a `catch`.
1622
-
1623
- ```ts
1624
- import { createSpyClass, mockConstructor, stubConstructor } from 'vitest-auto-spy';
1625
-
1626
- // a real class exists → full auto-spy instances
1627
- mockValueProp(globalThis, 'Worker', createSpyClass(BackgroundWorker));
1628
-
1629
- // only a type / a shape exists → a runner mock that is also a constructor
1630
- const LicenseClient = mockConstructor<LicenseClient>(() => ({ prepareRequest: vi.fn() }));
445
+ In [`agent-docs/doubles.md`](./agent-docs/doubles.md). Read it when production code does `new Foo()`, touches `<video>`, `localStorage`, animation frames or element boxes, or a module mock did nothing.
1631
446
 
1632
- // it lives on a global (or any object) → the same, installed and auto-restored
1633
- const Image = stubConstructor(globalThis, 'Image', () => ({ src: '' }));
1634
-
1635
- tracker.ping();
1636
- expect(Image).toHaveBeenCalledTimes(1);
1637
- expect(Image.instances[0].src).toBe('https://tns.example/hit');
1638
- ```
1639
-
1640
- `mockConstructor` stays a runner mock, so `toHaveBeenCalledWith` / `mockClear` work as usual, and it
1641
- throws a named error if it is ever called **without** `new`. `stubConstructor` installs through
1642
- `mockValueProp`, so `restoreMockedProps()` puts the platform's constructor back.
1643
-
1644
- **Static members are opt-in — `createSpyClass(Class, config, { statics: true })`.** Production code
1645
- that reaches the class rather than an instance (`BackgroundWorker.isSupported()`,
1646
- `Client.fromToken(t)`, a `VERSION` constant) finds nothing on the double otherwise, and the failure
1647
- is a `TypeError` on the class, not on the spy:
1648
-
1649
- ```ts
1650
- const WorkerSpy = createSpyClass(BackgroundWorker, undefined, { statics: true });
1651
-
1652
- WorkerSpy.isSupported.mockReturnValue(true); // needs a cast — see below
1653
- ```
1654
-
1655
- What the option copies, walking the class's own prototype chain so inherited statics come too: a
1656
- static **function** becomes a full spy of its own, a static **data member** is copied by value, and a
1657
- static **accessor** is skipped and never evaluated — a getter that reads configuration or touches the
1658
- network must not run because a double was built. `prototype`, `length`, `name`, `caller`,
1659
- `arguments` and the double's own `calls` / `instances` are never overwritten. It is off by default
1660
- for that last reason: a class with a static named `calls` would otherwise shadow the construction
1661
- log. **The statics are not typed** — the return type is still `ConstructorSpy<T>`, so reading one
1662
- needs a cast at the spec; type them and this note goes.
1663
-
1664
- For the three observers, prefer the purpose-built stubs (§13). For a `Worker` the code builds with
1665
- `new Worker(new URL(…, import.meta.url))`, prefer `stubWorker({ respond })` from `/dom-stubs`: an
1666
- `EventTarget` whose listeners all receive the reply, answered on a microtask after `postMessage`
1667
- returns, with `last.messages`, `last.emit(data)` and `last.fail(error)` on the handle. For `AbortController` — which breaks
1668
- in a jsdom run for a reason involving none of the three parties in the stack trace — use
1669
- `stubAbortController()`.
1670
-
1671
- The stub carries `AbortSignal.abort()`, `AbortSignal.timeout()` and `AbortSignal.any()` as well —
1672
- the three statics are how modern code makes a signal without a controller, `fetch(url, { signal:
1673
- AbortSignal.timeout(5_000) })` most of all. `timeout()` aborts through `setTimeout`, so
1674
- `vi.useFakeTimers()` drives it exactly as it drives the platform's, and it aborts with a
1675
- `TimeoutError` rather than an `AbortError` because that is the distinction the platform draws. A
1676
- signal's `reason` is the platform's `DOMException`, so code branching on
1677
- `signal.reason.name === 'AbortError'` takes the same branch it takes in a browser.
1678
-
1679
- ### `<video>` and `<audio>`
1680
-
1681
- jsdom implements them as a shell: `play()` throws, `duration` is `NaN` and is an accessor with no
1682
- setter, `canPlayType()` answers `''` for everything, `readyState` never leaves 0, `error` is not on
1683
- the prototype. `stubMediaElement()` patches the prototype (so it covers an element production code
1684
- creates itself) and, crucially, **fires the event that goes with each change** — production code
1685
- listens for `durationchange` / `timeupdate` / `ended`, and assigning the field alone leaves those
1686
- handlers unrun:
1687
-
1688
- ```ts
1689
- const media = stubMediaElement({ duration: 120 });
1690
-
1691
- media.set(video, { readyState: 1 }); // → loadedmetadata
1692
- media.set(video, { currentTime: 119 }); // → timeupdate
1693
- media.set(video, { ended: true }); // → pause, then ended — and the element is paused
1694
- expect(media.play).toHaveBeenCalledTimes(1);
1695
- ```
1696
-
1697
- State is per element, so an ad and the content report different durations — and per **install**, so
1698
- an element held in module scope or left in the document under `isolate: false` takes the new
1699
- install's `duration` rather than the previous one's. `set({ ended: true })` pauses the element and
1700
- fires `pause` before `ended`, as the platform does: an element that is both ended and playing is a
1701
- state no browser produces, and a player listening for `pause` was never told.
1702
-
1703
- `currentTime` is a get/set pair, so a player restarting itself with `video.currentTime = 0` reaches
1704
- the record and fires `timeupdate` too — `media.set()` is not the only way in, and the component's own
1705
- handler runs where it used to stay unrun while the assertion read the new value.
1706
-
1707
- ### `localStorage` and `sessionStorage`
1708
-
1709
- Do not write a `TestingStorage` class per project. `stubWebStorage()` installs an in-memory `Storage`
1710
- for this test — `getItem` / `setItem` / `removeItem` / `clear` / `key` / `length`, string coercion
1711
- as the platform does it — and `snapshot()` is the plain record to assert on:
1712
-
1713
- ```ts
1714
- import { type WebStorageStub, stubWebStorage } from 'vitest-auto-spy/dom-stubs';
447
+ <!-- agents-outline: agent-docs/doubles.md -->
1715
448
 
1716
- let local: WebStorageStub;
449
+ - `<video>` and `<audio>`
450
+ - `localStorage` and `sessionStorage`
451
+ - Animation frames and element boxes
452
+ - A module mock that did nothing
1717
453
 
1718
- beforeEach(() => {
1719
- local = stubWebStorage('localStorage', { items: { token: 'abc' } }); // or 'sessionStorage'
1720
- });
1721
-
1722
- it('forgets the token on logout', () => {
1723
- session.logout();
1724
- expect(local.snapshot()).toEqual({});
1725
- });
1726
- ```
1727
-
1728
- Writes in the middle of a test go through `.storage`, the installed `Storage` itself:
1729
- `local.storage.setItem('token', 'xyz')`. The stub is not a `Storage`; `local.setItem` is a `TS2339`.
1730
-
1731
- It goes through `mockValueProp`, so `restoreMockedProps()` (and `setupAutoSpy()` between tests) puts
1732
- the previous storage back; install it in `beforeEach`. It also lands on `document.defaultView` when
1733
- that is a separate object. It is not `restoreWebStorage()`: that one repairs a broken environment
1734
- once and leaves a working storage alone; this one replaces whatever is there, for one test, and
1735
- installs even in a `node` environment because the spec asked for it. Named-property access
1736
- (`localStorage.token`, `Object.keys(localStorage)`) does not see the items — read through the API.
1737
-
1738
- ### Animation frames and element boxes
1739
-
1740
- Do not assign `window.requestAnimationFrame` by hand, and do not stub `getBoundingClientRect` with a
1741
- partial literal. Both have a stub on `/dom-stubs`:
1742
-
1743
- ```ts
1744
- import { stubAnimationFrame, stubElementRect } from 'vitest-auto-spy/dom-stubs';
1745
-
1746
- beforeEach(() => {
1747
- stubAnimationFrame(); // 'immediate': the callback runs before requestAnimationFrame returns
1748
- stubElementRect(container, { width: 800, height: 600 }); // x / y default to 0
1749
- });
1750
-
1751
- it('scrolls after the next frame', () => {
1752
- const frames = stubAnimationFrame({ mode: 'queued' });
1753
-
1754
- list.scrollToSelected();
1755
- expect(frames.pending).toBe(1);
1756
-
1757
- frames.flush(); // one frame; the timestamp defaults to performance.now()
1758
- expect(list.scrolled).toBe(true);
1759
- });
1760
- ```
1761
-
1762
- `stubAnimationFrame` stubs `cancelAnimationFrame` too, and hands both spies back as
1763
- `frames.requestAnimationFrame` / `frames.cancelAnimationFrame`. A frame requested from inside a
1764
- running frame is queued in either mode, so a loop that requests its own next frame advances one step
1765
- per `flush()`. A callback that throws stops `flush()` (or propagates out of `requestAnimationFrame`
1766
- in `'immediate'` mode); pass `onError: (error) => { … }` to intercept it instead — rethrow from
1767
- inside to keep the default for an error you did not mean to swallow. `stubElementRect` returns a real
1768
- `DOMRect` per call, edges derived, plus the installed spy as `.getBoundingClientRect` for
1769
- `expect(stub.getBoundingClientRect).toHaveBeenCalled()`, and the return value is still callable as the
1770
- undo. The box is the spy's creation implementation, so `vi.resetAllMocks()` / `mockReset()` mid-test
1771
- keeps it on both spy engines; Bun's `mockReset()` drops it (re-stub after a reset there). Both go through `mockValueProp`, so `restoreMockedProps()` (and `setupAutoSpy()` between tests)
1772
- takes them off; `frames.restore()` does it sooner.
1773
-
1774
- ### A module mock that did nothing
1775
-
1776
- `vi.mock()` is the one thing in a ported suite that fails **silently**. Under a bundler
1777
- (`@angular/build:unit-test`, a pre-built `vite-node` entry) a workspace alias or a barrel is already
1778
- inlined when the mock would be installed, so the real implementation runs and the test either passes
1779
- for the wrong reason or fails somewhere unrelated.
1780
-
1781
- ```ts
1782
- import * as engine from '@app/pricing-engine';
1783
-
1784
- vi.mock('@app/pricing-engine');
1785
- beforeEach(() => assertMocked(engine, { specifier: '@app/pricing-engine', exports: ['createEngine'] }));
1786
- ```
1787
-
1788
- And when a mocked dependency probes itself with `mod.default ?? mod` — every package that ships both
1789
- CJS and ESM does — a factory of bare named exports throws `No "default" export is defined on the
1790
- mock` from **inside that dependency**. `moduleNamespace` is the shape it expects:
1791
-
1792
- ```ts
1793
- vi.mock('shaka-player', () => moduleNamespace({ Player: mockConstructor(() => playerStub) }));
1794
- ```
1795
-
1796
- A factory that spells out its own `default` **keeps it** — `moduleNamespace({ default: dayjsStub, utc })`
1797
- gives the probing dependency `dayjsStub`, where it used to be replaced by the namespace itself and
1798
- the default export silently became the wrong object. Only a factory without one gets
1799
- `default: <the namespace>`, which is the interop shape it was there for; the return type follows.
1800
-
1801
- A factory's `vi.fn()` comes back typed as the real function, without `calledWith` or `resolveWith`.
1802
- `adoptMock` takes it over in place — same object, history kept, typed from the export's signature —
1803
- and an unconfigured call keeps answering what the mock answered before:
1804
-
1805
- ```ts
1806
- import { loadUser } from './api';
1807
-
1808
- vi.mock('./api', () => ({ loadUser: vi.fn() }));
1809
- adoptMock(loadUser).calledWith(7).resolveWith({ id: 7, name: 'Ada' });
1810
- ```
1811
-
1812
- To keep the real module and configure one case, spy it through:
1813
- `vi.mock('./api', async (importOriginal) => moduleNamespace(await importOriginal(), { passthrough: true }))`.
1814
- Every function export runs for real and is recorded until configured; classes and values stay real;
1815
- calls one export makes to another inside the module are not recorded. A `vi.spyOn` / `{ spy: true }`
1816
- mock calls an original it does not report, so adopting it makes an unconfigured call answer
1817
- `undefined`; `node:test`'s `mock.fn()` is refused.
1818
-
1819
- There is no `mockModule(…)` helper here, and there cannot be: Vitest hoists the literal `vi.mock`
1820
- call, so a wrapper around it would be hoisted as a call to a function that does not exist yet. Share
1821
- a fixture between the factory and the tests with `vi.hoisted()`.
454
+ <!-- /agents-outline -->
1822
455
 
1823
456
  ---
1824
457
 
@@ -1892,58 +525,10 @@ convenient re-export from the root: it would quietly hand zone.js to every zonel
1892
525
 
1893
526
  ## 15. Other adapters
1894
527
 
1895
- ```ts
1896
- // NestJS
1897
- import { createNestUnit, injectSpy, provideAutoSpy } from 'vitest-auto-spy/nestjs';
1898
- const moduleRef = await Test.createTestingModule({ providers: [provideAutoSpy(MyService)] }).compile();
1899
- const spy = injectSpy(moduleRef, MyService);
1900
- // or no module at all: the unit built from its DI metadata, every unprovided token spied
1901
- const { unit, spies } = createNestUnit(CartService, { expose: [PricingService], providers: [{ provide: 'CONFIG', useValue: cfg }] });
1902
- spies.get(TaxService).rate.mockReturnValue(0.2); // spies.get refuses a token the unit never asked for, and an exposed class
1903
- spies.autoSpiedTokens(); // what nothing provided — the list the refusal message prints; spies.exposedTokens() is what `expose` actually built
1904
- // needs what Nest needs: emitDecoratorMetadata (tsc / SWC, not esbuild) and reflect-metadata loaded first
1905
-
1906
- // Vue / Pinia — provideAutoSpy(token, Class, methodsOrConfig?) returns a `global.provide` map
1907
- import { provideAutoSpy } from 'vitest-auto-spy/vue';
1908
- const provide = provideAutoSpy(UserServiceKey, UserService);
1909
- provide[UserServiceKey].getName.mockReturnValue('Ada');
1910
- mount(Greeting, { global: { provide } });
1911
- // a setup-store (`defineStore('x', () => …)`) is not a class — use createAutoMock<T>() there
1912
-
1913
- // React / Svelte — the core API, re-exported with the right adapter registered
1914
- import { createSpyFromClass } from 'vitest-auto-spy/react';
1915
- ```
528
+ In [`agent-docs/adapters.md`](./agent-docs/adapters.md). Read it for NestJS, Vue, React, Svelte, `bun test`, `node:test`, Rstest and the console entry.
1916
529
 
1917
- Console spies — `installConsoleSpies()` replaces `console.debug` / `error` / `info` / `log` / `time` /
1918
- `timeEnd` / `trace` / `warn` with silent typed spies, named `console<Method>Spy`. Install them per test
1919
- and take them off after it:
1920
-
1921
- ```ts
1922
- import { type ConsoleSpies, installConsoleSpies, restoreConsole } from 'vitest-auto-spy/console';
1923
-
1924
- let consoleSpies: ConsoleSpies;
1925
-
1926
- beforeEach(() => {
1927
- consoleSpies = installConsoleSpies();
1928
- });
1929
- afterEach(() => restoreConsole());
1930
-
1931
- expect(consoleSpies.consoleInfoSpy).toHaveBeenCalledWith('done'); // the output is silenced, not printed
1932
- ```
1933
-
1934
- The exported `consoleInfoSpy` & co. are the same objects; `restoreConsole()` keeps them and clears
1935
- their calls. **Do not rely on the import to install them**: it does so once per worker, so under
1936
- `isolate: false` the spies go on in whichever file imported them first and silence every later file
1937
- (`no-import-time-console-spies` reports it). Under `setupAutoSpy({ strayConsole })` the import installs
1938
- nothing at all.
1939
-
1940
- The spies survive `vi.resetModules()`. A fresh copy of the module used to record the previous copy's
1941
- spy as "the real `console.warn`", after which `restoreConsole()` installed that dead spy for the rest
1942
- of the worker and every log from then on went nowhere. The real methods are kept on one shared table
1943
- and a spy of another copy is refused as an original.
1944
-
1945
- Import your runtime entry (`…/bun`, `…/node`) **before** `…/console`, or it registers the Vitest
1946
- adapter. Prefer not to touch the real global? `createAutoMock<Console>()` gives a detached one.
530
+ <!-- agents-outline: agent-docs/adapters.md -->
531
+ <!-- /agents-outline -->
1947
532
 
1948
533
  ---
1949
534
 
@@ -1967,385 +552,27 @@ In [`agent-docs/errors.md`](./agent-docs/errors.md). A table from the text of a
1967
552
 
1968
553
  ## 18. Do not write this
1969
554
 
1970
- | ❌ | ✅ |
1971
- | --- | --- |
1972
- | `import … from 'jest-auto-spies'` | `import … from 'vitest-auto-spy'` |
1973
- | `vitest-auto-spy` inside a `bun test` file | `vitest-auto-spy/bun` |
1974
- | `let s: MyService = createSpyFromClass(MyService)` | `let s: Spy<MyService> = …` |
1975
- | `createSpyFromClass(X) as unknown as X` | `asInstance(createSpyFromClass(X))` |
1976
- | `{ provide: X, useValue: { a: vi.fn(), b: vi.fn() } }` | `provideAutoSpy(X)` |
1977
- | `{ provide: ActivatedRoute, useValue: { snapshot: { params } } }` | `provideActivatedRoute({ params })` — streams and snapshot from one record |
1978
- | `createAutoMock<Router>({ events: of(), url: '/' }, { returns: { navigate } })` | `provideRouterDouble({ url })` — one URL behind `url`, `routerState` and `events` |
1979
- | `vi.spyOn(TestBed.inject(X), 'method')` | `injectSpy(X).method` |
1980
- | `vi.spyOn(component, 'ngOnInit')` after `createComponent` | `vi.spyOn(Cls.prototype, 'ngOnInit')` before it, or assert the effect |
1981
- | `{ provide: IS_BROWSER, useValue: {} }` for an `InjectionToken<boolean>` | `useValue: false` — a value of the type the token declares |
1982
- | `Object.defineProperty(service, 'ready', { value: true })` | `mockReadonlyProp(service, 'ready', true)` |
1983
- | `source$.subscribe(v => expect(v).toBe(1))` | `await expect(expectEmission(source$)).resolves.toBe(1)` |
1984
- | `await lastValueFrom(done$, { defaultValue: undefined })` | `await expectCompletion(done$)` |
1985
- | `{ timeout: 0 }` on every helper because timers are faked | `setEmissionTimeout(100)` once, in the setup file |
1986
- | `{ provide: AbstractToken, useValue: createAutoMock<T>() }` | `provideAutoSpy(AbstractToken)` |
1987
- | `mockDeep<T>()` for a chain that goes through a **call** | `mockDeep<T>({}, { selfReturning: true })` |
1988
- | `await expect(expectEmission(x$)).rejects.toBe(originalError)` | `await expect(expectError(x$)).resolves.toBe(originalError)` |
1989
- | `source$.pipe(skip(1))` / `pipe(filter(p))` in front of the helper | `expectEmission(source$, { skip: 1 })` / `{ until: p }` |
1990
- | hold the promise, `vi.runAllTimers()`, then await | `expectEmission(source$, { advance: () => vi.runAllTimers() })` |
1991
- | `injectSpy(X)` then a `mockReturnValue` per method in `beforeEach` | `provideAutoSpy(X, { returns: { … }, overrides: { … } })` |
1992
- | a local `injectSpy` wrapper with `as never` + `as Spy<T>` | the library's — it also takes an `InjectionToken` |
1993
- | a hand-written double for a token with `Observable` members | `provideAutoSpyForToken(T, undefined, { observablePropsToSpyOn: […] })` |
1994
- | `mockDeep<T>() as unknown as T` to satisfy an API typed against `T` | `asInstance(mockDeep<T>())` |
1995
- | `from([double])` to stop `of(double)` swallowing the double | `of(double)` — `schedule` is no longer answered (§2) |
1996
- | `spy.instanceField.mockReturnValue(…)` on a member Angular moved | `provideAutoSpy(X, { instanceMethodsToSpyOn: ['…'] })` (§5) |
1997
- | `expect(component.total).toBeTruthy()` (a signal) | `expect(component.total).toHaveSignalValue(3)` |
1998
- | `fixture.detectChanges()` then assert signal state | `await stable(fixture)` then assert |
1999
- | `onlyMethodsToSpyOn: [...]` "to add a method" | omit it, or use `instanceMethodsToSpyOn` |
2000
- | a `vi.fn()` the code calls with `new` | `createSpyClass(Foo)` / `mockConstructor(factory)` |
2001
- | an exported `const` fixture holding `vi.fn()`s | an exported **factory** that returns it (§10) |
2002
- | `let s: Mocked<MyService>` (Vitest's own type) | `let s: Spy<MyService>` |
2003
- | `it('x', (done) => …)` / `beforeEach((done) => …)` | `async` + `await` — Vitest passes a `TestContext`, not `done` |
2004
- | `{ ...modelInstance, flag: true }` (drops every getter) | `withOverrides(modelInstance, { flag: true })` |
2005
- | `if ('params' in link) … else throw` in every spec | `narrow.byKey(link, 'params')` |
2006
- | `const x = a?.b; assert.exists(x); … x …` on every optional read | `narrow.defined(a?.b)` — it returns the value, so the narrowing sits in the expression |
2007
- | `expect(body).toBeInstanceOf(FormData); const form = body as FormData` | `narrow.instanceOf(body, FormData)` — the check and the type in one expression |
2008
- | five `asInstance(...)` in one call | `...asInstances(a, b, c, d, e)` |
2009
- | `vi.stubGlobal('Image', vi.fn(() => ({ src: '' })))` | `stubConstructor(globalThis, 'Image', () => ({ src: '' }))` |
2010
- | `Object.defineProperty(document, 'cookie', { value })` | `mockValueProp(document, 'cookie', value)` |
2011
- | `vi.spyOn(globalThis, 'Date')` | `mockSystemTime('2025-04-30T00:00:00Z')` |
2012
- | ten `await Promise.resolve()` for a dynamic `import()` | `await settleDynamicImport(() => import('…'))` |
2013
- | a bare `await import('…')` in a test body | `await settleDynamicImport(() => import('…'))` — `prefer-settle-dynamic-import` reports it |
2014
- | a capture whose only assertions are `toEqual([])` / `not.toHaveBeenCalled()` | `await expectNoEmission(source$)` — `no-vacuous-absence-assertion` reports it |
2015
- | `{ id: '1', isOffline: false } as Device` (a cast passes an excess key) | `createMock<Device>({ id: '1' })` — `prefer-create-mock` reports it |
2016
- | `TestBed.inject(S).m as Mock` (`Mock` is `Mock<any>`) | `injectSpy(S).m` — `no-mock-cast` reports it |
2017
- | `await fixture.whenRenderingDone()` | `await stable(fixture)` |
2018
- | an exported `const` provider with `vi.fn()` inside | an exported **factory** returning it (§10) |
2019
- | `.overrideProvider(X, provideAutoSpy(X))` (works, but says the wrong thing) | `.overrideProvider(X, overrideAutoSpy(X))` |
2020
- | `TestBed.overrideComponent` to swap a provider | `overrideComponentProvider(Cmp, X)` |
2021
- | `{ target, isIntersecting } as unknown as IntersectionObserverEntry` | `intersectionEntry(target, true)` |
2022
- | `@Component({ selector: 'app-chart' }) class MockChartComponent { @Input() … }` | `createComponentStub(ChartComponent)` — the selector cannot drift |
2023
- | a `TestingStorage` class assigned to `globalThis.localStorage` | `stubWebStorage('localStorage')`, restored with the other patches |
2024
- | an assertion containing a date, with no clock set | `mockSystemTime(iso)` first |
2025
- | `configureTestingModule` inside every `it()` | one per `describe` |
2026
- | `vi.mock('@angular/core')` to neutralise `effect()` | set the signals, `await stable(fixture)`, assert the result |
2027
- | a second `vi.spyOn(console, 'error')` | `consoleErrorSpy` from `vitest-auto-spy/console`, installed with `installConsoleSpies()` |
2028
- | `import { consoleErrorSpy } from 'vitest-auto-spy/console'` as the install | `installConsoleSpies()` in `beforeEach`, `restoreConsole()` in `afterEach` — the import installs once per worker |
2029
- | a bare `vi.spyOn(console, 'error')` to keep a spec quiet | `.mockImplementation(() => undefined)` — without it the line still prints |
2030
- | `mockReadonlyProp(c, 'items', vi.fn(() => []))` | `mockReadonlyProp(c, 'items', signal([]))` — a real signal |
2031
- | `spy.m.mockReturnValue(subject$)` for a `vi.fn(() => subject$)` | `spy.m.mockImplementation(() => subject$)` — the variable is re-read |
2032
- | `const overrides = { m: vi.fn(() => of(x)) }` hoisted out of the call | `const overrides: DeepPartial<X> = { … }` — checked against `X` where it is written |
2033
-
2034
- **A hand-rolled double trips `rxjs-x/finnish`, and a double from this package does not.** The rule
2035
- reports any name whose type is an `Observable` **or whose call signature returns one**
2036
- (`couldReturnObservable`). `vi.fn()` and `vi.spyOn()` hand back a callable mock carrying the
2037
- signature of the method it stands for, so a double for a method returning an `Observable` reads to
2038
- the rule as a stream and it asks for a `$` — on a spy, where the `$` would lie about what the
2039
- variable holds. `functions: false` gives no relief: that option is about function _declarations_,
2040
- not a variable holding one. A `Spy<T>` from `createSpyFromClass`, `provideAutoSpy` or
2041
- `createAutoMock` is an ordinary object instead — the Observable sits on its members, not on the
2042
- variable — so nothing is reported, and neither the name nor the assertion has to be bent. Measured
2043
- on an Angular workspace of 1771 spec files: the rule named seven sites, every one a hand-rolled
2044
- `vi.fn`/`vi.spyOn`, and none on a double built here.
2045
-
2046
- The overrides bag is the one shape worth knowing in detail, because the repair is not a rename.
2047
- Written inline, `provideAutoSpy(X, { overrides: { m: vi.fn(() => of(x)) } })` is invisible to the
2048
- rule — an object literal inside a call is skipped. Hoisted into a `const` it is reported **per key**,
2049
- and the key cannot be renamed: it is the method's name on `X`. What clears it is the annotation the
2050
- bag should carry anyway, `const overrides: DeepPartial<X> = { … }` — the rule skips a literal whose
2051
- variable is annotated, and the bag starts being checked against `X` where it is written rather than
2052
- only at the call. For a spy whose name is genuinely free, `*Spy` plus a `names` exemption in the
2053
- rule's own options says the same thing once for the whole repository.
2054
-
2055
- **The one mechanical rename in a migration that is not equivalent.** `vi.fn(() => x)` reads `x`
2056
- when the double is _called_; `mockReturnValue(x)` freezes the value `x` had when the double was
2057
- _configured_. They are indistinguishable until the test reassigns `x` — and the commonest reason to
2058
- do that is a fresh `Subject` after the previous one has been `error()`ed or completed, which is
2059
- exactly the case a suite is testing when it reassigns. The double then keeps handing out the dead
2060
- one: in one spec the service received a completed subject and silently skipped the modal it was
2061
- meant to show, with the test still green. Carry `vi.fn(() => x)` over as
2062
- `mockImplementation(() => x)`, and keep `mockReturnValue` for a literal. Worth saying out loud to
2063
- anyone writing a codemod, because the rename looks like the safest edit in the file.
2064
-
2065
- ### Advice that circulates and is wrong on Vitest
2066
-
2067
- Jest-era tutorials and cheat sheets repeat a handful of lines that fail on Vitest, or pass and leak.
2068
- Each was checked on Vitest 5.0.0:
2069
-
2070
- | Circulating advice | What happens | Write instead |
2071
- | --- | --- | --- |
2072
- | `const load = vi.fn(); vi.mock('./api', () => ({ load }))` | `vi.mock` is hoisted above every import and `const`: `ReferenceError: Cannot access 'load' before initialization`, wrapped in `[vitest] There was an error when mocking a module` | `const mocks = vi.hoisted(() => ({ load: vi.fn() }))`, then `() => ({ load: mocks.load })` |
2073
- | "name it `mockLoad` and the factory may read it" | Jest's exemption for `mock`-prefixed names does not exist in Vitest — same `ReferenceError` | `vi.hoisted` |
2074
- | `vi.requireActual('./api')` / `jest.requireActual` | `vi.requireActual` is `undefined` | `vi.mock('./api', async (importOriginal) => ({ ...(await importOriginal<typeof import('./api')>()), load: vi.fn() }))` |
2075
- | `import { jest } from 'vitest'` | `vitest` exports no `jest`; the binding is `undefined` | `vi` — `vi.fn()`, `vi.spyOn()`, `vi.mock()` |
2076
- | `import { userEvent } from '@testing-library/user-event'` | the named export exists only from 14.5.0; the default export works on every 14.x, and every 14.x method returns a promise | `import userEvent from '@testing-library/user-event'`; `const user = userEvent.setup(); await user.click(el)` |
2077
- | `vi.restoreAllMocks()` "to undo fake timers" | `restoreAllMocks`, `resetAllMocks` and `clearAllMocks` leave fake timers installed — `vi.isFakeTimers()` is still `true` | `vi.useRealTimers()` in `afterEach` |
2078
- | `global.fetch = vi.fn()` in a test | survives `vi.restoreAllMocks()` and `vi.unstubAllGlobals()`, and answers every later test of the file | `mockValueProp(globalThis, 'fetch', …)` or `vi.stubGlobal` + `unstubGlobals: true`; `blockNetwork()` to stay offline; the `no-hand-assigned-global` rule reports the bare form |
2079
-
2080
- ---
2081
-
2082
- ## 19. Before you report success
2083
-
2084
- Run what the project actually has — check its `package.json` first.
555
+ In [`agent-docs/anti-patterns.md`](./agent-docs/anti-patterns.md). Read it before writing a spec: the table of patterns to avoid with what to write instead, and advice that circulates and is wrong on Vitest.
2085
556
 
2086
- ```bash
2087
- npx vitest run path/to/file.spec.ts # or: bun test path/to/file.test.ts
2088
- npx tsc --noEmit # Spy<T> mistakes are compile errors, not runtime ones
2089
- npx vitest-auto-spy doctor # suite-level defects that never fail a run
2090
- ```
2091
-
2092
- **If you are an agent, add `--reporter=agent`** (Vitest 4.1). It is Vitest's own reporter, added for
2093
- exactly this: the same failures, without the passing-test roll call and the repeated banners that
2094
- make a run's output expensive to read and expensive to carry.
2095
-
2096
- ```bash
2097
- npx vitest run --reporter=agent path/to/file.spec.ts
2098
- ```
557
+ <!-- agents-outline: agent-docs/anti-patterns.md -->
2099
558
 
2100
- Type errors matter here more than usual: most of this library's guarantees are type-level, so a
2101
- suite that runs green but does not type-check is not done.
2102
-
2103
- `doctor` is read-only and finds what a green run cannot: a `tsconfig` `include` pattern that
2104
- matches no file (so it type-checks nothing while `tsc --noEmit` still reports success), a
2105
- production module importing a `*.spec.ts`, a spec importing another spec, a `@jest-environment`
2106
- pragma the runner never reads, configuration left behind for a runner that is gone, and a setup
2107
- file an Angular or Nx unit-test target never runs because the target does not name it. Three of
2108
- its checks are about coverage, where the run is green and the report is simply not the one the
2109
- config describes — `coverage.all` on a Vitest that stopped reading the key, a source-only
2110
- `coverage.include` in a runner config over a bundle, and a scope so large that `picomatch`
2111
- recompiling it per file costs more than collecting the coverage does. Two are about the Angular
2112
- builder's version: `@angular/build` in `[22.1.5, 22.1.7)` (`angular-build-splitting-off`), and an
2113
- `@analogjs/vite-plugin-angular` below 2.7.5 next to `@angular/build` 22.2 or newer
2114
- (`analog-behind-angular-build`), which dies at startup with `TypeError: cache.has is not a function`.
2115
- Five are about Vitest 5. On Vitest 5, `vitest-5-removed` reports what it took away and the run now
2116
- trips over — an import from `vitest/reporters`, `vitest/coverage`, `vitest/environments`,
2117
- `vitest/snapshot`, `vitest/runners`, `vitest/suite` or `vitest/mocker`, a `.sequential` chain,
2118
- `--outputJson`/`--compare` in a script — as errors; on Vitest 4 the same lines are upgrade notes.
2119
- `poolOptions` on Vitest 4 or newer is a warning, because Vitest runs without every option inside it.
2120
- `vitest-5-deprecated` names the two renamed programmatic calls nothing warns about. `vitest-5-clear-mocks`
2121
- says `clearMocks: true` restates the Vitest 5 default, and tells a Vitest 4 suite that never set it
2122
- to price the change with `npx vitest run --clearMocks` first. `vitest-5-available` tells a Vitest 4
2123
- repository what the upgrade buys — or names what holds it back: `@angular/build` below 22.2.0 beside
2124
- a unit-test target, Analog below 2.7.5, `vite` below 6.4, or Node below 22.12 in `.nvmrc`,
2125
- `.node-version`, CI or a private package's `engines`. `fs-module-cache-not-persisted` warns when
2126
- `fsModuleCache` is on and no CI config caches its directory. Renamed config keys Vitest 5 still
2127
- honours (`experimental.fsModuleCache`, `browser.isolate`, `cache.dir`, …) are left to Vitest's own
2128
- deprecation line.
2129
- It is worth one run after any large edit to a test suite — especially after a codemod, which is where
2130
- the eaten glob below came from.
2131
-
2132
- Two of its checks are about this package's own names. `helper-from-wrong-entry` catches a named
2133
- import taken from an entry that does not export it — `provideAutoSpy` from the root rather than
2134
- `/angular` or `/nestjs`, `expectRequest` from anywhere but `/angular-http`. It reads both directions
2135
- of the 5.21.0 Angular split — one of the thirty-two moved names still taken from `/angular`, and a
2136
- core name taken from `/angular/diagnostics`, `/angular/doubles` or `/angular/matchers`, which export
2137
- none of them — which makes it what fails that upgrade rather than a compiler: a repository green on
2138
- 5.20.0 exits 1 here with no test run, and the fix line names the companion to move to.
2139
- `no-unawaited-helper`
2140
- catches an `expectEmission` / `expectError` / `stable` / `flushEventLoop` called as a bare statement
2141
- and dropped, so the promise settles after the test ended and its assertion reports into a later test
2142
- or nowhere. Both resolve the name against a table **generated from this package's own `exports`
2143
- map**, which is why they are `doctor` checks and not lint rules: a per-file linter has none. Both
2144
- are conservative — the callee must have been imported from this package in that same file (a rename
2145
- with `as` still counts), the call must both begin and end a statement, and the pair goes quiet when
2146
- the installed major differs from the table's. Neither has a fixer; `doctor` still never writes.
2147
- Full reference: <https://asdalexey.github.io/vitest-auto-spy/utilities/cli>.
2148
-
2149
- **Three things about the CLI that decide whether its answer means anything:**
2150
-
2151
- - **An unknown flag is refused, exit 2, nothing runs.** `init --dryrun` used to write the files and
2152
- `perf --gat` used to pass with no gate at all — a typo in CI that read as a clean result. The two
2153
- stderr lines name the flag and list what the command accepts; `--cwd`, `--help` and `--version`
2154
- work everywhere.
2155
- - **The scan does not descend into a nested repository or a git worktree.** A tree carrying
2156
- worktrees under it listed every file twice, so `doctor` reported each import graph in duplicate and
2157
- `codemod --write` would have rewritten specs on another branch. A `.git` entry is a stop, whether
2158
- it is a directory (a nested clone) or a file (a worktree). Directories git ignores are skipped too
2159
- — every `.gitignore` from the scan root down, `.git/info/exclude` and the per-user
2160
- `core.excludesFile`, directories only, so an ignored file is still listed. Past 50 000 files the scan still
2161
- truncates, and `doctor` reports that as a `scan-cap-reached` warning (exit 1) rather than a clean
2162
- result; `VITEST_AUTO_SPY_SCAN_CAP` raises the cap.
2163
- - **A path that matches no file is an error, exit 2.** _Nothing left to migrate_ off a path nobody
2164
- read is not a clean result. Absolute paths and `./`-style ones resolve against `--cwd`.
2165
-
2166
- Exit codes, as the commands implement them: **0** is "ran, nothing to report"; **1** is "ran, and
2167
- here is the finding" — `doctor` with an error, `init --check` with a stale block, `codemod` with any
2168
- note, `perf --gate` over budget; **2** is "there was nothing to judge" — no command, an unknown
2169
- command, an unknown flag, a flag value it cannot use (an `--min-severity` word it does not know, a
2170
- number flag given a word or a negative number, a value flag with nothing after it, an `--ignore` id
2171
- `doctor` has no check for), an unreadable `--only` / `--from` value, a path matching no file, and a
2172
- `perf` run that measured nothing (including a red suite, which the gate will not judge at all). An
2173
- invalid flag value stops the command before it does anything: the message says so (`Nothing ran.`),
2174
- and a typo in a command, a flag, `--only` or `--ignore` gets a `Did you mean`.
2175
-
2176
- **Reading the output from a script: `--format json`**, on `doctor` and on `perf`. One JSON document
2177
- on stdout — `schema`, `exitCode`, `tally` (`errors`, `warnings`, `notes`), every finding with
2178
- `check`, `severity`, `file`, `message`, `fix`; `perf` adds `run` (with `slowestFiles`, the `--top` slowest files per phase), `budgets` and `gate.verdicts` (one row
2179
- per candidate, `outcome` one of `confirmed`, `not reproduced`, `unconfirmed`, `single reading`,
2180
- `over budget`). Parse that rather than the text: the text is wrapped to the terminal (80 columns in a
2181
- pipe), groups one cause found in many files into one block, and ends in a tally line that starts
2182
- with `N errors, N warnings, N notes`. In the text, every finding ends in a `Docs:` line — its own
2183
- section of the CLI page (`utilities/cli#<check-id>`), or of the codemod page for a codemod note. `--format markdown` renders the same document as tables — for a
2184
- merge request note or a job summary, not for parsing.
2185
-
2186
- **`doctor --ignore <check,…>`** leaves the named checks out of the report, the tally, the exit code
2187
- and the `--code-quality` file. Use it only for a finding the repository has answered in a way
2188
- `doctor` cannot see — `no-agent-instructions` in CI where the instruction files are kept out of git,
2189
- `angular-build-splitting-off` under a patched builder. An id `doctor` has no check for is exit 2,
2190
- with the closest id suggested. `--min-severity` hides findings from the text
2191
- only; `--ignore` removes them.
2192
-
2193
- ### If you were asked why a suite is slow
559
+ - Advice that circulates and is wrong on Vitest
2194
560
 
2195
- ```bash
2196
- npx vitest-auto-spy perf # runs the suite once and reports; exits 0 without --gate
2197
- npx vitest-auto-spy perf src/some/dir # a path is passed through to Vitest as a file filter
2198
- ```
2199
-
2200
- It reads Vitest's own per-file phase timings through `TestModule.diagnostic()` — nothing here
2201
- parses terminal output — and reports six phases: `environment`, `import`, `tests`, `setup`,
2202
- `prepare` (all measured per file) and `transform` (measured once for the whole run before Vitest 5,
2203
- per file on 5, where it is the wait for Vite's transforms and `import`/`setup` are net of it). **The phase
2204
- totals are CPU time summed across every worker, not wall clock** — a report showing `20.29s of CPU
2205
- time` under `986ms wall clock` is not a bug, it is the work spread over workers.
2206
-
2207
- When `environment` dominates, the `perf-environment` finding names spec files that could run under
2208
- the `node` environment instead — but only the ones it could _prove_ reach no DOM: the spec, its
2209
- configured setup files, and every repository module any of them imports were all read, none
2210
- mentions a DOM name, and every package they import is on a short allowlist (`vitest`, `rxjs`,
2211
- `date-fns`, `lodash`, `zod`, …). Anything it could not resolve is reported **undecided**, never
2212
- assumed safe — do not treat an undecided file as a candidate, and do not add packages to that list
2213
- yourself; a false positive breaks someone's suite on `document is not defined`. When `import`
2214
- dominates, `perf-import` names specs that reach their subject through a barrel (`index`/`public-api`
2215
- re-exporting a whole directory). When `environment` + `setup` + `prepare` together dominate,
2216
- `perf-isolation` suggests `isolate: false` in the config it names, kept only if peak memory stays
2217
- acceptable; its `Docs:` line leads to the check's section of the CLI page, which links this
2218
- package's own memory measurements of that trade — read them before recommending the flag, since it
2219
- raises peak memory.
2220
-
2221
- Two more findings are about settings rather than files. `perf-environment-engine` fires when
2222
- `environment` dominates and a `vite(st).config.*` names `jsdom` while nothing in those configs
2223
- mentions `happy-dom`: measured on this package's own 117-file Angular suite, `happy-dom` is 23.2 s of
2224
- user CPU against jsdom's 26.5 s, and on a spec that builds a DOM and does nothing else the gap is
2225
- 253 ms against 119 ms per file. It is a swap, not a flag — `happy-dom` implements less of the
2226
- platform — so take one project at a time; the finding names the config that sets `jsdom`, and the
2227
- numbers stay in the docs section. `perf-workers` fires on a run over a minute of summed CPU
2228
- that declares no `maxWorkers`, and it is the one finding here about **memory**: one worker per core
2229
- is the default, resident memory measured at 1.42 GB plus ~155 MB per worker, and a cap of four costs
2230
- about 2.8 % of wall clock. The finding itself counts this machine's cores
2231
- (`os.availableParallelism()`) and suggests half of them as `maxWorkers` in the config it names; the
2232
- figures above are in the docs section, not in the message. Do not quote a worker count as
2233
- universally right — it is a property of the machine.
2234
-
2235
- **On Vitest 5, `perf` reads what Vitest 5 reports; an older Vitest prints exactly what it did before.**
2236
- The advice reads the configuration Vitest resolved (`isolate`, `pool`, `maxWorkers`, `environment`,
2237
- `fsModuleCache`) rather than the config text, and never advises against an option you set
2238
- explicitly. `perf-transform` fires when the wait for Vite's transforms is 30 % or more of the CPU time
2239
- with the module cache off, and advises `fsModuleCache: true` — kept between CI pipelines, or it only
2240
- helps locally. `perf-long-pole` names the file still running alone after every other lane went idle,
2241
- when that tail is at least 2 s and 30 % of the span. `perf-isolation` adds the workers spawned and
2242
- their summed start-up, with an "at least" wall-clock saving; `perf-workers` counts the lanes the run
2243
- used rather than the machine's cores; under `isolate: false`, `perf-heap` lists what each file added
2244
- to its lane's heap. Environment time is counted once per lane (`concurrencyId`) and value — not per
2245
- `workerId`, which Vitest 5 renews for every file even in a reused worker. A finding about a switch
2246
- also prints `perf-vitest-doctor`: confirm it with `npx vitest doctor`, Vitest's own A/B runner, not
2247
- this package's `doctor`. A bare run on Vitest 5 passes `--experimental.diagnostics=false` so Vitest's
2248
- own hints do not repeat these. The reporter rewrites a `partial: true` report every ~2 s while the run
2249
- goes, so a killed or timed-out run still leaves one: `perf` prints it under a warning and the gate
2250
- refuses it like a red run.
2251
-
2252
- **A bare `vitest run` is not every repository's suite, and `perf` refuses to pretend otherwise.**
2253
- Where the suite is assembled by something else — an Angular builder, an Nx target, a script that
2254
- generates a config per project — there is no root `vite(st).config.*`, the Vitest defaults sweep up
2255
- every `*.spec.*` in the tree with no globals and no aliases, and every file fails to collect. The
2256
- phase table that comes out of that is real seconds spent on nothing: measured on one such workspace,
2257
- 1 830 files, 29 s of wall clock and **zero** test bodies executed. `perf` checks for that shape
2258
- before it runs anything, and refuses any report in which no body finished (exit 2). Measure the
2259
- repository's own command instead:
561
+ <!-- /agents-outline -->
2260
562
 
2261
- ```bash
2262
- npx vitest-auto-spy perf --command 'npm test' # measure that command
2263
- npx vitest-auto-spy perf --command 'npm test -- {paths:--include=}' --gate
2264
- ```
563
+ ---
2265
564
 
2266
- `--command` puts `VITEST_AUTO_SPY_PERF_OUT` and `VITEST_AUTO_SPY_PERF_REPORTER` in that command's
2267
- environment; the configuration it reaches attaches the reporter itself (`reporters: perf ===
2268
- undefined ? ['default'] : ['default', perf]`), and the reporter writes nothing at all when the first
2269
- variable is unset, so it can be declared permanently — by name, `vitest-auto-spy/perf-reporter`. `{paths}` / `{paths:<prefix>}` is where the
2270
- files of a confirmation pass go.
2271
-
2272
- **`--gate` is the only part of this command that fails anything**, and three rules keep it honest.
2273
- It judges the `tests` phase alone, because the other five are the harness and the machine rather than
2274
- anybody's code. A file budget is counted in the median test **of the same run** — the largest of
2275
- `--max-file-tests` (2 000) median tests, `--factor` (10) × the median test for each test in the file,
2276
- and a `--max-file-ms` (5 000) floor that can only spare a file — which is what makes the verdict the
2277
- same on a loaded CI runner and on an idle laptop, and what keeps a large file of ordinary tests out of
2278
- it: the rule it replaced, `--factor` × the median **file**, flagged 0 files of a 2 023-file consumer
2279
- suite at ×1 slowdown and 19 at ×9, a 209-test service spec at 5 ms a test among them. A confirmed finding
2280
- also says why: the confirmation pass records a CPU profile of each suspect file and every one of its
2281
- test bodies, and the gate prints the slowest tests, the share in hooks against bodies, and where the
2282
- time went in the spec, in your code and by package. The report ends in two tables of what is over
2283
- budget and nothing else. And every candidate is re-measured on its own before it may fail anything: a
2284
- file that is fast when it has the machine to itself is reported as _not reproduced_, an `info` rather
2285
- than a finding. Without a way to re-measure, findings are warnings that fail nothing unless
2286
- `--no-confirm` says one reading is enough. Exit `1` is "over budget", exit `2` is "there was nothing
2287
- to judge" — including a red or unfinished (`partial`) suite, which the gate will not judge at all, since a failed test is
2288
- measured until its timeout and 30 s of timeout looks exactly like 30 s of slow code.
2289
-
2290
- `--json <path>` re-analyses a report an earlier `--out <path>` run wrote, instead of running Vitest
2291
- again. Full reference: <https://asdalexey.github.io/vitest-auto-spy/utilities/cli>.
2292
-
2293
- ### Migrating a suite off `jest-auto-spies` — run the codemod, then verify it
565
+ ## 19. Before you report success
2294
566
 
2295
- ```bash
2296
- npx vitest-auto-spy codemod # dry run: prints the diff, writes nothing
2297
- npx vitest-auto-spy codemod --write # apply
2298
- npx vitest-auto-spy codemod --verify # exits 1 on anything the transforms should have removed
2299
- ```
567
+ In [`agent-docs/checklist.md`](./agent-docs/checklist.md). Read it before reporting a task done: what to run, and the checks for a slow suite, a `jest-auto-spies` migration and a codemod over specs.
2300
568
 
2301
- Do not hand-edit a suite of migrated imports; the codemod knows which entry point exports each name
2302
- (it reads the installed package's export map) and it transposes `jest.Mock<R, [A]>` into the single
2303
- call signature Vitest takes — a plain rename compiles into the **reverse** meaning and nothing fails
2304
- until a call site disagrees. It leaves a `jest.*` member with no `vi` twin (`requireMock`,
2305
- `replaceProperty`, `createMockFromModule`, `jest.setTimeout`, `requireActual`) exactly as it was and
2306
- reports what to do instead, rather than guessing.
2307
-
2308
- `--verify` matches the **result** against the patterns the codemod removes, so it also catches what
2309
- the transforms declined to enter (a template literal, an unbalanced bracket) and a file somebody
2310
- migrated by hand. Run it after `--write`, and again after any manual clean-up. `--only` / `--skip`
2311
- select transforms by id, `--list` prints them. Full reference:
2312
- <https://asdalexey.github.io/vitest-auto-spy/utilities/codemod>.
2313
-
2314
- **Every rewrite is parsed before it is written.** The result goes through the project's own
2315
- `typescript` and its diagnostics are compared with the original's; a file the run would have broken
2316
- is reported as `codemod-broke-syntax` and **left exactly as it was**, so the run exits 1 with one
2317
- file named rather than a tree that no longer compiles. Where `typescript` is not installed the check
2318
- is skipped silently — it is a safety net, not an install instruction.
2319
-
2320
- It visits JavaScript specs too — `*.spec.js`, `*.test.jsx`, the `.cjs` / `.mjs` forms — because a
2321
- Jest suite that was never TypeScript is the suite with the most `jest.` in it. Two things it now
2322
- reports instead of rewriting into something wrong: `jest.fn<R, [A]>()` / `jest.spyOn<…>()` in a file
2323
- that imports from `@jest/globals` (`jest-mock-type-arguments` — `jest-mock` 29 already takes the
2324
- whole function type, so transposing a second time produces a return type of a return type), and
2325
- `.withArgs(…)` on a `vi.spyOn` chain (`jasmine-with-args-on-spy-on` — `vi.spyOn` has no
2326
- `calledWith`, and renaming it onto one produces a method that does not exist).
2327
-
2328
- Past 50 000 files the repository scan truncates and the run says so — _Nothing left to migrate_ off a
2329
- truncated list is a claim about a tree the tool never looked at. `VITEST_AUTO_SPY_SCAN_CAP` raises
2330
- the cap.
2331
-
2332
- ### If you are writing a codemod over specs
2333
-
2334
- Two traps, both found the hard way on rxjs-heavy code.
2335
-
2336
- **`String.prototype.replace` interprets `$` in the replacement.** `$&`, `` $` ``, `$'` and `$n` are
2337
- substitution patterns, and `$'` — "everything after the match" — is one character away from every
2338
- observable name in the codebase. A replacement containing `reloadAndSeekTo$'`
2339
- inserted the entire remainder of the file into itself and left an unterminated string; the only
2340
- thing that caught it was ESLint's `Parsing error`. Pass a function, which is never interpreted:
569
+ <!-- agents-outline: agent-docs/checklist.md -->
2341
570
 
2342
- ```ts
2343
- source.replace(from, () => to); // not source.replace(from, to)
2344
- ```
571
+ - If you were asked why a suite is slow
572
+ - Migrating a suite off `jest-auto-spies` — run the codemod, then verify it
573
+ - If you are writing a codemod over specs
2345
574
 
2346
- **`node.getStart()` excludes leading comments.** A codemod that replaces a range starting there
2347
- silently eats the `// eslint-disable-next-line` above the node. Use `node.getFullStart()`, or count
2348
- the comments before and after and compare against `HEAD`.
575
+ <!-- /agents-outline -->
2349
576
 
2350
577
  ---
2351
578