Bridging Spy<T> and T
Spy<T> is a mapped type. It drops #private / private members, so it is not assignable to T — which is correct (a spy is not the class) and a constant nuisance when an API asks for T. The fix is a named, documented view instead of an as any scattered through a suite:
import { asInstance, asSpy } from 'vitest-auto-spy';
asInstance(cartSpy); // Spy<CartService> → CartService, for APIs typed against the class
asSpy(TestBed.inject(CartService)); // CartService → Spy<CartService>, for the helpersBoth are the same object at runtime; only the view changes.
const store = createSpyFromClass(CartStore);
renderShallow(CartComponent, {
providers: [{ provide: CartStore, useValue: asInstance(store) }],
});Declare the variable as Spy<T> (which is what injectSpy(X) returns) rather than as T, and the bridges stay at the boundaries where an external API forces the other view.
A spy you can call with new
A runner mock (vi.fn()) rejects new as soon as it carries a mockReturnValue, so code under test that does new Foo() — a Worker, an IntersectionObserver, a hand-rolled client — cannot be served by one. createSpyClass returns a real constructor function whose instances are full auto-spies:
import { createSpyClass } from 'vitest-auto-spy';
import { mockValueProp } from 'vitest-auto-spy';
const WorkerSpy = createSpyClass(BackgroundWorker);
mockValueProp(globalThis, 'BackgroundWorker', WorkerSpy);
service.start();
expect(WorkerSpy.calls[0]).toEqual(['./task.js']);
WorkerSpy.instances[0].postMessage.mockReturnValue(undefined);| Member | What it holds |
|---|---|
calls | The arguments of every new (and plain call), in order |
instances | The Spy<T> produced by each construction, in order |
It takes the same optional second argument as createSpyFromClass, so each instance can be configured the usual way.
Which error means which direction
The compiler reports the Spy<T> / T mismatch in four different ways, and none of them contains both the words "spy" and "instance" — which is why the fix is hard to find from the message alone, and why the usual repair is a double assertion that also hides real mismatches.
| Message | Direction | Fix |
|---|---|---|
TS2352: … 'accessorSpies' is missing in type 'Router' | T → spy | asSpy(TestBed.inject(Router)) |
TS2739 / TS2740: Type 'Spy<X>' is missing the following properties from type 'X' | spy → T | asInstance(spy) |
TS2345: Argument of type 'Spy<X>' is not assignable to parameter of type 'X' | spy → T | asInstance(spy) |
is missing the following properties: _modalOpened, body, … (private names) | — | declare Spy<T>, not Mocked<T> |
TS2352 is the one a migrated suite hits everywhere at once: TestBed.inject(X) as Spy<X> is the jest-auto-spies idiom and is in every guide, and it only starts failing once the specs are compiled by the same toolchain as production code — a ts-jest setup with isolated-module semantics never type-checked it.
The last row is its own trap. Vitest's own Mocked<T> keeps T's private members, so the error lists private field names and reads as "the double is incomplete". It is not; the declaration is wrong. Spy<T> covers the public surface on purpose.
A generic class needs its type argument
TestBed.inject infers from the constructor, so FeatureFlagService<T = FeatureFlagDefaults> comes back as FeatureFlagService<any>. The any then spreads through Spy<> and surfaces as a mismatch between AddPromiseSpyMethods<unknown> and WithMockReturnValue<…>, eight levels deep, with nothing in the message about a missing type parameter.
const config = asSpy<FeatureFlagService>(TestBed.inject(FeatureFlagService));
const config = injectSpy<FeatureFlagService>(FeatureFlagService); // same, in AngularasInstances(...) — a whole argument list at once
factory = webSsoAuthCheckFactory(...asInstances(account, authCheck, domainEvents, storage), document);One wrapper per argument is not merely longer, it is discovered one argument at a time: TypeScript stops checking a call at the first argument that does not fit, so a factory taking five spies reports one TS2345, and the next only after the previous is fixed and tsc is run again. A non-spy in the list passes through unchanged, so a call that mixes spies with real values does not have to be split.
Overloads: Parameters reads the last signature
const cinemas = asSpy<VenuesService, { overload: 'first' }>(TestBed.inject(VenuesService));
const client = createSpyFromClass<VenuesService, { overload: 'first' }>(VenuesService);Parameters<F> and ReturnType<F> — and therefore the helpers a spy attaches — read the last overload of a method. On a generated API client (ng-openapi-gen, openapi-generator) that is observe: 'events', the signature nobody calls: nextWith(body) then stops compiling, demanding an HttpEvent<T>, with nothing in the message about overload order.
{ overload: 'first' } types the spy against the first signature instead. For a single method there is also Overload<Client['get'], 0>, which is what to put in a MockInstance<…> or a vi.fn<…>().
Spy<T>, not Mocked<T>
let modal: Spy<KdsModalService>; // ✅
let modal: Mocked<KdsModalService>; // ❌Mocked<T> is Vitest's own type and it intersects with T completely, private members included. Assigning a spy to it fails with Type 'Spy<…>' is missing the following properties: _modalOpened, body, rendererFactory, … — a list of private field names, from which it is impossible to guess that the declaration is what is wrong rather than the spy. The no-mocked-for-spy rule catches it mechanically.